diff --git a/.changeset/effect-state-kernel-stage1.md b/.changeset/effect-state-kernel-stage1.md new file mode 100644 index 000000000..1fc86a1e6 --- /dev/null +++ b/.changeset/effect-state-kernel-stage1.md @@ -0,0 +1,11 @@ +--- +"@agent-bundle/runtime": patch +--- + +Rewrite the state-kernel driver internals on Effect v4 behind the unchanged +public API: `Scope`/`Layer` own sqlite connection and BEGIN IMMEDIATE +transaction lifecycles (`acquireUseRelease` commit/rollback), and the kernel's +fail-closed states ride a typed `AgentStateError` error channel mapped back at +the boundary module. `defineState`/`dispatch`/`read`/`changes`/`reset` still +return the same Promise shapes and reject with the same typed errors; root and +plugin entries still ship zero kernel or effect bytes. diff --git a/agent-patterns/effect-scope.md b/agent-patterns/effect-scope.md index 47d8ed282..522666329 100644 --- a/agent-patterns/effect-scope.md +++ b/agent-patterns/effect-scope.md @@ -28,6 +28,24 @@ const connection = Effect.acquireRelease( Finalizers run in reverse acquire order. Interruption still runs them. +## Transactions (stage-1 kernel idiom) + +`Effect.acquireUseRelease` when begin/commit/rollback are one unit and the +release step branches on the exit: + +```ts +Effect.acquireUseRelease( + begin, // BEGIN IMMEDIATE + () => work, + (db, exit) => Exit.isFailure(exit) + ? rollback + : commit.pipe(Effect.catch((e) => rollback.pipe(Effect.andThen(Effect.fail(e))))), +); +``` + +A failed COMMIT must still roll back before re-raising, or the connection +holds the transaction open for the next caller. + ## Layers - `Layer.effect` for a service with no finalizer. diff --git a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts index a4a5056fe..2ae83a387 100644 --- a/examples/rsc-agent-runtime/tests/host-artifacts.test.ts +++ b/examples/rsc-agent-runtime/tests/host-artifacts.test.ts @@ -313,18 +313,23 @@ test('runs each packaged native hook from one shell argv path when its plugin ro test('keeps the published Agent Bundle package free of the supplemental RSC runtime', async () => { const packageRoot = join(exampleRoot, '../../packages/agent-bundle'); - const packageJson = await readJson<{ dependencies?: Record; optionalDependencies?: Record; peerDependencies?: Record }>( + const packageJson = await readJson<{ dependencies?: Record; optionalDependencies?: Record; peerDependencies?: Record; peerDependenciesMeta?: Record }>( join(packageRoot, 'package.json'), ); - const allDependencies = { + // Install-cost guard: hook-only consumers must never be forced to install + // the RSC runtime stack. Optional peers (declared for the #103 test + // harness) add no install cost, so they are allowed only when + // peerDependenciesMeta marks them optional. + const requiredDependencies = { ...packageJson.dependencies, ...packageJson.optionalDependencies, - ...packageJson.peerDependencies, }; - - expect(allDependencies).not.toHaveProperty('react'); - expect(allDependencies).not.toHaveProperty('react-server-dom-rspack'); - expect(allDependencies).not.toHaveProperty('rsbuild-plugin-rsc'); + for (const name of ['react', 'react-server-dom-rspack', 'rsbuild-plugin-rsc']) { + expect(requiredDependencies).not.toHaveProperty(name); + if (packageJson.peerDependencies?.[name] !== undefined) { + expect(packageJson.peerDependenciesMeta?.[name]?.optional, `${name} peer must be optional`).toBe(true); + } + } const sourceRoot = join(packageRoot, 'src'); const sourceFiles = await readdir(sourceRoot, { recursive: true }); diff --git a/packages/rsc-runtime/src/state/effect.ts b/packages/rsc-runtime/src/state/effect.ts new file mode 100644 index 000000000..cc38c371e --- /dev/null +++ b/packages/rsc-runtime/src/state/effect.ts @@ -0,0 +1,22 @@ +import { Effect } from 'effect'; + +import { AgentStateError } from './contract.js'; + +/** + * Lifts synchronous kernel work into the typed state error channel. + * Expected fail-closed conditions remain AgentStateError failures; any other + * throw is an implementation defect and stays in Effect's defect channel. + */ +export const stateEffect = ( + evaluate: () => A, +): Effect.Effect => + Effect.try({ + catch: (error) => error, + try: evaluate, + }).pipe( + Effect.catch((error) => + error instanceof AgentStateError + ? Effect.fail(error) + : Effect.die(error), + ), + ); diff --git a/packages/rsc-runtime/src/state/memory-driver.ts b/packages/rsc-runtime/src/state/memory-driver.ts index 5f3fe026d..94cb0e308 100644 --- a/packages/rsc-runtime/src/state/memory-driver.ts +++ b/packages/rsc-runtime/src/state/memory-driver.ts @@ -1,3 +1,9 @@ +import { Effect, Layer } from 'effect'; + +import { + makeScopedEffectRuntime, + runPromise, +} from '../effect/boundary.js'; import type { AgentStateChangeBatch, AgentStateChangesOptions, @@ -23,6 +29,7 @@ import { resolveResetState, runStateMigrations, } from './journal.js'; +import { stateEffect } from './effect.js'; /** * In-memory state driver (#98). @@ -72,6 +79,7 @@ interface MemoryStoreInternals { } interface MemoryStoreEntry { + readonly activate: () => Promise; readonly internals: MemoryStoreInternals; readonly store: AgentStateStore; } @@ -104,6 +112,24 @@ const createMemoryStore = ( journal: [], keys: new Map(), }; + const runtime = makeScopedEffectRuntime( + Layer.effectDiscard( + Effect.acquireRelease( + Effect.void, + () => + Effect.sync(() => { + if (!internals.closed) { + internals.closed = true; + onClose(); + } + }), + ), + ), + ); + const runStore = (effect: Effect.Effect): Promise => + internals.closed + ? runPromise(Effect.fail(new AgentStateError('store-closed', `State '${internals.definition.id}' store is closed`))) + : runtime.run(effect); /** * Commits share one shape: validate inputs, then honor a committed @@ -168,86 +194,103 @@ const createMemoryStore = ( }, location: `memory:${lifetime}:${definition.id}`, - async changes(options: AgentStateChangesOptions): Promise { - expectOperable(internals.closed, internals.definition.id, options.signal); - if (options.afterRevision === undefined) { - throw new AgentStateError('invalid-input', `State '${internals.definition.id}' changes require afterRevision`); - } - expectRevisionShape(options.afterRevision, `State '${internals.definition.id}' afterRevision`); - if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1)) { - throw new AgentStateError( - 'invalid-input', - `State '${internals.definition.id}' changes limit must be an integer >= 1`, - ); - } - const selected = internals.journal - .filter((record) => record.revision > options.afterRevision) - .slice(0, options.limit) - .map((record) => changeFromJournalRecord(record)); - return Object.freeze({ changes: Object.freeze(selected), headRevision: internals.head.revision }); + changes(options: AgentStateChangesOptions): Promise { + return runStore( + stateEffect(() => { + expectOperable(internals.closed, internals.definition.id, options.signal); + if (options.afterRevision === undefined) { + throw new AgentStateError('invalid-input', `State '${internals.definition.id}' changes require afterRevision`); + } + expectRevisionShape(options.afterRevision, `State '${internals.definition.id}' afterRevision`); + if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1)) { + throw new AgentStateError( + 'invalid-input', + `State '${internals.definition.id}' changes limit must be an integer >= 1`, + ); + } + const selected = internals.journal + .filter((record) => record.revision > options.afterRevision) + .slice(0, options.limit) + .map((record) => changeFromJournalRecord(record)); + return Object.freeze({ changes: Object.freeze(selected), headRevision: internals.head.revision }); + }), + ); }, - async close(): Promise { - if (!internals.closed) { - internals.closed = true; - onClose(); - } + close(): Promise { + return runtime.close(); }, - async dispatch(name, payload, options: AgentStateDispatchOptions): Promise> { - expectOperable(internals.closed, internals.definition.id, options.signal); - const key = expectIdempotencyKey(options.idempotencyKey); - expectRevisionShape(options.expectedRevision, `State '${internals.definition.id}' expectedRevision`); - const applied = applyStateEvent(internals.definition, internals.head.state, name, payload); - const canonicalInput = canonicalCommitInput({ - committedAt: '', - idempotencyKey: key, - kind: 'event', - name, - payload: applied.payload, - revision: 0, - }); - return commit( - { canonicalInput, key, kind: 'event', name, payload: applied.payload, state: applied.state }, - options.expectedRevision, + dispatch(name, payload, options: AgentStateDispatchOptions): Promise> { + return runStore( + stateEffect(() => { + expectOperable(internals.closed, internals.definition.id, options.signal); + const key = expectIdempotencyKey(options.idempotencyKey); + expectRevisionShape(options.expectedRevision, `State '${internals.definition.id}' expectedRevision`); + const applied = applyStateEvent(internals.definition, internals.head.state, name, payload); + const canonicalInput = canonicalCommitInput({ + committedAt: '', + idempotencyKey: key, + kind: 'event', + name, + payload: applied.payload, + revision: 0, + }); + return commit( + { canonicalInput, key, kind: 'event', name, payload: applied.payload, state: applied.state }, + options.expectedRevision, + ); + }), ); }, - async read(options: AgentStateReadOptions = {}): Promise> { - expectOperable(internals.closed, internals.definition.id, options.signal); - expectRevisionShape(options.revision, `State '${internals.definition.id}' revision`); - if (options.revision === undefined || options.revision === internals.head.revision) { - return internals.head; - } - if (options.revision > internals.head.revision) { - throw new AgentStateError( - 'revision-unavailable', - `State '${internals.definition.id}' revision ${String(options.revision)} is beyond the head ${String(internals.head.revision)}`, - ); - } - return Object.freeze({ - revision: options.revision, - state: replayJournal(internals.definition, internals.journal, options.revision), - }); + read(options: AgentStateReadOptions = {}): Promise> { + return runStore( + stateEffect(() => { + expectOperable(internals.closed, internals.definition.id, options.signal); + expectRevisionShape(options.revision, `State '${internals.definition.id}' revision`); + if (options.revision === undefined || options.revision === internals.head.revision) { + return internals.head; + } + if (options.revision > internals.head.revision) { + throw new AgentStateError( + 'revision-unavailable', + `State '${internals.definition.id}' revision ${String(options.revision)} is beyond the head ${String(internals.head.revision)}`, + ); + } + return Object.freeze({ + revision: options.revision, + state: replayJournal(internals.definition, internals.journal, options.revision), + }); + }), + ); }, - async reset(options: AgentStateResetOptions): Promise> { - expectOperable(internals.closed, internals.definition.id, options.signal); - const key = expectIdempotencyKey(options.idempotencyKey); - expectRevisionShape(options.expectedRevision, `State '${internals.definition.id}' expectedRevision`); - const state = resolveResetState(internals.definition, options.seed); - const canonicalInput = canonicalCommitInput({ - committedAt: '', - idempotencyKey: key, - kind: 'reset', - revision: 0, - state, - }); - return commit({ canonicalInput, key, kind: 'reset', state }, options.expectedRevision); + reset(options: AgentStateResetOptions): Promise> { + return runStore( + stateEffect(() => { + expectOperable(internals.closed, internals.definition.id, options.signal); + const key = expectIdempotencyKey(options.idempotencyKey); + expectRevisionShape(options.expectedRevision, `State '${internals.definition.id}' expectedRevision`); + const state = resolveResetState(internals.definition, options.seed); + const canonicalInput = canonicalCommitInput({ + committedAt: '', + idempotencyKey: key, + kind: 'reset', + revision: 0, + state, + }); + return commit({ canonicalInput, key, kind: 'reset', state }, options.expectedRevision); + }), + ); }, }; - return { internals, store: Object.freeze(store) }; + return { + activate: () => runtime.run(Effect.void), + internals, + store: Object.freeze(store), + }; }; const migrateOpenStore = ( @@ -276,53 +319,101 @@ export const createMemoryStateDriver = (options: MemoryStateDriverOptions = {}): // Heterogeneously typed per definition; entries are cast back at the one // retrieval site below, keyed by the definition id they were created for. const registry = new Map>(); + const openStores = new Set>(); + const pendingOpens = new Set>(); let closed = false; + let closing: Promise | undefined; + + const trackPendingOpen = (operation: Promise): Promise => { + const settled = operation.then( + () => undefined, + () => undefined, + ); + pendingOpens.add(settled); + void settled.then(() => { + pendingOpens.delete(settled); + }); + return operation; + }; return Object.freeze({ durable: false, kind: 'memory', lifetime, - async close(): Promise { + close(): Promise { + if (closing !== undefined) return closing; closed = true; - for (const entry of [...registry.values()]) { - await entry.store.close(); - } - registry.clear(); + closing = (async () => { + while (pendingOpens.size > 0) { + await Promise.all([...pendingOpens]); + } + for (const entry of [...openStores]) { + await entry.store.close(); + } + openStores.clear(); + registry.clear(); + })(); + return closing; }, - async open( + open( definition: AgentStateDefinition, ): Promise> { - if (closed) { - throw new AgentStateError('store-closed', `State '${definition.id}' cannot open on a closed driver`); - } - if (definition.lifetime !== lifetime) { - throw new AgentStateError( - 'lifetime-mismatch', - `State '${definition.id}' declares lifetime '${definition.lifetime}' but this driver provides '${lifetime}'`, - ); - } - switch (lifetime) { - case 'request': - return createMemoryStore(definition, lifetime, now, () => undefined).store; - case 'process': { - const existing = registry.get(definition.id) as unknown as MemoryStoreEntry | undefined; - if (existing === undefined) { - const created = createMemoryStore(definition, lifetime, now, () => registry.delete(definition.id)); - registry.set(definition.id, created as unknown as MemoryStoreEntry); - return created.store; + return trackPendingOpen( + (async () => { + const entry = await runPromise( + stateEffect(() => { + if (closed) { + throw new AgentStateError('store-closed', `State '${definition.id}' cannot open on a closed driver`); + } + if (definition.lifetime !== lifetime) { + throw new AgentStateError( + 'lifetime-mismatch', + `State '${definition.id}' declares lifetime '${definition.lifetime}' but this driver provides '${lifetime}'`, + ); + } + switch (lifetime) { + case 'request': { + const created = createMemoryStore(definition, lifetime, now, () => { + openStores.delete(created as unknown as MemoryStoreEntry); + }); + openStores.add(created as unknown as MemoryStoreEntry); + return created; + } + case 'process': { + const existing = registry.get(definition.id) as unknown as MemoryStoreEntry | undefined; + if (existing === undefined) { + const created = createMemoryStore(definition, lifetime, now, () => { + registry.delete(definition.id); + openStores.delete(created as unknown as MemoryStoreEntry); + }); + registry.set(definition.id, created as unknown as MemoryStoreEntry); + openStores.add(created as unknown as MemoryStoreEntry); + return created; + } + if (definition.version !== existing.internals.definition.version) { + migrateOpenStore(existing.internals, definition, now); + } + return existing; + } + default: { + const unreachable: never = lifetime; + throw new AgentStateError('invalid-definition', `Unknown volatile lifetime ${String(unreachable)}`); + } + } + }), + ); + await entry.activate(); + if (closed) { + await entry.store.close(); + return runPromise( + Effect.fail(new AgentStateError('store-closed', `State '${definition.id}' cannot open on a closed driver`)), + ); } - if (definition.version !== existing.internals.definition.version) { - migrateOpenStore(existing.internals, definition, now); - } - return existing.store; - } - default: { - const unreachable: never = lifetime; - throw new AgentStateError('invalid-definition', `Unknown volatile lifetime ${String(unreachable)}`); - } - } + return entry.store; + })(), + ); }, }); }; diff --git a/packages/rsc-runtime/src/state/sqlite.ts b/packages/rsc-runtime/src/state/sqlite.ts index af4a268c2..490621634 100644 --- a/packages/rsc-runtime/src/state/sqlite.ts +++ b/packages/rsc-runtime/src/state/sqlite.ts @@ -7,6 +7,18 @@ import { dirname, join, resolve } from 'node:path'; // state users and stateless projects never load it or see the warning. import { DatabaseSync } from 'node:sqlite'; +import { + Context, + Effect, + Exit, + Layer, +} from 'effect'; + +import { + makeScopedEffectRuntime, + runPromise, + type ScopedEffectRuntime, +} from '../effect/boundary.js'; import type { AgentStateChangeBatch, AgentStateChangesOptions, @@ -85,6 +97,7 @@ export interface SqliteStateDriverOptions { } interface SqliteErrorShape { + readonly code?: string; readonly errcode?: number; readonly errstr?: string; } @@ -93,9 +106,20 @@ const SQLITE_CORRUPT = 11; const SQLITE_NOTADB = 26; const SQLITE_BUSY = 5; -const mapSqliteError = (definitionId: string, action: string, error: unknown): AgentStateError => { +const mapSqliteError = ( + definitionId: string, + action: string, + error: unknown, + mapSystemError: boolean, +): AgentStateError | undefined => { if (error instanceof AgentStateError) return error; const shape = error as SqliteErrorShape; + const sqliteError = + typeof shape?.errcode === 'number' + || (typeof shape?.code === 'string' && shape.code.startsWith('ERR_SQLITE')); + if (!sqliteError && !(mapSystemError && typeof shape?.code === 'string')) { + return undefined; + } const detail = typeof shape.errstr === 'string' ? `: ${shape.errstr}` : ''; if (shape.errcode === SQLITE_CORRUPT || shape.errcode === SQLITE_NOTADB) { return new AgentStateError( @@ -118,6 +142,24 @@ const mapSqliteError = (definitionId: string, action: string, error: unknown): A ); }; +const sqliteEffect = ( + definitionId: string, + action: string, + evaluate: () => A, + mapSystemError = false, +): Effect.Effect => + Effect.try({ + catch: (error) => error, + try: evaluate, + }).pipe( + Effect.catch((error) => { + const mapped = mapSqliteError(definitionId, action, error, mapSystemError); + return mapped === undefined + ? Effect.die(error) + : Effect.fail(mapped); + }), + ); + const expectRevisionShape = (revision: number | undefined, label: string): void => { if (revision !== undefined && (!Number.isInteger(revision) || revision < 0)) { throw new AgentStateError('invalid-input', `${label} must be an integer >= 0`); @@ -181,26 +223,30 @@ const recordFromRow = (definitionId: string, row: JournalRow): AgentStateJournal const sanitizedFileName = (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', +) {} + class SqliteStore implements AgentStateStore { readonly location: string; #closed = false; - readonly #db: DatabaseSync; #definition: AgentStateDefinition; readonly #now: () => Date; readonly #onClose: () => void; + readonly #runtime: ScopedEffectRuntime; constructor( definition: AgentStateDefinition, - db: DatabaseSync, file: string, now: () => Date, onClose: () => void, + runtime: ScopedEffectRuntime, ) { this.#definition = definition; - this.#db = db; this.location = file; this.#now = now; this.#onClose = onClose; + this.#runtime = runtime; } get definition(): AgentStateDefinition { @@ -213,34 +259,41 @@ class SqliteStore implements Age * database lock); reads take a deferred snapshot transaction, which WAL * never blocks on writers. */ - #transaction(mode: 'read' | 'write', action: string, work: () => T): T { + #transaction( + mode: 'read' | 'write', + action: string, + work: (db: DatabaseSync) => T, + ): Effect.Effect { const id = this.#definition.id; - try { - this.#db.exec(mode === 'write' ? 'BEGIN IMMEDIATE' : 'BEGIN DEFERRED'); - } catch (error) { - throw mapSqliteError(id, `${action}: begin`, error); - } - let result: T; - try { - result = work(); - } catch (error) { - try { - this.#db.exec('ROLLBACK'); - } catch { - // The connection is unusable; the original error carries the cause. - } - throw mapSqliteError(id, action, error); - } - try { - this.#db.exec('COMMIT'); - } catch (error) { - throw mapSqliteError(id, `${action}: commit`, error); - } - return result; + return Effect.gen(function*() { + const db = yield* SqliteConnection; + return yield* Effect.acquireUseRelease( + sqliteEffect(id, `${action}: begin`, () => { + db.exec(mode === 'write' ? 'BEGIN IMMEDIATE' : 'BEGIN DEFERRED'); + return db; + }), + () => sqliteEffect(id, action, () => work(db)), + (connection, exit) => { + const rollback = sqliteEffect(id, `${action}: rollback`, () => { + connection.exec('ROLLBACK'); + }); + if (Exit.isFailure(exit)) return rollback; + return sqliteEffect(id, `${action}: commit`, () => { + connection.exec('COMMIT'); + }).pipe( + Effect.catch((commitError) => + rollback.pipe( + Effect.andThen(Effect.fail(commitError)), + ), + ), + ); + }, + ); + }); } - #headRow(action: string): { revision: number; state: string } { - const row = this.#db.prepare('SELECT revision, state FROM agent_state_head WHERE id = 1').get() as + #headRow(db: DatabaseSync, action: string): { revision: number; state: string } { + const row = db.prepare('SELECT revision, state FROM agent_state_head WHERE id = 1').get() as | { revision: number; state: string } | undefined; if (row === undefined || !Number.isInteger(row.revision) || row.revision < 0) { @@ -249,8 +302,8 @@ class SqliteStore implements Age return row; } - #headState(action: string): AgentStateSnapshot { - const row = this.#headRow(action); + #headState(db: DatabaseSync, action: string): AgentStateSnapshot { + const row = this.#headRow(db, action); const raw = parseStoredJson(this.#definition.id, 'head state', row.revision, row.state); const parsed = this.#definition.schema.safeParse(raw); if (!parsed.success) { @@ -262,43 +315,43 @@ class SqliteStore implements Age return Object.freeze({ revision: row.revision, state: deepFreezeJson(parsed.data) }); } - #journalRecords(upTo?: number): AgentStateJournalRecord[] { + #journalRecords(db: DatabaseSync, upTo?: number): AgentStateJournalRecord[] { const rows = ( upTo === undefined - ? this.#db.prepare('SELECT * FROM agent_state_journal ORDER BY revision').all() - : this.#db.prepare('SELECT * FROM agent_state_journal WHERE revision <= ? ORDER BY revision').all(upTo) + ? db.prepare('SELECT * FROM agent_state_journal ORDER BY revision').all() + : db.prepare('SELECT * FROM agent_state_journal WHERE revision <= ? ORDER BY revision').all(upTo) ) as unknown as JournalRow[]; return rows.map((row) => recordFromRow(this.#definition.id, row)); } - #latestMigrationRevision(): number { - const row = this.#db + #latestMigrationRevision(db: DatabaseSync): number { + const row = db .prepare("SELECT COALESCE(MAX(revision), 0) AS revision FROM agent_state_journal WHERE kind = 'migrate'") .get() as { revision: number }; return row.revision; } - #committedByKey(key: string): AgentStateJournalRecord | undefined { - const row = this.#db.prepare('SELECT * FROM agent_state_journal WHERE idempotency_key = ?').get(key) as + #committedByKey(db: DatabaseSync, key: string): AgentStateJournalRecord | undefined { + const row = db.prepare('SELECT * FROM agent_state_journal WHERE idempotency_key = ?').get(key) as | JournalRow | undefined; return row === undefined ? undefined : recordFromRow(this.#definition.id, row); } - #replayTo(revision: number): TState { - const latestMigration = this.#latestMigrationRevision(); + #replayTo(db: DatabaseSync, revision: number): TState { + const latestMigration = this.#latestMigrationRevision(db); if (latestMigration > revision) { throw new AgentStateError( 'revision-unavailable', `State '${this.#definition.id}' revision ${String(revision)} predates the migration at revision ${String(latestMigration)}`, ); } - return replayJournal(this.#definition, this.#journalRecords(revision), revision); + return replayJournal(this.#definition, this.#journalRecords(db, revision), revision); } - #appendRecord(record: AgentStateJournalRecord, state: TState): AgentStateCommitResult { + #appendRecord(db: DatabaseSync, record: AgentStateJournalRecord, state: TState): AgentStateCommitResult { const stateText = canonicalJson(state); - this.#db + db .prepare( 'INSERT INTO agent_state_journal (revision, kind, name, payload, state, to_version, idempotency_key, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', ) @@ -312,7 +365,7 @@ class SqliteStore implements Age record.idempotencyKey, record.committedAt, ); - this.#db.prepare('UPDATE agent_state_head SET revision = ?, state = ? WHERE id = 1').run(record.revision, stateText); + db.prepare('UPDATE agent_state_head SET revision = ?, state = ? WHERE id = 1').run(record.revision, stateText); return Object.freeze({ replayed: false, revision: record.revision, state }); } @@ -321,119 +374,149 @@ class SqliteStore implements Age | { readonly kind: 'event'; readonly name: string; readonly rawPayload: unknown } | { readonly kind: 'reset'; readonly seed: TState | undefined }, options: AgentStateDispatchOptions | AgentStateResetOptions, - ): AgentStateCommitResult { - expectOperable(this.#closed, this.#definition.id, options.signal); - const key = expectIdempotencyKey(options.idempotencyKey); - expectRevisionShape(options.expectedRevision, `State '${this.#definition.id}' expectedRevision`); - return this.#transaction('write', input.kind === 'event' ? `dispatch '${input.name}'` : 'reset', () => { - const head = this.#headState('commit'); - const prepared = - input.kind === 'event' - ? ((): { canonicalInput: string; record: AgentStateJournalRecord; state: TState } => { - const applied = applyStateEvent(this.#definition, head.state, input.name, input.rawPayload); - const record: AgentStateJournalRecord = { - committedAt: this.#now().toISOString(), - idempotencyKey: key, - kind: 'event', - name: input.name, - payload: applied.payload, - revision: head.revision + 1, - }; - return { canonicalInput: canonicalCommitInput(record), record, state: applied.state }; - })() - : ((): { canonicalInput: string; record: AgentStateJournalRecord; state: TState } => { - const state = resolveResetState(this.#definition, input.seed); - const record: AgentStateJournalRecord = { - committedAt: this.#now().toISOString(), - idempotencyKey: key, - kind: 'reset', - revision: head.revision + 1, - state, - }; - return { canonicalInput: canonicalCommitInput(record), record, state }; - })(); - const committed = this.#committedByKey(key); - if (committed !== undefined) { - if (canonicalCommitInput(committed) !== prepared.canonicalInput) { + ): Effect.Effect, AgentStateError, SqliteConnection> { + const definition = this.#definition; + const appendRecord = (db: DatabaseSync, record: AgentStateJournalRecord, state: TState) => + this.#appendRecord(db, record, state); + const committedByKey = (db: DatabaseSync, key: string) => this.#committedByKey(db, key); + const headState = (db: DatabaseSync) => this.#headState(db, 'commit'); + const now = this.#now; + const replayTo = (db: DatabaseSync, revision: number) => this.#replayTo(db, revision); + const transaction = this.#transaction.bind(this); + const validate = sqliteEffect(definition.id, 'validate commit', () => { + expectOperable(this.#closed, definition.id, options.signal); + expectRevisionShape(options.expectedRevision, `State '${definition.id}' expectedRevision`); + return expectIdempotencyKey(options.idempotencyKey); + }); + return Effect.gen(function*() { + const key = yield* validate; + return yield* transaction('write', input.kind === 'event' ? `dispatch '${input.name}'` : 'reset', (db) => { + const head = headState(db); + const prepared = + input.kind === 'event' + ? ((): { canonicalInput: string; record: AgentStateJournalRecord; state: TState } => { + const applied = applyStateEvent(definition, head.state, input.name, input.rawPayload); + const record: AgentStateJournalRecord = { + committedAt: now().toISOString(), + idempotencyKey: key, + kind: 'event', + name: input.name, + payload: applied.payload, + revision: head.revision + 1, + }; + return { canonicalInput: canonicalCommitInput(record), record, state: applied.state }; + })() + : ((): { canonicalInput: string; record: AgentStateJournalRecord; state: TState } => { + const state = resolveResetState(definition, input.seed); + const record: AgentStateJournalRecord = { + committedAt: now().toISOString(), + idempotencyKey: key, + kind: 'reset', + revision: head.revision + 1, + state, + }; + return { canonicalInput: canonicalCommitInput(record), record, state }; + })(); + const committed = committedByKey(db, key); + if (committed !== undefined) { + if (canonicalCommitInput(committed) !== prepared.canonicalInput) { + throw new AgentStateError( + 'idempotency-conflict', + `State '${definition.id}' idempotency key was reused with a conflicting input`, + ); + } + return Object.freeze({ replayed: true, revision: committed.revision, state: replayTo(db, committed.revision) }); + } + if (options.expectedRevision !== undefined && options.expectedRevision !== head.revision) { throw new AgentStateError( - 'idempotency-conflict', - `State '${this.#definition.id}' idempotency key was reused with a conflicting input`, + 'revision-conflict', + `State '${definition.id}' expected revision ${String(options.expectedRevision)} but the head is ${String(head.revision)}`, ); } - return Object.freeze({ replayed: true, revision: committed.revision, state: this.#replayTo(committed.revision) }); - } - if (options.expectedRevision !== undefined && options.expectedRevision !== head.revision) { - throw new AgentStateError( - 'revision-conflict', - `State '${this.#definition.id}' expected revision ${String(options.expectedRevision)} but the head is ${String(head.revision)}`, - ); - } - return this.#appendRecord(prepared.record, prepared.state); + return appendRecord(db, prepared.record, prepared.state); + }); }); } - async dispatch>( + #run(effect: Effect.Effect): Promise { + return this.#closed + ? runPromise(Effect.fail(new AgentStateError('store-closed', `State '${this.#definition.id}' store is closed`))) + : this.#runtime.run(effect); + } + + dispatch>( name: TName, payload: unknown, options: AgentStateDispatchOptions, ): Promise> { - return this.#commit({ kind: 'event', name, rawPayload: payload }, options); + return this.#run(this.#commit({ kind: 'event', name, rawPayload: payload }, options)); } - async reset(options: AgentStateResetOptions): Promise> { - return this.#commit({ kind: 'reset', seed: options.seed }, options); + reset(options: AgentStateResetOptions): Promise> { + return this.#run(this.#commit({ kind: 'reset', seed: options.seed }, options)); } - async read(options: AgentStateReadOptions = {}): Promise> { - expectOperable(this.#closed, this.#definition.id, options.signal); - expectRevisionShape(options.revision, `State '${this.#definition.id}' revision`); - return this.#transaction('read', 'read', () => { - const head = this.#headState('read'); - if (options.revision === undefined || options.revision === head.revision) return head; - if (options.revision > head.revision) { - throw new AgentStateError( - 'revision-unavailable', - `State '${this.#definition.id}' revision ${String(options.revision)} is beyond the head ${String(head.revision)}`, - ); - } - return Object.freeze({ revision: options.revision, state: this.#replayTo(options.revision) }); - }); + read(options: AgentStateReadOptions = {}): Promise> { + return this.#run( + sqliteEffect(this.#definition.id, 'validate read', () => { + expectOperable(this.#closed, this.#definition.id, options.signal); + expectRevisionShape(options.revision, `State '${this.#definition.id}' revision`); + }).pipe( + Effect.andThen( + this.#transaction('read', 'read', (db) => { + const head = this.#headState(db, 'read'); + if (options.revision === undefined || options.revision === head.revision) return head; + if (options.revision > head.revision) { + throw new AgentStateError( + 'revision-unavailable', + `State '${this.#definition.id}' revision ${String(options.revision)} is beyond the head ${String(head.revision)}`, + ); + } + return Object.freeze({ revision: options.revision, state: this.#replayTo(db, options.revision) }); + }), + ), + ), + ); } - async changes(options: AgentStateChangesOptions): Promise { - expectOperable(this.#closed, this.#definition.id, options.signal); - if (options.afterRevision === undefined) { - throw new AgentStateError('invalid-input', `State '${this.#definition.id}' changes require afterRevision`); - } - expectRevisionShape(options.afterRevision, `State '${this.#definition.id}' afterRevision`); - if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1)) { - throw new AgentStateError('invalid-input', `State '${this.#definition.id}' changes limit must be an integer >= 1`); - } - return this.#transaction('read', 'changes', () => { - const head = this.#headRow('changes'); - const rows = this.#db - .prepare('SELECT * FROM agent_state_journal WHERE revision > ? ORDER BY revision LIMIT ?') - .all(options.afterRevision, options.limit ?? -1) as unknown as JournalRow[]; - const changes = rows.map((row) => changeFromJournalRecord(recordFromRow(this.#definition.id, row))); - return Object.freeze({ changes: Object.freeze(changes), headRevision: head.revision }); - }); + changes(options: AgentStateChangesOptions): Promise { + return this.#run( + sqliteEffect(this.#definition.id, 'validate changes', () => { + expectOperable(this.#closed, this.#definition.id, options.signal); + if (options.afterRevision === undefined) { + throw new AgentStateError('invalid-input', `State '${this.#definition.id}' changes require afterRevision`); + } + expectRevisionShape(options.afterRevision, `State '${this.#definition.id}' afterRevision`); + if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1)) { + throw new AgentStateError('invalid-input', `State '${this.#definition.id}' changes limit must be an integer >= 1`); + } + }).pipe( + Effect.andThen( + this.#transaction('read', 'changes', (db) => { + const head = this.#headRow(db, 'changes'); + const rows = db + .prepare('SELECT * FROM agent_state_journal WHERE revision > ? ORDER BY revision LIMIT ?') + .all(options.afterRevision, options.limit ?? -1) as unknown as JournalRow[]; + const changes = rows.map((row) => changeFromJournalRecord(recordFromRow(this.#definition.id, row))); + return Object.freeze({ changes: Object.freeze(changes), headRevision: head.revision }); + }), + ), + ), + ); } - async close(): Promise { - if (this.#closed) return; + close(): Promise { + if (this.#closed) return this.#runtime.close(); this.#closed = true; - try { - this.#db.close(); - } catch { - // Closing an already-broken connection must not mask the caller's path. - } this.#onClose(); + return this.#runtime.close(); } /** Opens the database schema, verifies identity, and runs due migrations. */ - initialize(): void { - this.#transaction('write', 'open', () => { - this.#db.exec(` + initialize(busyTimeoutMs: number): Promise { + const definitionId = this.#definition.id; + const initializeStorage = this.#transaction('write', 'open', (transactionDb) => { + transactionDb.exec(` CREATE TABLE IF NOT EXISTS agent_state_meta ( id INTEGER PRIMARY KEY CHECK (id = 1), definition_id TEXT NOT NULL, @@ -457,14 +540,14 @@ class SqliteStore implements Age ); `); const definition = this.#definition; - const meta = this.#db + const meta = transactionDb .prepare('SELECT definition_id, schema_version, kernel_format FROM agent_state_meta WHERE id = 1') .get() as { definition_id: string; kernel_format: number; schema_version: number } | undefined; if (meta === undefined) { - this.#db + transactionDb .prepare('INSERT INTO agent_state_meta (id, definition_id, schema_version, kernel_format) VALUES (1, ?, ?, ?)') .run(definition.id, definition.version, KERNEL_FORMAT); - this.#db + transactionDb .prepare('INSERT INTO agent_state_head (id, revision, state) VALUES (1, 0, ?)') .run(canonicalJson(definition.initial)); return; @@ -481,9 +564,9 @@ class SqliteStore implements Age `State '${definition.id}' storage uses kernel format ${String(meta.kernel_format)}; this kernel reads format ${String(KERNEL_FORMAT)}`, ); } - const head = this.#headRow('open'); + const head = this.#headRow(transactionDb, 'open'); const journalHead = ( - this.#db.prepare('SELECT COALESCE(MAX(revision), 0) AS revision FROM agent_state_journal').get() as { + transactionDb.prepare('SELECT COALESCE(MAX(revision), 0) AS revision FROM agent_state_journal').get() as { revision: number; } ).revision; @@ -504,9 +587,23 @@ class SqliteStore implements Age state: migrated, toVersion: definition.version, }; - this.#appendRecord(record, migrated); - this.#db.prepare('UPDATE agent_state_meta SET schema_version = ? WHERE id = 1').run(definition.version); + this.#appendRecord(transactionDb, record, migrated); + transactionDb.prepare('UPDATE agent_state_meta SET schema_version = ? WHERE id = 1').run(definition.version); }); + return this.#runtime.run( + Effect.gen(function*() { + const db = yield* SqliteConnection; + yield* sqliteEffect(definitionId, 'configure storage', () => { + // busy_timeout first: switching journal modes takes the database + // lock, and two processes racing the very first open would otherwise + // fail SQLITE_BUSY with a zero retry budget. + db.exec(`PRAGMA busy_timeout = ${String(busyTimeoutMs)}`); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA synchronous = FULL'); + }); + yield* initializeStorage; + }), + ); } } @@ -520,65 +617,97 @@ export const createSqliteStateDriver = (options: SqliteStateDriverOptions): Agen } const now = options.now ?? ((): Date => new Date()); const openStores = new Set>(); + const pendingOpens = new Set>(); let closed = false; + let closing: Promise | undefined; + + const trackPendingOpen = (operation: Promise): Promise => { + const settled = operation.then( + () => undefined, + () => undefined, + ); + pendingOpens.add(settled); + void settled.then(() => { + pendingOpens.delete(settled); + }); + return operation; + }; return Object.freeze({ durable: true, kind: 'sqlite', lifetime: 'workspace-durable' as const, - async close(): Promise { + close(): Promise { + if (closing !== undefined) return closing; closed = true; - for (const store of [...openStores]) { - await store.close(); - } - openStores.clear(); + closing = (async () => { + while (pendingOpens.size > 0) { + await Promise.all([...pendingOpens]); + } + for (const store of [...openStores]) { + await store.close(); + } + openStores.clear(); + })(); + return closing; }, - async open( + open( definition: AgentStateDefinition, ): Promise> { - if (closed) { - throw new AgentStateError('store-closed', `State '${definition.id}' cannot open on a closed driver`); - } - if (definition.lifetime !== 'workspace-durable') { - throw new AgentStateError( - 'lifetime-mismatch', - `State '${definition.id}' declares lifetime '${definition.lifetime}' but this driver provides 'workspace-durable'`, - ); - } - const file = resolve( - options.file !== undefined ? options.file : join(options.root as string, sanitizedFileName(definition.id)), + return trackPendingOpen( + (async () => { + const file = await runPromise( + sqliteEffect(definition.id, 'resolve storage', () => { + if (closed) { + throw new AgentStateError('store-closed', `State '${definition.id}' cannot open on a closed driver`); + } + if (definition.lifetime !== 'workspace-durable') { + throw new AgentStateError( + 'lifetime-mismatch', + `State '${definition.id}' declares lifetime '${definition.lifetime}' but this driver provides 'workspace-durable'`, + ); + } + return resolve( + options.file !== undefined ? options.file : join(options.root as string, sanitizedFileName(definition.id)), + ); + }), + ); + const connection = Effect.acquireRelease( + sqliteEffect(definition.id, 'open database', () => { + mkdirSync(dirname(file), { recursive: true }); + return new DatabaseSync(file); + }, true), + (db) => + Effect.sync(() => { + db.close(); + }), + ); + const runtime = makeScopedEffectRuntime( + Layer.effect(SqliteConnection, connection), + ); + let store: SqliteStore; + try { + store = new SqliteStore(definition, file, now, () => + openStores.delete(store as unknown as SqliteStore), + runtime, + ); + await store.initialize(busyTimeoutMs); + } catch (error) { + await runtime.close(); + throw error; + } + if (closed) { + await store.close(); + return runPromise( + Effect.fail(new AgentStateError('store-closed', `State '${definition.id}' cannot open on a closed driver`)), + ); + } + openStores.add(store as unknown as SqliteStore); + return store; + })(), ); - let db: DatabaseSync; - try { - mkdirSync(dirname(file), { recursive: true }); - db = new DatabaseSync(file); - } catch (error) { - throw mapSqliteError(definition.id, 'open database', error); - } - let store: SqliteStore; - try { - // busy_timeout first: switching journal modes takes the database - // lock, and two processes racing the very first open would otherwise - // fail SQLITE_BUSY with a zero retry budget. - db.exec(`PRAGMA busy_timeout = ${String(busyTimeoutMs)}`); - db.exec('PRAGMA journal_mode = WAL'); - db.exec('PRAGMA synchronous = FULL'); - store = new SqliteStore(definition, db, file, now, () => - openStores.delete(store as unknown as SqliteStore), - ); - store.initialize(); - } catch (error) { - try { - db.close(); - } catch { - // Preserve the initialization failure. - } - throw mapSqliteError(definition.id, 'initialize storage', error); - } - openStores.add(store as unknown as SqliteStore); - return store; }, }); }; diff --git a/packages/rsc-runtime/tests/state-kernel.test.ts b/packages/rsc-runtime/tests/state-kernel.test.ts index 6532da613..af0750884 100644 --- a/packages/rsc-runtime/tests/state-kernel.test.ts +++ b/packages/rsc-runtime/tests/state-kernel.test.ts @@ -180,6 +180,47 @@ describe('createMemoryStateDriver', () => { await expect(store.read()).rejects.toMatchObject({ code: 'store-closed' }); await expect(driver.open(counterDefinition())).rejects.toMatchObject({ code: 'store-closed' }); }); + + it('driver close finalizes request-scoped stores exactly once', async () => { + const driver = createMemoryStateDriver({ lifetime: 'request' }); + const store = await driver.open(counterDefinition('request')); + const closing = driver.close(); + const repeatedClose = driver.close(); + await Promise.all([closing, repeatedClose]); + await driver.close(); + await store.close(); + await expect(store.read()).rejects.toMatchObject({ code: 'store-closed' }); + }); + + it('settles a pending open with store-closed before driver close resolves', async () => { + const driver = createMemoryStateDriver({ lifetime: 'request' }); + const pendingOpen = driver.open(counterDefinition('request')); + let settled = false; + const observedOpen = pendingOpen.then( + (store) => { + settled = true; + return { status: 'success' as const, store }; + }, + (error: unknown) => { + settled = true; + return { error, status: 'failure' as const }; + }, + ); + + await driver.close(); + const settledBeforeCloseResolved = settled; + const outcome = await observedOpen; + if (outcome.status === 'success') await outcome.store.close(); + + expect(settledBeforeCloseResolved).toBe(true); + expect(outcome).toMatchObject({ + status: 'failure', + error: { + code: 'store-closed', + name: 'AgentStateError', + }, + }); + }); }); describe('request context state slot', () => { diff --git a/packages/rsc-runtime/tests/state-packaging.test.ts b/packages/rsc-runtime/tests/state-packaging.test.ts index ce6ae8555..0391ed1aa 100644 --- a/packages/rsc-runtime/tests/state-packaging.test.ts +++ b/packages/rsc-runtime/tests/state-packaging.test.ts @@ -22,7 +22,7 @@ describe.sequential('state kernel packaging boundaries', () => { it('keeps every kernel and storage identifier out of the root and plugin entries', async () => { for (const entry of ['index.js', 'plugin.js']) { const source = await distFile(entry); - for (const identifier of ['node:sqlite', 'defineState', 'AgentStateError', 'DatabaseSync', 'agent_state_journal']) { + for (const identifier of ['node:sqlite', 'defineState', 'AgentStateError', 'DatabaseSync', 'agent_state_journal', 'from "effect"', 'Effect.runPromise']) { expect(source, `${entry} must not contain ${identifier}`).not.toContain(identifier); } } diff --git a/packages/rsc-runtime/tests/state-sqlite.test.ts b/packages/rsc-runtime/tests/state-sqlite.test.ts index 75ac77108..f399c6a3d 100644 --- a/packages/rsc-runtime/tests/state-sqlite.test.ts +++ b/packages/rsc-runtime/tests/state-sqlite.test.ts @@ -128,6 +128,43 @@ describe('sqlite driver storage behavior', () => { await expect(first.read()).rejects.toMatchObject({ code: 'store-closed' }); })); + it('rejects a pending open when the driver closes before initialization resumes', () => + withRoot(async (root) => { + const driver = createSqliteStateDriver({ root }); + const pendingOpen = driver.open(counterDefinition()); + let settled = false; + const observedOpen = pendingOpen.then( + (store) => { + settled = true; + return { status: 'success' as const, store }; + }, + (error: unknown) => { + settled = true; + return { error, status: 'failure' as const }; + }, + ); + + const closing = driver.close(); + const repeatedClose = driver.close(); + await Promise.all([closing, repeatedClose]); + const settledBeforeCloseResolved = settled; + const outcome = await observedOpen; + if (outcome.status === 'success') await outcome.store.close(); + + expect(settledBeforeCloseResolved).toBe(true); + expect(outcome).toMatchObject({ + status: 'failure', + error: { + code: 'store-closed', + name: 'AgentStateError', + }, + }); + await expect(driver.open(counterDefinition())).rejects.toMatchObject({ + code: 'store-closed', + name: 'AgentStateError', + }); + })); + it('runs WAL journal mode with full synchronous durability', () => withRoot(async (root) => { const store = await createSqliteStateDriver({ root }).open(counterDefinition()); @@ -141,6 +178,33 @@ describe('sqlite driver storage behavior', () => { await store.close(); })); + it('rolls back failed transactions without collapsing unexpected defects', () => + withRoot(async (root) => { + const defect = new Error('clock implementation defect'); + let shouldFail = true; + const driver = createSqliteStateDriver({ + now: () => { + if (shouldFail) { + shouldFail = false; + throw defect; + } + return new Date('2026-01-01T00:00:00.000Z'); + }, + root, + }); + const store = await driver.open(counterDefinition()); + + await expect( + store.dispatch('bumped', { by: 1 }, { idempotencyKey: 'defect' }), + ).rejects.toBe(defect); + await expect(store.read()).resolves.toEqual({ revision: 0, state: { count: 0 } }); + await expect( + store.dispatch('bumped', { by: 2 }, { idempotencyKey: 'recovered' }), + ).resolves.toMatchObject({ replayed: false, revision: 1, state: { count: 2 } }); + + await driver.close(); + })); + it('fails closed with a typed corrupt error when the file is not a database', () => withRoot(async (root) => { const file = join(root, 'state.sqlite');