From 3aba525802cdfcceeb28fa2e31bdac29965315e5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 04:13:40 +0000 Subject: [PATCH 1/3] feat(runtime)!: refuse to open a fresh SQLite store beside a pre-#201 file --- .changeset/remove-legacy-sqlite-state.md | 2 +- packages/rsc-runtime/src/state/sqlite.ts | 19 +++++++++++-- .../rsc-runtime/tests/state-sqlite.test.ts | 27 ++++++++++++++++++- .../docs/en/reference/runtime-environment.mdx | 14 +++++----- .../docs/zh/reference/runtime-environment.mdx | 8 +++--- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/.changeset/remove-legacy-sqlite-state.md b/.changeset/remove-legacy-sqlite-state.md index 3b1ba392f..9aa443bef 100644 --- a/.changeset/remove-legacy-sqlite-state.md +++ b/.changeset/remove-legacy-sqlite-state.md @@ -2,4 +2,4 @@ "@agent-bundle/runtime": minor --- -Stop adopting pre-#201 durable SQLite state stores in `createSqliteStateDriver`: a root-mode store still named `-<12 hex of utf8(id)>.sqlite` is left on disk unread and a fresh `-.sqlite` store opens beside it, and a journal table without a `result_state` column is no longer upgraded in place but rejected on open with a typed `corrupt` error. Delete or recreate old stores before upgrading. (#837) +Stop adopting pre-#201 durable SQLite state stores in `createSqliteStateDriver`: a root-mode store still named `-<12 hex of utf8(id)>.sqlite` is no longer renamed to `-.sqlite` and adopted, and a journal table without a `result_state` column is no longer upgraded in place but rejected on open with a typed `corrupt` error. Delete or recreate old stores before upgrading. (#837) diff --git a/packages/rsc-runtime/src/state/sqlite.ts b/packages/rsc-runtime/src/state/sqlite.ts index fc6db1e23..24f5c5935 100644 --- a/packages/rsc-runtime/src/state/sqlite.ts +++ b/packages/rsc-runtime/src/state/sqlite.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; // node:sqlite emits an ExperimentalWarning on load (documented in the README): // the module is Node's built-in SQLite binding, stable enough for Node >= 22.13 @@ -313,6 +313,10 @@ const recordFromRow = (definitionId: string, row: JournalRow): AgentStateJournal const sanitizedFileName = (definitionId: string): string => `${definitionId.replace(/[^a-zA-Z0-9._-]+/gu, '-')}-${createHash('sha256').update(definitionId, 'utf8').digest('hex').slice(0, 16)}.sqlite`; +/** The pre-#201 root-mode name, detected only to refuse opening a fresh store beside it. */ +const pre201FileName = (definitionId: string): string => + `${definitionId.replace(/[^a-zA-Z0-9._-]+/gu, '-')}-${Buffer.from(definitionId, 'utf8').toString('hex').slice(0, 12)}.sqlite`; + class SqliteConnection extends Context.Service()( '@agent-bundle/runtime/state/SqliteConnection', ) {} @@ -1056,7 +1060,18 @@ export const createSqliteStateDriver = (options: SqliteStateDriverOptions): Agen ); } if (options.file !== undefined) return resolve(options.file); - return resolve(join(options.root as string, sanitizedFileName(definition.id))); + const current = resolve(join(options.root as string, sanitizedFileName(definition.id))); + const pre201 = resolve(join(options.root as string, pre201FileName(definition.id))); + if (!existsSync(current) && existsSync(pre201)) { + throw new AgentStateError( + 'corrupt', + `State '${definition.id}' found a store at '${pre201}' under the pre-#201 file name, which this ` + + `runtime no longer reads, and none at '${current}'. Move that file and its -wal/-shm sidecars ` + + 'out of the state root to keep a copy, or delete them; the next open then creates a fresh, ' + + 'empty store. It is not adopted or migrated.', + ); + } + return current; }, true), ); const connection = Effect.acquireRelease( diff --git a/packages/rsc-runtime/tests/state-sqlite.test.ts b/packages/rsc-runtime/tests/state-sqlite.test.ts index 6a5bf255e..b9f5c6691 100644 --- a/packages/rsc-runtime/tests/state-sqlite.test.ts +++ b/packages/rsc-runtime/tests/state-sqlite.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; @@ -145,6 +145,31 @@ describe('sqlite driver storage behavior', () => { await expect(first.read()).rejects.toMatchObject({ code: 'store-closed' }); })); + it('refuses to open a fresh root store beside one under the pre-#201 file name', () => + withRoot(async (root) => { + const definition = counterDefinition(); + const pre201Name = `state-sqlite-test-counter-${Buffer.from(definition.id, 'utf8').toString('hex').slice(0, 12)}.sqlite`; + const pre201File = join(root, pre201Name); + await writeFile(pre201File, 'pre-#201 store'); + + const refusal = createSqliteStateDriver({ root }).open(definition); + await expect(refusal).rejects.toMatchObject({ code: 'corrupt', name: 'AgentStateError' }); + await expect(refusal).rejects.toThrow(`found a store at '${pre201File}' under the pre-#201 file name`); + await expect(refusal).rejects.toThrow('Move that file and its -wal/-shm sidecars out of the state root'); + expect(await readdir(root)).toEqual([pre201Name]); + expect(await readFile(pre201File, 'utf8')).toBe('pre-#201 store'); + + await rm(pre201File); + const store = await createSqliteStateDriver({ root }).open(definition); + await store.dispatch('bumped', { by: 1 }, { idempotencyKey: 'k1' }); + await store.close(); + // Once the current store exists it is authoritative; a pre-#201 file beside it is not consulted. + await writeFile(pre201File, 'pre-#201 store'); + const reopened = await createSqliteStateDriver({ root }).open(definition); + await expect(reopened.read()).resolves.toEqual({ revision: 1, state: { count: 1 } }); + await reopened.close(); + })); + it('keeps the original commit input while migrating each committed result', () => withRoot(async (root) => { const file = join(root, 'migrating-reset.sqlite'); diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 559b2ec83..11a4488cc 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -129,18 +129,20 @@ The driver reads only the layout it writes today. It neither adopts nor migrates before that layout, in either of two ways. A database under the pre-#201 file name, whose suffix was the state id's own leading bytes in hex -rather than a SHA-256 digest, is never opened. The driver leaves the old file untouched and -creates a new, empty store beside it under the current name, so the old data is still on disk and -simply unread. +rather than a SHA-256 digest, is never opened. When a root-mode store finds that file and no +database under the current name, the open fails with a `corrupt` `AgentStateError` that names the +old file, instead of creating an empty store beside it. The old file is left untouched. Once a +database under the current name exists, the old file is not consulted. A database under the current file name whose `agent_state_journal` table differs from the one the driver creates fails at open with a `corrupt` `AgentStateError` naming the table. Column names, their order, and their `NOT NULL` flags all count, so a journal whose `result_state` column is nullable, as an older runtime's `ALTER TABLE` left it, is rejected before any row is read. -Recover from either by deleting the stale database files, each `*.sqlite` plus its `-wal` and -`-shm` sidecars, or the whole state root. The next launch creates a fresh store at the definition's -initial state. Nothing reconstructs the old history. +Recover from either by moving the stale database files out of the state root to keep a copy, or +deleting them: each `*.sqlite` plus its `-wal` and `-shm` sidecars, or the whole state root. The +next launch creates a fresh store at the definition's initial state. Nothing reconstructs the old +history. ## Next diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index 17cebac78..04410d613 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -112,14 +112,16 @@ Workbench 路由调用会把 `AGENT_BUNDLE_STATE_ROOT` 固定为 `/.age 驱动只读取它今天写出的布局。对早于该布局写出的存储,它既不接管也不迁移,具体有两种情形。 使用 #201 之前文件名的数据库(后缀是状态 id 自身起始字节的十六进制,而不是 SHA-256 摘要)永远不会被 -打开。驱动原样保留旧文件,并在旁边按当前文件名新建一个空存储,因此旧数据仍在磁盘上,只是不再被读取。 +打开。根目录模式的存储若发现该文件、且当前文件名下没有数据库,打开会以点名旧文件的 `corrupt` +`AgentStateError` 失败,而不是在旁边新建一个空存储。旧文件原样保留。一旦当前文件名下已有数据库, +旧文件就不再被检查。 使用当前文件名、但 `agent_state_journal` 表与驱动所创建的不一致的数据库,会在打开时以点名该表的 `corrupt` `AgentStateError` 失败。列名、列顺序与各列的 `NOT NULL` 标志都参与比较,因此像旧版运行时用 `ALTER TABLE` 留下的可空 `result_state` 列,会在读取任何行之前就被拒绝。 -两种情形的恢复方式相同:删除过期的数据库文件(每个 `*.sqlite` 及其 `-wal`、`-shm` 附属文件),或删除 -整个状态根目录。下次启动会按定义的初始状态新建存储。旧历史不会被任何机制重建。 +两种情形的恢复方式相同:把过期的数据库文件移出状态根目录以保留副本,或将其删除(每个 `*.sqlite` 及其 +`-wal`、`-shm` 附属文件,或整个状态根目录)。下次启动会按定义的初始状态新建存储。旧历史不会被任何机制重建。 ## 下一步 From 6c327addf3df7942429fdd2a0fb049f9bf9ef650 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 04:14:43 +0000 Subject: [PATCH 2/3] chore: changeset for #854 --- .changeset/refuse-pre201-sqlite-store.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/refuse-pre201-sqlite-store.md diff --git a/.changeset/refuse-pre201-sqlite-store.md b/.changeset/refuse-pre201-sqlite-store.md new file mode 100644 index 000000000..a1496ab72 --- /dev/null +++ b/.changeset/refuse-pre201-sqlite-store.md @@ -0,0 +1,5 @@ +--- +"@agent-bundle/runtime": minor +--- + +Fail `createSqliteStateDriver({ root }).open()` from `@agent-bundle/runtime/state/sqlite` with a typed `corrupt` `AgentStateError` when the root holds a pre-#201 `-<12 hex of utf8(id)>.sqlite` store and no store under the current name. Previously an empty store opened beside it silently. The error names the old file: move it and its `-wal`/`-shm` sidecars out of the state root, or delete them, and the next open creates a fresh store. The old store is never adopted or migrated. (#854) From c333c80c9dfcbb42c8f3448c3d576bacac0c2948 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 25 Sep 2026 04:17:52 +0000 Subject: [PATCH 3/3] chore: align the #837 changeset recovery wording --- .changeset/remove-legacy-sqlite-state.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/remove-legacy-sqlite-state.md b/.changeset/remove-legacy-sqlite-state.md index 9aa443bef..82e11aa0e 100644 --- a/.changeset/remove-legacy-sqlite-state.md +++ b/.changeset/remove-legacy-sqlite-state.md @@ -2,4 +2,4 @@ "@agent-bundle/runtime": minor --- -Stop adopting pre-#201 durable SQLite state stores in `createSqliteStateDriver`: a root-mode store still named `-<12 hex of utf8(id)>.sqlite` is no longer renamed to `-.sqlite` and adopted, and a journal table without a `result_state` column is no longer upgraded in place but rejected on open with a typed `corrupt` error. Delete or recreate old stores before upgrading. (#837) +Stop adopting pre-#201 durable SQLite state stores in `createSqliteStateDriver`: a root-mode store still named `-<12 hex of utf8(id)>.sqlite` is no longer renamed to `-.sqlite` and adopted, and a journal table without a `result_state` column is no longer upgraded in place but rejected on open with a typed `corrupt` error. Move old stores and their `-wal`/`-shm` sidecars out of the state root, or delete them, before upgrading. (#837)