From bc7f764efd0c13e5a125173257813c587b435723 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 16 Sep 2026 07:42:01 -0700 Subject: [PATCH 1/6] fix: index instance retention lookups Find expired actor instances through actor type and update time instead of scanning retained instances. Install the index in migration 10 before the existing polling-migration early return, and update doctor checks. Verify actual pruning plans on SQLite, PostgreSQL, and MySQL, including version-nine upgrades, interrupted installs, and multiple policies. See https://github.com/cardmagic/solid-objects-ruby/pull/69 --- CHANGELOG.md | 7 ++ docs/parity.md | 2 +- package.json | 4 +- src/doctor.ts | 4 +- src/schema.ts | 19 +++- test/dead-letters.test.ts | 4 +- test/doctor.test.ts | 2 +- test/instance-retention.test.ts | 168 ++++++++++++++++++++++++++++++++ 8 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 test/instance-retention.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a73b166..4a93b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Index opt-in instance expiration by actor type and update time so pruning + can find expired instances without scanning retained history. Install schema + migration 10 before running this version. Retention policies, timestamps, + pending-work protections, and deletion order are unchanged. + ## 0.15.0 - 2026-09-15 - Report transient process-heartbeat errors and keep retrying while effects run, diff --git a/docs/parity.md b/docs/parity.md index 69379e7..7410553 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -86,7 +86,7 @@ Ruby field names; this does not change runtime delivery semantics. | Additional supervised components | Native | `registerComponent()` builds, validates, runs, and stops application components with the runtime. | | Dead-letter inspection and retry | Native | `runtime.deadLetters` provides deny-by-default immutable inspection and idempotent durable retry linkage. | | Reconciliation reads | Native | Authorized cursor pages cover active, quiet, and orphaned instances; bounded state batches are migrated and deeply frozen. | -| Message, process, and opt-in instance retention | Native | Supervised scheduling bounds message and process growth; authorized manual APIs add preview and keep destructive instance expiration explicit. | +| Message, process, and opt-in instance retention | Native | Supervised scheduling bounds message and process growth; authorized manual APIs add preview and keep destructive instance expiration explicit. SQL instance pruning uses an index on actor type and update time. | | Doctor and schema verification | Native | Structured checks cover configuration, schema/version shape, adapter server versions, neutral-context policy probes, live roles, and a targeted round trip. | | CLI | Native | The packaged executable loads an application runtime and exposes start, diagnostics, processes, dead letters, reminders, and explicit retention pruning as JSON. | | Operator dashboard | Native | The opt-in `solid-objects/web` export provides Fetch and Node/Connect mounting, authorized runtime views and actions, session-backed CSRF, filtering, paging, charts, and immutable extension hooks. Matches the Ruby dashboard's own documented limits: no audit trail of admin actions, dead-letter retry is one at a time, and pause sets a flag rather than interrupting an in-flight turn. | diff --git a/package.json b/package.json index 99436e2..d912582 100644 --- a/package.json +++ b/package.json @@ -115,8 +115,8 @@ "test": "vitest run", "test:browser": "pnpm run build && playwright test", "test:coverage": "vitest run --coverage", - "test:postgresql": "vitest run test/postgresql.test.ts test/effect-recovery.test.ts", - "test:mysql": "vitest run test/mysql.test.ts test/effect-recovery.test.ts", + "test:postgresql": "vitest run test/postgresql.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts", + "test:mysql": "vitest run test/mysql.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts", "test:package": "node scripts/release-artifact-smoke.mjs", "test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts", "test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts", diff --git a/src/doctor.ts b/src/doctor.ts index ad77b93..ad65018 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -210,11 +210,11 @@ export class Doctor { message: `incompatible schema identity ${wrongIdentity.schema_identity}`, }) } - if (versions.join(",") !== "1,2,3,4,5,6,7,8,9") { + if (versions.join(",") !== "1,2,3,4,5,6,7,8,9,10") { return check({ name: "schema", status: "fail", - message: `expected schema migrations 1, 2, 3, 4, 5, 6, 7, 8, 9; found ${versions.join(", ")}`, + message: `expected schema migrations 1, 2, 3, 4, 5, 6, 7, 8, 9, 10; found ${versions.join(", ")}`, }) } return check({ diff --git a/src/schema.ts b/src/schema.ts index 5514d5c..9bc48d3 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -10,7 +10,8 @@ const OBSERVABLE_INVALIDATIONS_VERSION = 6 const KEYED_REMINDERS_VERSION = 7 const POLLING_INDEXES_VERSION = 8 const EFFECT_RECOVERY_VERSION = 9 -const LATEST_VERSION = EFFECT_RECOVERY_VERSION +const INSTANCE_RETENTION_INDEX_VERSION = 10 +const LATEST_VERSION = INSTANCE_RETENTION_INDEX_VERSION export async function installSchema(options: { connection: DatabaseConnection @@ -333,6 +334,22 @@ export async function installSchema(options: { }) } + if (!installedVersions.has(INSTANCE_RETENTION_INDEX_VERSION)) { + await createIndex({ + connection, + family, + table: table("instances"), + name: `${prefix}instances_retention`, + columns: "actor_type, updated_at_ms, id", + }) + await recordMigration({ + connection, + table: table("schema_migrations"), + version: INSTANCE_RETENTION_INDEX_VERSION, + schemaIdentity, + }) + } + if (installedVersions.has(POLLING_INDEXES_VERSION)) return const pollingIndexes = [ ["effects", `${prefix}effects_poll`, "status, available_at_ms, id"], diff --git a/test/dead-letters.test.ts b/test/dead-letters.test.ts index 89f4228..9715529 100644 --- a/test/dead-letters.test.ts +++ b/test/dead-letters.test.ts @@ -151,7 +151,7 @@ describe("schema migrations", () => { const broadcastColumns = await runtime.settings.database.connection((connection) => connection.all<{ name: string }>("PRAGMA table_info(solid_objects_broadcasts)"), ) - expect(versions.map(({ version }) => Number(version))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) + expect(versions.map(({ version }) => Number(version))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) expect(deadLetterColumns.map(({ name }) => name)).toContain("retried_message_id") expect(broadcastColumns.map(({ name }) => name)).toContain("invalidations") expect(await installedPollingIndexes(runtime)).toEqual(POLLING_INDEX_COLUMNS) @@ -184,7 +184,7 @@ describe("schema migrations", () => { "SELECT version FROM solid_objects_schema_migrations ORDER BY version", ), ) - expect(versions.map(({ version }) => Number(version))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) + expect(versions.map(({ version }) => Number(version))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) expect(await installedPollingIndexes(runtime)).toEqual(POLLING_INDEX_COLUMNS) }) diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 8c21e1b..d1af544 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -27,7 +27,7 @@ describe("runtime doctor", () => { expect(check(report, "configuration").status).toBe("pass") expect(check(report, "schema")).toMatchObject({ status: "pass", - details: { versions: [1, 2, 3, 4, 5, 6, 7, 8, 9] }, + details: { versions: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }, }) expect(check(report, "authorization").status).toBe("pass") expect(check(report, "database").status).toBe("pass") diff --git a/test/instance-retention.test.ts b/test/instance-retention.test.ts new file mode 100644 index 0000000..64d2654 --- /dev/null +++ b/test/instance-retention.test.ts @@ -0,0 +1,168 @@ +import { afterEach, expect, it } from "vitest" +import { mysql } from "../src/database/mysql.js" +import { postgresql } from "../src/database/postgresql.js" +import { sqlite } from "../src/database/sqlite.js" +import type { + Database, + DatabaseConnection, + DatabaseTransactionOptions, +} from "../src/database/types.js" +import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" + +const PREFIX = "retention_index_test_" +const DAY = 24 * 60 * 60 * 1_000 +let runtime: SolidObjectsRuntime | undefined + +afterEach(async () => { + try { + await runtime?.repository.resetForTesting() + } finally { + await runtime?.close() + runtime = undefined + } +}) + +it.each(["fresh installation", "version-nine upgrade", "interrupted upgrade", "multiple policies"])( + "uses the retention index to find rare expired instances after %s", + async (scenario) => { + const database = new RetentionPlanDatabase(testDatabase()) + const policies: Record = { RetentionActor: DAY } + if (scenario === "multiple policies") policies.OtherRetentionActor = 2 * DAY + runtime = createRuntime({ + database, + tableNamePrefix: PREFIX, + authorizeAdministration: () => true, + instanceRetentionByActorType: policies, + }) + await runtime.install() + await database.transaction(async (connection) => { + const now = await connection.nowMilliseconds() + for (let offset = 0; offset < 2_000; offset += 250) { + const parameters = Array.from({ length: 250 }, (_, index) => { + const identity = String(offset + index).padStart(8, "0") + return [identity, "RetentionActor", identity, "{}", 1, now, now] + }) + await connection.run( + `INSERT INTO ${PREFIX}instances + (id, actor_type, actor_id, state, state_version, created_at_ms, updated_at_ms) + VALUES ${parameters.map(() => "(?, ?, ?, ?, ?, ?, ?)").join(", ")}`, + parameters.flat(), + ) + } + await connection.run(`UPDATE ${PREFIX}instances SET updated_at_ms = ? WHERE id = ?`, [ + now - 2 * DAY, + "00001999", + ]) + if (scenario === "multiple policies") { + await connection.run( + `INSERT INTO ${PREFIX}instances + (id, actor_type, actor_id, state, state_version, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ["other", "OtherRetentionActor", "other", "{}", 1, now - 3 * DAY, now - 3 * DAY], + ) + } + }) + if (scenario === "version-nine upgrade" || scenario === "interrupted upgrade") { + await database.connection(async (connection) => { + if (scenario === "version-nine upgrade") { + const tableClause = database.family === "mysql" ? ` ON ${PREFIX}instances` : "" + await connection.run(`DROP INDEX ${PREFIX}instances_retention${tableClause}`) + } + await connection.run(`DELETE FROM ${PREFIX}schema_migrations WHERE version = 10`) + }) + await runtime.install() + await runtime.install() + } + await database.connection((connection) => + connection.run( + `${database.family === "mysql" ? "ANALYZE TABLE" : "ANALYZE"} ${PREFIX}instances`, + ), + ) + + expect(await runtime.retention.preview({ target: "instances" })).toEqual({ + target: "instances", + count: Object.keys(policies).length, + }) + expect(await runtime.retention.prune({ target: "instances" })).toEqual({ + target: "instances", + count: Object.keys(policies).length, + }) + expect(database.plans).toHaveLength(2) + const planColumn = + database.family === "mysql" ? "key" : database.family === "sqlite" ? "detail" : "QUERY PLAN" + for (const plan of database.plans) { + expect(plan.map((row) => String(row[planColumn])).join("\n")).toContain( + `${PREFIX}instances_retention`, + ) + } + const remaining = await database.connection((connection) => + connection.get<{ count: number | bigint }>( + `SELECT COUNT(*) AS count FROM ${PREFIX}instances`, + ), + ) + expect(Number(remaining?.count)).toBe(1_999) + const versions = await database.connection((connection) => + connection.all<{ version: number | bigint }>( + `SELECT version FROM ${PREFIX}schema_migrations ORDER BY version`, + ), + ) + expect(versions.map(({ version }) => Number(version))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + }, + 30_000, +) + +function testDatabase(): Database { + const connectionString = process.env.SOLID_OBJECTS_DATABASE_URL + if (!connectionString) return sqlite({ path: ":memory:" }) + if (connectionString.startsWith("mysql:")) return mysql({ connectionString }) + if (connectionString.startsWith("postgresql:")) return postgresql({ connectionString }) + throw new Error("instance retention tests require a mysql: or postgresql: database URL") +} + +class RetentionPlanDatabase implements Database { + readonly family: Database["family"] + readonly schemaIdentity: string + readonly plans: Record[][] = [] + + constructor(private readonly database: Database) { + this.family = database.family + this.schemaIdentity = database.schemaIdentity + } + + connection( + callback: (connection: DatabaseConnection) => Promise, + ): Promise { + return this.database.connection(callback) + } + + transaction( + callback: (connection: DatabaseConnection) => Promise, + options?: DatabaseTransactionOptions, + ): Promise { + return this.database.transaction( + (connection) => + callback({ + run: (sql, parameters) => connection.run(sql, parameters), + get: (sql: string, parameters?: readonly unknown[]) => + connection.get(sql, parameters), + all: async (sql: string, parameters?: readonly unknown[]) => { + if (sql.startsWith(`SELECT id FROM ${PREFIX}instances WHERE`)) { + const explain = { + sqlite: "EXPLAIN QUERY PLAN", + mysql: "EXPLAIN FORMAT=TRADITIONAL", + postgresql: "EXPLAIN", + }[this.family] + this.plans.push(await connection.all(`${explain} ${sql}`, parameters)) + } + return connection.all(sql, parameters) + }, + nowMilliseconds: () => connection.nowMilliseconds(), + }), + options, + ) + } + + close(): Promise { + return this.database.close() + } +} From a3245511972725d18f5c3576ef38d2380f338368 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 16 Sep 2026 07:47:54 -0700 Subject: [PATCH 2/6] test: separate retention plans from policy checks Older SQLite versions may choose a primary-key scan for an OR across multiple actor policies even with the retention index. Keep explicit index-selection regressions for fresh and upgraded single-policy data. Verify distinct cutoffs and unconfigured actors in a separate behavior test without requiring an optimizer choice for that different workload. The full Node 24.4 suite and all new MySQL and PostgreSQL cases pass. --- test/instance-retention.test.ts | 62 +++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/test/instance-retention.test.ts b/test/instance-retention.test.ts index 64d2654..d99a5c8 100644 --- a/test/instance-retention.test.ts +++ b/test/instance-retention.test.ts @@ -22,17 +22,15 @@ afterEach(async () => { } }) -it.each(["fresh installation", "version-nine upgrade", "interrupted upgrade", "multiple policies"])( +it.each(["fresh installation", "version-nine upgrade", "interrupted upgrade"])( "uses the retention index to find rare expired instances after %s", async (scenario) => { const database = new RetentionPlanDatabase(testDatabase()) - const policies: Record = { RetentionActor: DAY } - if (scenario === "multiple policies") policies.OtherRetentionActor = 2 * DAY runtime = createRuntime({ database, tableNamePrefix: PREFIX, authorizeAdministration: () => true, - instanceRetentionByActorType: policies, + instanceRetentionByActorType: { RetentionActor: DAY }, }) await runtime.install() await database.transaction(async (connection) => { @@ -40,7 +38,7 @@ it.each(["fresh installation", "version-nine upgrade", "interrupted upgrade", "m for (let offset = 0; offset < 2_000; offset += 250) { const parameters = Array.from({ length: 250 }, (_, index) => { const identity = String(offset + index).padStart(8, "0") - return [identity, "RetentionActor", identity, "{}", 1, now, now] + return [identity, "RetentionActor", identity, "{}", 1, now - 3 * DAY, now] }) await connection.run( `INSERT INTO ${PREFIX}instances @@ -53,14 +51,6 @@ it.each(["fresh installation", "version-nine upgrade", "interrupted upgrade", "m now - 2 * DAY, "00001999", ]) - if (scenario === "multiple policies") { - await connection.run( - `INSERT INTO ${PREFIX}instances - (id, actor_type, actor_id, state, state_version, created_at_ms, updated_at_ms) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ["other", "OtherRetentionActor", "other", "{}", 1, now - 3 * DAY, now - 3 * DAY], - ) - } }) if (scenario === "version-nine upgrade" || scenario === "interrupted upgrade") { await database.connection(async (connection) => { @@ -81,11 +71,11 @@ it.each(["fresh installation", "version-nine upgrade", "interrupted upgrade", "m expect(await runtime.retention.preview({ target: "instances" })).toEqual({ target: "instances", - count: Object.keys(policies).length, + count: 1, }) expect(await runtime.retention.prune({ target: "instances" })).toEqual({ target: "instances", - count: Object.keys(policies).length, + count: 1, }) expect(database.plans).toHaveLength(2) const planColumn = @@ -111,6 +101,48 @@ it.each(["fresh installation", "version-nine upgrade", "interrupted upgrade", "m 30_000, ) +it("preserves distinct actor policies and unconfigured instances with the retention index", async () => { + const database = testDatabase() + runtime = createRuntime({ + database, + tableNamePrefix: PREFIX, + authorizeAdministration: () => true, + instanceRetentionByActorType: { RetentionActor: DAY, OtherRetentionActor: 2 * DAY }, + }) + await runtime.install() + await database.transaction(async (connection) => { + const now = await connection.nowMilliseconds() + const cases = [ + { identity: "expired", actorType: "RetentionActor", age: 2 * DAY }, + { identity: "recent", actorType: "RetentionActor", age: DAY / 2 }, + { identity: "other-expired", actorType: "OtherRetentionActor", age: 3 * DAY }, + { identity: "other-retained", actorType: "OtherRetentionActor", age: 1.5 * DAY }, + { identity: "unconfigured", actorType: "UnconfiguredActor", age: 3 * DAY }, + ] + for (const { identity, actorType, age } of cases) { + await connection.run( + `INSERT INTO ${PREFIX}instances + (id, actor_type, actor_id, state, state_version, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [identity, actorType, identity, "{}", 1, now - 3 * DAY, now - age], + ) + } + }) + + expect(await runtime.retention.preview({ target: "instances" })).toEqual({ + target: "instances", + count: 2, + }) + expect(await runtime.retention.prune({ target: "instances" })).toEqual({ + target: "instances", + count: 2, + }) + const remaining = await database.connection((connection) => + connection.all<{ id: string }>(`SELECT id FROM ${PREFIX}instances ORDER BY id`), + ) + expect(remaining.map(({ id }) => id)).toEqual(["other-retained", "recent", "unconfigured"]) +}, 30_000) + function testDatabase(): Database { const connectionString = process.env.SOLID_OBJECTS_DATABASE_URL if (!connectionString) return sqlite({ path: ":memory:" }) From a03c0ac6e9da8cfd9551ea802e1537c2e3e100cf Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 16 Sep 2026 07:52:51 -0700 Subject: [PATCH 3/6] test: clarify retention plan types and setup Name the query-plan fields emitted by each supported database and infer forwarded query arguments from DatabaseConnection. Flatten the upgrade setup so index removal and migration reset are explicit sequential steps. --- test/instance-retention.test.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/test/instance-retention.test.ts b/test/instance-retention.test.ts index d99a5c8..b585df0 100644 --- a/test/instance-retention.test.ts +++ b/test/instance-retention.test.ts @@ -11,6 +11,7 @@ import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" const PREFIX = "retention_index_test_" const DAY = 24 * 60 * 60 * 1_000 +type RetentionQueryPlanRow = { key?: string | null; detail?: string; "QUERY PLAN"?: string } let runtime: SolidObjectsRuntime | undefined afterEach(async () => { @@ -52,14 +53,16 @@ it.each(["fresh installation", "version-nine upgrade", "interrupted upgrade"])( "00001999", ]) }) - if (scenario === "version-nine upgrade" || scenario === "interrupted upgrade") { + if (scenario === "version-nine upgrade") { await database.connection(async (connection) => { - if (scenario === "version-nine upgrade") { - const tableClause = database.family === "mysql" ? ` ON ${PREFIX}instances` : "" - await connection.run(`DROP INDEX ${PREFIX}instances_retention${tableClause}`) - } - await connection.run(`DELETE FROM ${PREFIX}schema_migrations WHERE version = 10`) + const tableClause = database.family === "mysql" ? ` ON ${PREFIX}instances` : "" + await connection.run(`DROP INDEX ${PREFIX}instances_retention${tableClause}`) }) + } + if (scenario === "version-nine upgrade" || scenario === "interrupted upgrade") { + await database.connection((connection) => + connection.run(`DELETE FROM ${PREFIX}schema_migrations WHERE version = 10`), + ) await runtime.install() await runtime.install() } @@ -154,7 +157,7 @@ function testDatabase(): Database { class RetentionPlanDatabase implements Database { readonly family: Database["family"] readonly schemaIdentity: string - readonly plans: Record[][] = [] + readonly plans: RetentionQueryPlanRow[][] = [] constructor(private readonly database: Database) { this.family = database.family @@ -175,16 +178,17 @@ class RetentionPlanDatabase implements Database { (connection) => callback({ run: (sql, parameters) => connection.run(sql, parameters), - get: (sql: string, parameters?: readonly unknown[]) => - connection.get(sql, parameters), - all: async (sql: string, parameters?: readonly unknown[]) => { + get: (sql, parameters) => connection.get(sql, parameters), + all: async (sql, parameters) => { if (sql.startsWith(`SELECT id FROM ${PREFIX}instances WHERE`)) { const explain = { sqlite: "EXPLAIN QUERY PLAN", mysql: "EXPLAIN FORMAT=TRADITIONAL", postgresql: "EXPLAIN", }[this.family] - this.plans.push(await connection.all(`${explain} ${sql}`, parameters)) + this.plans.push( + await connection.all(`${explain} ${sql}`, parameters), + ) } return connection.all(sql, parameters) }, From 3254be716b1fed96998764b150774b4f0affaa36 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 16 Sep 2026 07:53:54 -0700 Subject: [PATCH 4/6] test: preserve database callback signatures Forward get directly and derive the all argument tuple from DatabaseConnection so generic callbacks keep their explicit native contract. --- test/instance-retention.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/instance-retention.test.ts b/test/instance-retention.test.ts index b585df0..d7f0188 100644 --- a/test/instance-retention.test.ts +++ b/test/instance-retention.test.ts @@ -178,8 +178,10 @@ class RetentionPlanDatabase implements Database { (connection) => callback({ run: (sql, parameters) => connection.run(sql, parameters), - get: (sql, parameters) => connection.get(sql, parameters), - all: async (sql, parameters) => { + get: connection.get.bind(connection), + all: async ( + ...[sql, parameters]: Parameters + ) => { if (sql.startsWith(`SELECT id FROM ${PREFIX}instances WHERE`)) { const explain = { sqlite: "EXPLAIN QUERY PLAN", From 9804e564bc9d1882f5899100d8d280493cd91f2f Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 16 Sep 2026 07:56:58 -0700 Subject: [PATCH 5/6] test: verify retention preview query plans Capture connection reads as well as transactional candidate queries and independently require the preview and pruning plans to use the retention index. --- test/instance-retention.test.ts | 69 +++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/test/instance-retention.test.ts b/test/instance-retention.test.ts index d7f0188..046693d 100644 --- a/test/instance-retention.test.ts +++ b/test/instance-retention.test.ts @@ -80,10 +80,11 @@ it.each(["fresh installation", "version-nine upgrade", "interrupted upgrade"])( target: "instances", count: 1, }) - expect(database.plans).toHaveLength(2) + expect(database.previewPlans).toHaveLength(1) + expect(database.pruningPlans).toHaveLength(2) const planColumn = database.family === "mysql" ? "key" : database.family === "sqlite" ? "detail" : "QUERY PLAN" - for (const plan of database.plans) { + for (const plan of [...database.previewPlans, ...database.pruningPlans]) { expect(plan.map((row) => String(row[planColumn])).join("\n")).toContain( `${PREFIX}instances_retention`, ) @@ -157,7 +158,8 @@ function testDatabase(): Database { class RetentionPlanDatabase implements Database { readonly family: Database["family"] readonly schemaIdentity: string - readonly plans: RetentionQueryPlanRow[][] = [] + readonly previewPlans: RetentionQueryPlanRow[][] = [] + readonly pruningPlans: RetentionQueryPlanRow[][] = [] constructor(private readonly database: Database) { this.family = database.family @@ -167,7 +169,7 @@ class RetentionPlanDatabase implements Database { connection( callback: (connection: DatabaseConnection) => Promise, ): Promise { - return this.database.connection(callback) + return this.database.connection((connection) => callback(this.recordingConnection(connection))) } transaction( @@ -175,27 +177,7 @@ class RetentionPlanDatabase implements Database { options?: DatabaseTransactionOptions, ): Promise { return this.database.transaction( - (connection) => - callback({ - run: (sql, parameters) => connection.run(sql, parameters), - get: connection.get.bind(connection), - all: async ( - ...[sql, parameters]: Parameters - ) => { - if (sql.startsWith(`SELECT id FROM ${PREFIX}instances WHERE`)) { - const explain = { - sqlite: "EXPLAIN QUERY PLAN", - mysql: "EXPLAIN FORMAT=TRADITIONAL", - postgresql: "EXPLAIN", - }[this.family] - this.plans.push( - await connection.all(`${explain} ${sql}`, parameters), - ) - } - return connection.all(sql, parameters) - }, - nowMilliseconds: () => connection.nowMilliseconds(), - }), + (connection) => callback(this.recordingConnection(connection)), options, ) } @@ -203,4 +185,41 @@ class RetentionPlanDatabase implements Database { close(): Promise { return this.database.close() } + + private recordingConnection(connection: DatabaseConnection): DatabaseConnection { + const explainStatement = { + sqlite: "EXPLAIN QUERY PLAN", + mysql: "EXPLAIN FORMAT=TRADITIONAL", + postgresql: "EXPLAIN", + }[this.family] + const explain = (options: { + sql: string + parameters: Parameters[1] + }) => + connection.all( + `${explainStatement} ${options.sql}`, + options.parameters, + ) + + return { + run: (sql, parameters) => connection.run(sql, parameters), + get: async ( + ...[sql, parameters]: Parameters + ) => { + if (sql.startsWith(`SELECT COUNT(*) AS count FROM ${PREFIX}instances WHERE`)) { + this.previewPlans.push(await explain({ sql, parameters })) + } + return connection.get(sql, parameters) + }, + all: async ( + ...[sql, parameters]: Parameters + ) => { + if (sql.startsWith(`SELECT id FROM ${PREFIX}instances WHERE`)) { + this.pruningPlans.push(await explain({ sql, parameters })) + } + return connection.all(sql, parameters) + }, + nowMilliseconds: () => connection.nowMilliseconds(), + } + } } From b6cac4be71d1eaef4c93d33c9cb95d9350a3c6a4 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Wed, 16 Sep 2026 08:21:03 -0700 Subject: [PATCH 6/6] chore: prepare version 0.15.1 Align the package and runtime versions and date the retention index release notes for the patch release. --- CHANGELOG.md | 2 +- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a93b3b..d04a688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.15.1 - 2026-09-16 - Index opt-in instance expiration by actor type and update time so pruning can find expired instances without scanning retained history. Install schema diff --git a/package.json b/package.json index d912582..cb45151 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.15.0", + "version": "0.15.1", "description": "Race-free realtime state per application identity, backed by your SQL database", "type": "module", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index 92d00d6..f8ec2c0 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.15.0" +export const VERSION = "0.15.1"