Skip to content

Commit 184ff02

Browse files
feat(runtime)!: refuse to open a fresh SQLite store beside a pre-#201 file (#854)
* feat(runtime)!: refuse to open a fresh SQLite store beside a pre-#201 file * chore: changeset for #854 * chore: align the #837 changeset recovery wording
1 parent 78d75f2 commit 184ff02

6 files changed

Lines changed: 62 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@agent-bundle/runtime": minor
3+
---
4+
5+
Fail `createSqliteStateDriver({ root }).open()` from `@agent-bundle/runtime/state/sqlite` with a typed `corrupt` `AgentStateError` when the root holds a pre-#201 `<sanitized id>-<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)

‎.changeset/remove-legacy-sqlite-state.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@agent-bundle/runtime": minor
33
---
44

5-
Stop adopting pre-#201 durable SQLite state stores in `createSqliteStateDriver`: a root-mode store still named `<sanitized id>-<12 hex of utf8(id)>.sqlite` is left on disk unread and a fresh `<sanitized id>-<sha256(id)[0:16]>.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)
5+
Stop adopting pre-#201 durable SQLite state stores in `createSqliteStateDriver`: a root-mode store still named `<sanitized id>-<12 hex of utf8(id)>.sqlite` is no longer renamed to `<sanitized id>-<sha256(id)[0:16]>.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)

‎packages/rsc-runtime/src/state/sqlite.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash } from 'node:crypto';
2-
import { mkdirSync } from 'node:fs';
2+
import { existsSync, mkdirSync } from 'node:fs';
33
import { dirname, join, resolve } from 'node:path';
44
// node:sqlite emits an ExperimentalWarning on load (documented in the README):
55
// 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
313313
const sanitizedFileName = (definitionId: string): string =>
314314
`${definitionId.replace(/[^a-zA-Z0-9._-]+/gu, '-')}-${createHash('sha256').update(definitionId, 'utf8').digest('hex').slice(0, 16)}.sqlite`;
315315

316+
/** The pre-#201 root-mode name, detected only to refuse opening a fresh store beside it. */
317+
const pre201FileName = (definitionId: string): string =>
318+
`${definitionId.replace(/[^a-zA-Z0-9._-]+/gu, '-')}-${Buffer.from(definitionId, 'utf8').toString('hex').slice(0, 12)}.sqlite`;
319+
316320
class SqliteConnection extends Context.Service<SqliteConnection, DatabaseSync>()(
317321
'@agent-bundle/runtime/state/SqliteConnection',
318322
) {}
@@ -1056,7 +1060,18 @@ export const createSqliteStateDriver = (options: SqliteStateDriverOptions): Agen
10561060
);
10571061
}
10581062
if (options.file !== undefined) return resolve(options.file);
1059-
return resolve(join(options.root as string, sanitizedFileName(definition.id)));
1063+
const current = resolve(join(options.root as string, sanitizedFileName(definition.id)));
1064+
const pre201 = resolve(join(options.root as string, pre201FileName(definition.id)));
1065+
if (!existsSync(current) && existsSync(pre201)) {
1066+
throw new AgentStateError(
1067+
'corrupt',
1068+
`State '${definition.id}' found a store at '${pre201}' under the pre-#201 file name, which this ` +
1069+
`runtime no longer reads, and none at '${current}'. Move that file and its -wal/-shm sidecars ` +
1070+
'out of the state root to keep a copy, or delete them; the next open then creates a fresh, ' +
1071+
'empty store. It is not adopted or migrated.',
1072+
);
1073+
}
1074+
return current;
10601075
}, true),
10611076
);
10621077
const connection = Effect.acquireRelease(

‎packages/rsc-runtime/tests/state-sqlite.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdtemp, writeFile } from 'node:fs/promises';
1+
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
22
import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
44
import { DatabaseSync } from 'node:sqlite';
@@ -145,6 +145,31 @@ describe('sqlite driver storage behavior', () => {
145145
await expect(first.read()).rejects.toMatchObject({ code: 'store-closed' });
146146
}));
147147

