Skip to content

Commit ae7722c

Browse files
fix(state): preserve legacy migration semantics (#201)
Adopt legacy SQLite databases with their WAL sidecars, retain canonical reset inputs while migrating committed results, make memory migrations atomic, and surface successful-scope close failures.
1 parent 954a44b commit ae7722c

5 files changed

Lines changed: 320 additions & 34 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@agent-bundle/runtime": patch
3+
---
4+
5+
Preserve durable state across the sqlite filename transition, recover legacy
6+
journal results before schema migrations rebase history, keep reset
7+
idempotency inputs unchanged while migrating their committed results, make
8+
in-memory migrations atomic, and surface sqlite close failures on otherwise
9+
successful shutdown.

‎packages/rsc-runtime/src/state/memory-driver.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ interface MemoryStoreInternals<TState, TEvents extends AgentStateEventSchemas> {
8383
definition: AgentStateDefinition<TState, TEvents>;
8484
head: AgentStateSnapshot<TState>;
8585
readonly journal: AgentStateJournalRecord[];
86-
readonly keys: Map<string, CommittedResult<TState>>;
86+
keys: Map<string, CommittedResult<TState>>;
8787
}
8888

8989
interface MemoryStoreEntry<TState, TEvents extends AgentStateEventSchemas> {
@@ -319,17 +319,19 @@ const migrateOpenStore = <TState, TEvents extends AgentStateEventSchemas>(
319319
state: migrated,
320320
toVersion: definition.version,
321321
};
322-
internals.journal.push(record);
322+
const keys = new Map<string, CommittedResult<TState>>();
323323
// Committed results replay across migrations: every stored result sits at
324324
// `fromVersion` (this loop maintains that inductively), so each one rides
325325
// the same migration chain as the head.
326326
for (const [key, entry] of internals.keys) {
327-
internals.keys.set(key, {
327+
keys.set(key, {
328328
record: entry.record,
329329
state: runStateMigrations(definition, fromVersion, entry.state),
330330
});
331331
}
332-
internals.keys.set(record.idempotencyKey, { record, state: migrated });
332+
keys.set(record.idempotencyKey, { record, state: migrated });
333+
internals.keys = keys;
334+
internals.journal.push(record);
333335
internals.head = Object.freeze({ revision: record.revision, state: migrated });
334336
internals.definition = definition;
335337
};

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

Lines changed: 100 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { createHash } from 'node:crypto';
2-
import { mkdirSync } from 'node:fs';
2+
import {
3+
existsSync,
4+
mkdirSync,
5+
renameSync,
6+
} from 'node:fs';
37
import { dirname, join, resolve } from 'node:path';
48
// node:sqlite emits an ExperimentalWarning on load (documented in the README):
59
// the module is Node's built-in SQLite binding, stable enough for Node >= 22.13
@@ -27,6 +31,7 @@ import type {
2731
AgentStateDefinition,
2832
AgentStateDispatchOptions,
2933
AgentStateDriver,
34+
AgentStateEvent,
3035
AgentStateEventSchemas,
3136
AgentStateJournalRecord,
3237
AgentStateReadOptions,
@@ -197,6 +202,7 @@ interface JournalRow {
197202
readonly kind: string;
198203
readonly name: string | null;
199204
readonly payload: string | null;
205+
readonly result_state: string | null;
200206
readonly revision: number;
201207
readonly state: string | null;
202208
readonly to_version: number | null;
@@ -230,6 +236,9 @@ const recordFromRow = (definitionId: string, row: JournalRow): AgentStateJournal
230236
const sanitizedFileName = (definitionId: string): string =>
231237
`${definitionId.replace(/[^a-zA-Z0-9._-]+/gu, '-')}-${createHash('sha256').update(definitionId, 'utf8').digest('hex').slice(0, 16)}.sqlite`;
232238

239+
const legacySanitizedFileName = (definitionId: string): string =>
240+
`${definitionId.replace(/[^a-zA-Z0-9._-]+/gu, '-')}-${Buffer.from(definitionId, 'utf8').toString('hex').slice(0, 12)}.sqlite`;
241+
233242
class SqliteConnection extends Context.Service<SqliteConnection, DatabaseSync>()(
234243
'@agent-bundle/runtime/state/SqliteConnection',
235244
) {}
@@ -341,11 +350,13 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
341350
#committedByKey(
342351
db: DatabaseSync,
343352
key: string,
344-
): { readonly record: AgentStateJournalRecord; readonly stateText: string | null } | undefined {
353+
): { readonly record: AgentStateJournalRecord; readonly resultStateText: string | null } | undefined {
345354
const row = db.prepare('SELECT * FROM agent_state_journal WHERE idempotency_key = ?').get(key) as
346355
| JournalRow
347356
| undefined;
348-
return row === undefined ? undefined : { record: recordFromRow(this.#definition.id, row), stateText: row.state };
357+
return row === undefined
358+
? undefined
359+
: { record: recordFromRow(this.#definition.id, row), resultStateText: row.result_state ?? row.state };
349360
}
350361

351362
/**
@@ -356,11 +367,11 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
356367
*/
357368
#committedState(
358369
db: DatabaseSync,
359-
committed: { readonly record: AgentStateJournalRecord; readonly stateText: string | null },
370+
committed: { readonly record: AgentStateJournalRecord; readonly resultStateText: string | null },
360371
): TState {
361372
const raw =
362-
committed.stateText !== null
363-
? parseStoredJson(this.#definition.id, 'state', committed.record.revision, committed.stateText)
373+
committed.resultStateText !== null
374+
? parseStoredJson(this.#definition.id, 'result state', committed.record.revision, committed.resultStateText)
364375
: this.#replayTo(db, committed.record.revision);
365376
const parsed = this.#definition.schema.safeParse(raw);
366377
if (!parsed.success) {
@@ -387,7 +398,7 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
387398
const stateText = canonicalJson(state);
388399
db
389400
.prepare(
390-
'INSERT INTO agent_state_journal (revision, kind, name, payload, state, to_version, idempotency_key, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
401+
'INSERT INTO agent_state_journal (revision, kind, name, payload, state, result_state, to_version, idempotency_key, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
391402
)
392403
.run(
393404
record.revision,
@@ -397,6 +408,7 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
397408
// Event rows store their post-commit state too, so idempotent replay
398409
// survives migrations without exact-revision replay.
399410
stateText,
411+
stateText,
400412
record.kind === 'migrate' ? record.toVersion : null,
401413
record.idempotencyKey,
402414
record.committedAt,
@@ -417,7 +429,7 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
417429
const committedByKey = (db: DatabaseSync, key: string) => this.#committedByKey(db, key);
418430
const committedState = (
419431
db: DatabaseSync,
420-
committed: { readonly record: AgentStateJournalRecord; readonly stateText: string | null },
432+
committed: { readonly record: AgentStateJournalRecord; readonly resultStateText: string | null },
421433
) => this.#committedState(db, committed);
422434
const headState = (db: DatabaseSync) => this.#headState(db, 'commit');
423435
const now = this.#now;
@@ -601,6 +613,7 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
601613
name TEXT,
602614
payload TEXT,
603615
state TEXT,
616+
result_state TEXT,
604617
to_version INTEGER,
605618
idempotency_key TEXT NOT NULL UNIQUE,
606619
committed_at TEXT NOT NULL
@@ -611,6 +624,12 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
611624
state TEXT NOT NULL
612625
);
613626
`);
627+
const journalColumns = transactionDb.prepare('PRAGMA table_info(agent_state_journal)').all() as unknown as {
628+
readonly name: string;
629+
}[];
630+
if (!journalColumns.some((column) => column.name === 'result_state')) {
631+
transactionDb.exec('ALTER TABLE agent_state_journal ADD COLUMN result_state TEXT');
632+
}
614633
const definition = this.#definition;
615634
const meta = transactionDb
616635
.prepare('SELECT definition_id, schema_version, kernel_format FROM agent_state_meta WHERE id = 1')
@@ -678,14 +697,43 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
678697
}
679698
}
680699
const migrated = runStateMigrations(definition, meta.schema_version, rawHead);
681-
// Stored post-commit states ride the same chain so committed
682-
// idempotency keys keep replaying after the migration; every stored
683-
// state sits at `meta.schema_version` (maintained inductively here).
684-
const updateState = transactionDb.prepare('UPDATE agent_state_journal SET state = ? WHERE revision = ?');
685-
for (const row of rows) {
686-
if (row.state === null) continue;
687-
const rawState = parseStoredJson(definition.id, 'state', row.revision, row.state);
688-
updateState.run(canonicalJson(runStateMigrations(definition, meta.schema_version, rawState)), row.revision);
700+
// Journal records retain the original commit input for dedupe. Their
701+
// committed results migrate separately, matching the memory driver's
702+
// `{ record, state }` split. Legacy event rows without a result are
703+
// replayed before the new migration baseline makes old revisions
704+
// unavailable.
705+
const updateResult = transactionDb.prepare(
706+
'UPDATE agent_state_journal SET result_state = ? WHERE revision = ?',
707+
);
708+
let replayState: unknown = definition.initial;
709+
for (const [index, row] of rows.entries()) {
710+
const record = records[index] as AgentStateJournalRecord;
711+
const storedResultText = row.result_state ?? row.state;
712+
let migratedResult: TState;
713+
if (storedResultText !== null) {
714+
replayState = parseStoredJson(definition.id, 'result state', row.revision, storedResultText);
715+
migratedResult = runStateMigrations(definition, meta.schema_version, replayState);
716+
} else if (record.kind === 'event') {
717+
try {
718+
replayState = definition.reduce(
719+
replayState as TState,
720+
{ name: record.name, payload: record.payload } as AgentStateEvent<TEvents>,
721+
);
722+
} catch (error) {
723+
throw new AgentStateError(
724+
'migration-failure',
725+
`State '${definition.id}' could not recover legacy result at revision ${String(record.revision)}`,
726+
{ cause: error },
727+
);
728+
}
729+
migratedResult = runStateMigrations(definition, meta.schema_version, replayState);
730+
} else {
731+
throw new AgentStateError(
732+
'corrupt',
733+
`State '${definition.id}' journal row at revision ${String(record.revision)} has no committed result`,
734+
);
735+
}
736+
updateResult.run(canonicalJson(migratedResult), row.revision);
689737
}
690738
const record: AgentStateJournalRecord = {
691739
committedAt: this.#now().toISOString(),
@@ -763,25 +811,48 @@ export const createSqliteStateDriver = (options: SqliteStateDriverOptions): Agen
763811
`State '${definition.id}' declares lifetime '${definition.lifetime}' but this driver provides 'workspace-durable'`,
764812
);
765813
}
766-
return resolve(
767-
options.file !== undefined ? options.file : join(options.root as string, sanitizedFileName(definition.id)),
768-
);
769-
}),
814+
if (options.file !== undefined) return resolve(options.file);
815+
const root = options.root as string;
816+
const currentFile = resolve(join(root, sanitizedFileName(definition.id)));
817+
const legacyFile = resolve(join(root, legacySanitizedFileName(definition.id)));
818+
mkdirSync(dirname(currentFile), { recursive: true });
819+
if (!existsSync(currentFile) && existsSync(legacyFile)) {
820+
for (const suffix of ['-wal', '-shm']) {
821+
const legacySidecar = `${legacyFile}${suffix}`;
822+
if (!existsSync(legacySidecar)) continue;
823+
try {
824+
renameSync(legacySidecar, `${currentFile}${suffix}`);
825+
} catch (error) {
826+
// A concurrent adopter may have moved this sidecar after
827+
// the existence check. Other failures must remain visible.
828+
if ((error as SqliteErrorShape).code !== 'ENOENT') throw error;
829+
}
830+
}
831+
try {
832+
renameSync(legacyFile, currentFile);
833+
} catch (error) {
834+
// Another opener may have atomically adopted the same
835+
// legacy file after both observed it. The winner's current
836+
// path is authoritative; otherwise preserve the failure.
837+
if (!existsSync(currentFile)) throw error;
838+
}
839+
}
840+
return currentFile;
841+
}, true),
770842
);
771843
const connection = Effect.acquireRelease(
772844
sqliteEffect(definition.id, 'open database', () => {
773845
mkdirSync(dirname(file), { recursive: true });
774846
return new DatabaseSync(file);
775847
}, true),
776-
(db) =>
777-
Effect.sync(() => {
778-
try {
779-
db.close();
780-
} catch {
781-
// Closing an already-broken connection must not mask the
782-
// caller's path (the original failure carries the cause).
783-
}
784-
}),
848+
(db, exit) => {
849+
const close = sqliteEffect(definition.id, 'close database', () => {
850+
db.close();
851+
}, true);
852+
return Exit.isFailure(exit)
853+
? close.pipe(Effect.catch(() => Effect.void))
854+
: close;
855+
},
785856
);
786857
const runtime = makeScopedEffectRuntime(
787858
Layer.effect(SqliteConnection, connection),

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,43 @@ describe('explicit migrations', () => {
361361
});
362362
});
363363

364+
it('leaves the process store unchanged when a historical result migration throws', async () => {
365+
const driver = createMemoryStateDriver();
366+
const storeV1 = await driver.open(v1());
367+
await storeV1.dispatch('incremented', { by: 1 }, { idempotencyKey: 'i1' });
368+
await storeV1.dispatch('incremented', { by: 2 }, { idempotencyKey: 'i2' });
369+
await storeV1.dispatch('incremented', { by: 3 }, { idempotencyKey: 'i3' });
370+
371+
await expect(
372+
driver.open(
373+
v2((persisted) => {
374+
const state = persisted as CounterState;
375+
if (state.count === 3) throw new Error('cannot migrate historical result');
376+
return { ...state, unit: 'edits' };
377+
}),
378+
),
379+
).rejects.toMatchObject({ code: 'migration-failure' });
380+
381+
expect(await storeV1.read()).toEqual({ revision: 3, state: { count: 6 } });
382+
expect((await storeV1.changes({ afterRevision: 0 })).changes.map((change) => change.kind)).toEqual([
383+
'event',
384+
'event',
385+
'event',
386+
]);
387+
await expect(
388+
storeV1.dispatch('incremented', { by: 1 }, { idempotencyKey: 'i1' }),
389+
).resolves.toEqual({ replayed: true, revision: 1, state: { count: 1 } });
390+
391+
const storeV2 = await driver.open(v2());
392+
expect(await storeV2.read()).toEqual({ revision: 4, state: { count: 6, unit: 'edits' } });
393+
expect((await storeV2.changes({ afterRevision: 0 })).changes.map((change) => change.kind)).toEqual([
394+
'event',
395+
'event',
396+
'event',
397+
'migrate',
398+
]);
399+
});
400+
364401
it('rejects opening a persisted-newer store with an older definition', async () => {
365402
const driver = createMemoryStateDriver();
366403
await driver.open(v2());

0 commit comments

Comments
 (0)