Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/refuse-pre201-sqlite-store.md
Original file line number Diff line number Diff line change
@@ -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 `<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)
2 changes: 1 addition & 1 deletion .changeset/remove-legacy-sqlite-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@agent-bundle/runtime": minor
---

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)
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)
19 changes: 17 additions & 2 deletions packages/rsc-runtime/src/state/sqlite.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<SqliteConnection, DatabaseSync>()(
'@agent-bundle/runtime/state/SqliteConnection',
) {}
Expand Down Expand Up @@ -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(
Expand Down
27 changes: 26 additions & 1 deletion packages/rsc-runtime/tests/state-sqlite.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
Expand Down
14 changes: 8 additions & 6 deletions website/docs/en/reference/runtime-environment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions website/docs/zh/reference/runtime-environment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,16 @@ Workbench 路由调用会把 `AGENT_BUNDLE_STATE_ROOT` 固定为 `<project>/.age
驱动只读取它今天写出的布局。对早于该布局写出的存储,它既不接管也不迁移,具体有两种情形。

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

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

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

## 下一步

Expand Down
Loading