148+
it('refuses to open a fresh root store beside one under the pre-#201 file name', () =>
149+
withRoot(async (root) => {
150+
const definition = counterDefinition();
151+
const pre201Name = `state-sqlite-test-counter-${Buffer.from(definition.id, 'utf8').toString('hex').slice(0, 12)}.sqlite`;
152+
const pre201File = join(root, pre201Name);
153+
await writeFile(pre201File, 'pre-#201 store');
154+
155+
const refusal = createSqliteStateDriver({ root }).open(definition);
156+
await expect(refusal).rejects.toMatchObject({ code: 'corrupt', name: 'AgentStateError' });
157+
await expect(refusal).rejects.toThrow(`found a store at '${pre201File}' under the pre-#201 file name`);
158+
await expect(refusal).rejects.toThrow('Move that file and its -wal/-shm sidecars out of the state root');
159+
expect(await readdir(root)).toEqual([pre201Name]);
160+
expect(await readFile(pre201File, 'utf8')).toBe('pre-#201 store');
161+
162+
await rm(pre201File);
163+
const store = await createSqliteStateDriver({ root }).open(definition);
164+
await store.dispatch('bumped', { by: 1 }, { idempotencyKey: 'k1' });
165+
await store.close();
166+
// Once the current store exists it is authoritative; a pre-#201 file beside it is not consulted.
167+
await writeFile(pre201File, 'pre-#201 store');
168+
const reopened = await createSqliteStateDriver({ root }).open(definition);
169+
await expect(reopened.read()).resolves.toEqual({ revision: 1, state: { count: 1 } });
170+
await reopened.close();
171+
}));
172+
148173
it('keeps the original commit input while migrating each committed result', () =>
149174
withRoot(async (root) => {
150175
const file = join(root, 'migrating-reset.sqlite');

‎website/docs/en/reference/runtime-environment.mdx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,20 @@ The driver reads only the layout it writes today. It neither adopts nor migrates
129129
before that layout, in either of two ways.
130130

131131
A database under the pre-#201 file name, whose suffix was the state id's own leading bytes in hex
132-
rather than a SHA-256 digest, is never opened. The driver leaves the old file untouched and
133-
creates a new, empty store beside it under the current name, so the old data is still on disk and
134-
simply unread.
132+
rather than a SHA-256 digest, is never opened. When a root-mode store finds that file and no
133+
database under the current name, the open fails with a `corrupt` `AgentStateError` that names the
134+
old file, instead of creating an empty store beside it. The old file is left untouched. Once a
135+
database under the current name exists, the old file is not consulted.
135136

136137
A database under the current file name whose `agent_state_journal` table differs from the one the
137138
driver creates fails at open with a `corrupt` `AgentStateError` naming the table. Column names,
138139
their order, and their `NOT NULL` flags all count, so a journal whose `result_state` column is
139140
nullable, as an older runtime's `ALTER TABLE` left it, is rejected before any row is read.
140141

141-
Recover from either by deleting the stale database files, each `*.sqlite` plus its `-wal` and
142-
`-shm` sidecars, or the whole state root. The next launch creates a fresh store at the definition's
143-
initial state. Nothing reconstructs the old history.
142+
Recover from either by moving the stale database files out of the state root to keep a copy, or
143+
deleting them: each `*.sqlite` plus its `-wal` and `-shm` sidecars, or the whole state root. The
144+
next launch creates a fresh store at the definition's initial state. Nothing reconstructs the old
145+
history.
144146

145147
## Next
146148

‎website/docs/zh/reference/runtime-environment.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,14 +112,16 @@ Workbench 路由调用会把 `AGENT_BUNDLE_STATE_ROOT` 固定为 `<project>/.age
112112
驱动只读取它今天写出的布局。对早于该布局写出的存储,它既不接管也不迁移,具体有两种情形。
113113

114114
使用 #201 之前文件名的数据库(后缀是状态 id 自身起始字节的十六进制,而不是 SHA-256 摘要)永远不会被
115-
打开。驱动原样保留旧文件,并在旁边按当前文件名新建一个空存储,因此旧数据仍在磁盘上,只是不再被读取。
115+
打开。根目录模式的存储若发现该文件、且当前文件名下没有数据库,打开会以点名旧文件的 `corrupt`
116+
`AgentStateError` 失败,而不是在旁边新建一个空存储。旧文件原样保留。一旦当前文件名下已有数据库,
117+
旧文件就不再被检查。
116118

117119
使用当前文件名、但 `agent_state_journal` 表与驱动所创建的不一致的数据库,会在打开时以点名该表的
118120
`corrupt` `AgentStateError` 失败。列名、列顺序与各列的 `NOT NULL` 标志都参与比较,因此像旧版运行时用
119121
`ALTER TABLE` 留下的可空 `result_state` 列,会在读取任何行之前就被拒绝。
120122

121-
两种情形的恢复方式相同:删除过期的数据库文件(每个 `*.sqlite` 及其 `-wal`、`-shm` 附属文件),或删除
122-
整个状态根目录。下次启动会按定义的初始状态新建存储。旧历史不会被任何机制重建。
123+
两种情形的恢复方式相同:把过期的数据库文件移出状态根目录以保留副本,或将其删除(每个 `*.sqlite` 及其
124+
`-wal`、`-shm` 附属文件,或整个状态根目录)。下次启动会按定义的初始状态新建存储。旧历史不会被任何机制重建。
123125

124126
## 下一步
125127

0 commit comments

Comments
 (0)