diff --git a/packages/storage/domain/src/domain.ts b/packages/storage/domain/src/domain.ts index aa29348dfc..227553c7ce 100644 --- a/packages/storage/domain/src/domain.ts +++ b/packages/storage/domain/src/domain.ts @@ -164,9 +164,9 @@ export class DomainImpl { const host: TableHost = { domainName: spec.name, unit, - enqueue: (job) => this.enqueue(job), - assertReadable: () => this.assertReadable(), - emitChanged: (change) => this.ctx.emit('domain/changed', change), + enqueue: job => this.enqueue(job), + assertReadable: () => { this.assertReadable() }, + emitChanged: (change) => { this.ctx.emit('domain/changed', change) }, } for (const [table, tableRecords] of records) { this.tables.set(table, new KvTableImpl(host, table, tableRecords)) @@ -178,7 +178,7 @@ export class DomainImpl { this.assertReadable() return this.globalValue }, - set: (value) => this.enqueue(async () => { + set: value => this.enqueue(async () => { await this.unit.setGlobal(value) this.globalValue = value host.emitChanged({ domain: this.name, table: '', key: '', operation: 'put', value }) diff --git a/packages/storage/domain/src/index.ts b/packages/storage/domain/src/index.ts index 059f6076f2..fafa97ebae 100644 --- a/packages/storage/domain/src/index.ts +++ b/packages/storage/domain/src/index.ts @@ -113,11 +113,12 @@ export class DomainFacility { } // A null stored global means "never written": serve `initial` without // materializing it — the first `set` writes. - const globalValue = spec.global === undefined + const globalSpec = spec.global + const globalValue = globalSpec === undefined ? undefined : snapshot.global === null - ? spec.global.initial - : parseRecord(spec.name, '', '', () => spec.global!.schema.parse(snapshot.global)) + ? globalSpec.initial + : parseRecord(spec.name, '', '', () => globalSpec.schema.parse(snapshot.global)) const domain = new DomainImpl(this.ctx, spec, unit, tables, globalValue) // The open-domain table entry is itself the effect: registration and // the drain-then-unlist teardown live in one closure. diff --git a/packages/storage/domain/src/spec.ts b/packages/storage/domain/src/spec.ts index 783812174d..df833bebe1 100644 --- a/packages/storage/domain/src/spec.ts +++ b/packages/storage/domain/src/spec.ts @@ -45,7 +45,7 @@ export interface DomainSpec { /** Key type of one declared table, recovered from its phantom carrier. */ export type TableKeyOf = - S['tables'][N] extends DomainTableSpec ? K : never + S['tables'][N] extends DomainTableSpec ? K : never /** Value type of one declared table. */ export type TableValueOf = diff --git a/packages/storage/domain/tests/domain.spec.ts b/packages/storage/domain/tests/domain.spec.ts index 909c983061..ebff665c08 100644 --- a/packages/storage/domain/tests/domain.spec.ts +++ b/packages/storage/domain/tests/domain.spec.ts @@ -174,14 +174,14 @@ describe('KvTable writes', () => { const table = (await facility.open(spec)).table('items') await table.put('counter', { label: 'c', count: 0 }) await Promise.all(Array.from({ length: 50 }, () => - table.update('counter', (current) => ({ ...current, count: current.count + 1 })))) + table.update('counter', current => ({ ...current, count: current.count + 1 })))) expect(table.get('counter')).toEqual({ label: 'c', count: 50 }) }) it('update rejects a missing key; delete reports prior existence', async () => { const { facility } = await harness() const table = (await facility.open(spec)).table('items') - await expect(table.update('ghost', (v) => v)).rejects.toMatchObject({ code: 'missing-key' }) + await expect(table.update('ghost', v => v)).rejects.toMatchObject({ code: 'missing-key' }) await table.put('a', { label: 'x', count: 1 }) await expect(table.delete('a')).resolves.toBe(true) await expect(table.delete('a')).resolves.toBe(false) @@ -192,7 +192,7 @@ describe('KvTable writes', () => { const domain = await facility.open(spec) const table = domain.table('items') await table.put('a', { label: 'x', count: 1 }) - await table.update('a', (current) => ({ ...current, count: 2 })) + await table.update('a', current => ({ ...current, count: 2 })) await table.delete('a') await table.delete('a') // no event: already absent await domain.global.set({ theme: 'dark' }) @@ -216,7 +216,7 @@ describe('durability failure', () => { pool.failNextWrites = 3 await expect(table.put('a', { label: 'x', count: 99 })).rejects.toThrow(/injected/) - await expect(table.update('a', (c) => ({ ...c, count: c.count + 1 }))).rejects.toThrow(/injected/) + await expect(table.update('a', c => ({ ...c, count: c.count + 1 }))).rejects.toThrow(/injected/) await expect(table.delete('a')).rejects.toThrow(/injected/) // Reads still serve the pre-failure record; no events leaked. @@ -225,7 +225,7 @@ describe('durability failure', () => { expect(changes).toHaveLength(seen) // The chain survives rejections: the next write lands cleanly with no residue. - await table.update('a', (c) => ({ ...c, count: c.count + 1 })) + await table.update('a', c => ({ ...c, count: c.count + 1 })) expect(table.get('a')).toEqual({ label: 'x', count: 2 }) }) diff --git a/packages/storage/domain/tests/helpers/memory-backend.ts b/packages/storage/domain/tests/helpers/memory-backend.ts index 906202becf..1eb1bffe2d 100644 --- a/packages/storage/domain/tests/helpers/memory-backend.ts +++ b/packages/storage/domain/tests/helpers/memory-backend.ts @@ -18,10 +18,10 @@ import { StorageError } from '@deepseek-ai/dsh-storage' import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage' -/** One unit's medium: tables of records plus the global slot. */ +/** One unit's medium: tables of records plus the global slot (`null` = never written). */ export interface MemoryMedium { tables: Map> - global: unknown | null + global: unknown } /** @@ -70,7 +70,7 @@ class MemoryKvUnit implements KvUnit { } } - async loadAll(): Promise<{ tables: Record>; global: unknown | null }> { + async loadAll(): Promise<{ tables: Record>; global: unknown }> { this.assertOpen() const tables: Record> = {} for (const table of this.descriptor.tables) { diff --git a/packages/storage/domain/tests/invariant.spec.ts b/packages/storage/domain/tests/invariant.spec.ts index 00b9329464..c16dbe72c3 100644 --- a/packages/storage/domain/tests/invariant.spec.ts +++ b/packages/storage/domain/tests/invariant.spec.ts @@ -29,7 +29,7 @@ async function setup() { return { ctx, facility } } -const invariantViolation = expect.objectContaining>({ +const invariantViolation: unknown = expect.objectContaining>({ code: 'INVARIANT', packageName: '@deepseek-ai/dsh-domain', }) @@ -40,42 +40,42 @@ describe('domain change-event invariants', () => { const domain = await facility.open(spec) const rows = domain.table('rows') await rows.put('a', { n: 1 }) - await rows.update('a', (current) => ({ n: current.n + 1 })) + await rows.update('a', current => ({ n: current.n + 1 })) await expect(rows.delete('a')).resolves.toBe(true) await domain.global.set({ n: 5 }) }) it('rejects an event for a domain that is not open', async () => { const { ctx } = await setup() - expect(() => ctx.emit('domain/changed', { + expect(() => { ctx.emit('domain/changed', { domain: 'ghost', table: 'rows', key: 'a', operation: 'put', value: { n: 1 }, - })).toThrow(invariantViolation) + }) }).toThrow(invariantViolation) }) it('rejects a put event whose value is not the in-memory record', async () => { const { ctx, facility } = await setup() const domain = await facility.open(spec) await domain.table('rows').put('a', { n: 1 }) - expect(() => ctx.emit('domain/changed', { + expect(() => { ctx.emit('domain/changed', { domain: 'inv', table: 'rows', key: 'a', operation: 'put', value: { n: 999 }, - })).toThrow(invariantViolation) + }) }).toThrow(invariantViolation) }) it('rejects a deletion event while the record is still in memory', async () => { const { ctx, facility } = await setup() const domain = await facility.open(spec) await domain.table('rows').put('a', { n: 1 }) - expect(() => ctx.emit('domain/changed', { + expect(() => { ctx.emit('domain/changed', { domain: 'inv', table: 'rows', key: 'a', operation: 'deleted', - })).toThrow(invariantViolation) + }) }).toThrow(invariantViolation) }) it('rejects a global event whose value is not the in-memory global', async () => { const { ctx, facility } = await setup() await facility.open(spec) - expect(() => ctx.emit('domain/changed', { + expect(() => { ctx.emit('domain/changed', { domain: 'inv', table: '', key: '', operation: 'put', value: { n: 42 }, - })).toThrow(invariantViolation) + }) }).toThrow(invariantViolation) }) it('tolerates operations outside the closed union without failing falsely', async () => { @@ -84,8 +84,8 @@ describe('domain change-event invariants', () => { await domain.table('rows').put('a', { n: 1 }) // Merge-hostile input: the closed union's satisfies-never default arm is // unreachable in typed code; an untyped emit must not crash the check. - expect(() => ctx.emit('domain/changed', { + expect(() => { ctx.emit('domain/changed', { domain: 'inv', table: 'rows', key: 'a', operation: 'exotic', - } as unknown as DomainChanged)).not.toThrow() + } as unknown as DomainChanged) }).not.toThrow() }) }) diff --git a/packages/storage/storage-json/src/format.ts b/packages/storage/storage-json/src/format.ts index 9a16e7bf32..55efb830b7 100644 --- a/packages/storage/storage-json/src/format.ts +++ b/packages/storage/storage-json/src/format.ts @@ -8,10 +8,10 @@ import { StorageError } from '@deepseek-ai/dsh-storage' import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage' -/** In-memory authoritative state of one unit; the file is its projection. */ +/** In-memory authoritative state of one unit; the file is its projection. `global` is `null` until first written. */ export interface UnitState { version: number - global: unknown | null + global: unknown tables: Map> } diff --git a/packages/storage/storage-json/src/unit.ts b/packages/storage/storage-json/src/unit.ts index 6677abacf2..1bf272ec19 100644 --- a/packages/storage/storage-json/src/unit.ts +++ b/packages/storage/storage-json/src/unit.ts @@ -36,10 +36,10 @@ export async function openJsonUnit( const state: UnitState = text === undefined ? { - version: descriptor.version, - global: null, - tables: new Map(descriptor.tables.map((table) => [table, new Map()])), - } + version: descriptor.version, + global: null, + tables: new Map(descriptor.tables.map(table => [table, new Map()])), + } : parse(text, descriptor) return new JsonKvUnit(descriptor, path, state, onClose) } @@ -56,13 +56,13 @@ class JsonKvUnit implements KvUnit { private readonly onClose: () => void, ) {} - async loadAll(): Promise<{ tables: Record>; global: unknown | null }> { + loadAll(): Promise<{ tables: Record>; global: unknown }> { this.assertOpen() const tables: Record> = {} for (const [table, records] of this.state.tables) { tables[table] = Object.fromEntries(records) } - return { tables, global: this.state.global } + return Promise.resolve({ tables, global: this.state.global }) } async putRecord(table: string, key: string, value: unknown): Promise { @@ -73,7 +73,7 @@ class JsonKvUnit implements KvUnit { records.set(key, value) // Roll back on a failed publish: memory is authoritative, so a rejected // write must not survive in memory (or ride along with the next publish). - await this.publish().catch(async (error) => { + await this.publish().catch((error: unknown) => { if (hadKey) records.set(key, previous) else records.delete(key) throw error @@ -86,7 +86,7 @@ class JsonKvUnit implements KvUnit { if (!records.has(key)) return const previous = records.get(key) records.delete(key) - await this.publish().catch(async (error) => { + await this.publish().catch((error: unknown) => { records.set(key, previous) throw error }) @@ -99,7 +99,7 @@ class JsonKvUnit implements KvUnit { } const previous = this.state.global this.state.global = value - await this.publish().catch(async (error) => { + await this.publish().catch((error: unknown) => { this.state.global = previous throw error }) diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index 83ed948fd0..870498f0ea 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -78,7 +78,7 @@ describe('json backend specifics', () => { const root = await freshRoot() const backend = new JsonStorageBackend(root) await backend.kv.open(descriptor) - await expect(backend.kv.open(descriptor)).rejects.toThrowError(/already open/) + await expect(backend.kv.open(descriptor)).rejects.toThrow(/already open/) await backend.close() }) @@ -208,13 +208,15 @@ describe('json backend specifics', () => { const bigWrite = unit.putRecord('t', 'big', { blob: 'x'.repeat(4 * 1024 * 1024) }) await unit.close() await expect(bigWrite).resolves.toBeUndefined() - const onDisk = JSON.parse(await readFile(join(root, 'shape.json'), 'utf8')) - expect(onDisk.tables.t.big).toBeDefined() + const onDisk = JSON.parse(await readFile(join(root, 'shape.json'), 'utf8')) as { + tables: Record> + } + expect(onDisk.tables['t']?.['big']).toBeDefined() const backend2 = new JsonStorageBackend(root) const opening = backend2.kv.open(descriptor) const closing = backend2.close() - await expect(opening.then((u) => u.putRecord('t', 'x', {}))).rejects.toMatchObject({ code: 'closed' }) + await expect(opening.then(u => u.putRecord('t', 'x', {}))).rejects.toMatchObject({ code: 'closed' }) await closing }) }) diff --git a/packages/storage/storage-sqlite/src/unit.ts b/packages/storage/storage-sqlite/src/unit.ts index c525341e7d..d8260b3108 100644 --- a/packages/storage/storage-sqlite/src/unit.ts +++ b/packages/storage/storage-sqlite/src/unit.ts @@ -62,24 +62,25 @@ export class SqliteKvUnit implements KvUnit { : undefined } - async loadAll(): Promise<{ tables: Record>; global: unknown | null }> { - this.ensureOpen() - const tables: Record> = {} - for (const [name, statements] of this.tables) { - // Null prototype: record keys are arbitrary strings, so '__proto__' - // must land as an own property instead of mutating the prototype. - const records: Record = Object.create(null) as Record - for (const row of statements.selectAll.all() as unknown as Array<{ key: string; value: string }>) { - records[row.key] = this.parseValue(row.value, `table '${name}' key '${row.key}'`) + loadAll(): Promise<{ tables: Record>; global: unknown }> { + return this.settle(() => { + const tables: Record> = {} + for (const [name, statements] of this.tables) { + // Null prototype: record keys are arbitrary strings, so '__proto__' + // must land as an own property instead of mutating the prototype. + const records: Record = Object.create(null) as Record + for (const row of statements.selectAll.all() as unknown as Array<{ key: string; value: string }>) { + records[row.key] = this.parseValue(row.value, `table '${name}' key '${row.key}'`) + } + tables[name] = records } - tables[name] = records - } - let global: unknown = null - if (this.globalSelect !== undefined) { - const row = this.globalSelect.get(this.descriptor.name) as { value: string } | undefined - if (row !== undefined) global = this.parseValue(row.value, 'global slot') - } - return { tables, global } + let global: unknown = null + if (this.globalSelect !== undefined) { + const row = this.globalSelect.get(this.descriptor.name) as { value: string } | undefined + if (row !== undefined) global = this.parseValue(row.value, 'global slot') + } + return { tables, global } + }) } /** Parse one stored value column, mapping bad JSON to `malformed-medium`. */ @@ -95,28 +96,48 @@ export class SqliteKvUnit implements KvUnit { } } - async putRecord(table: string, key: string, value: unknown): Promise { - this.ensureOpen() - this.statementsFor(table).upsert.run(key, JSON.stringify(value)) + putRecord(table: string, key: string, value: unknown): Promise { + return this.settle(() => { + this.statementsFor(table).upsert.run(key, JSON.stringify(value)) + }) } - async deleteRecord(table: string, key: string): Promise { - this.ensureOpen() - this.statementsFor(table).remove.run(key) + deleteRecord(table: string, key: string): Promise { + return this.settle(() => { + this.statementsFor(table).remove.run(key) + }) } - async setGlobal(value: unknown): Promise { - this.ensureOpen() - if (this.globalUpsert === undefined) { - throw new Error(`kv unit '${this.descriptor.name}' declared no global slot`) + setGlobal(value: unknown): Promise { + return this.settle(() => { + if (this.globalUpsert === undefined) { + throw new Error(`kv unit '${this.descriptor.name}' declared no global slot`) + } + this.globalUpsert.run(this.descriptor.name, JSON.stringify(value)) + }) + } + + close(): Promise { + if (!this.closed) { + this.closed = true + this.onClose() } - this.globalUpsert.run(this.descriptor.name, JSON.stringify(value)) + return Promise.resolve() } - async close(): Promise { - if (this.closed) return - this.closed = true - this.onClose() + /** + * Run one synchronous primitive behind the closed guard, mapping a throw to + * a rejection so the Promise-returning contract never throws synchronously. + */ + private settle(operation: () => T): Promise { + try { + this.ensureOpen() + return Promise.resolve(operation()) + } catch (error) { + // Non-Error throws can only enter through JSON.stringify propagating a + // value's own toJSON throw; wrap those, preserve every real Error. + return Promise.reject(error instanceof Error ? error : new Error(String(error))) + } } private ensureOpen(): void { diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index e269de25dd..b5ddd46fb1 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -171,6 +171,17 @@ describe('sqlite backend specifics', () => { await reopened.close() }) + it('wraps a non-Error toJSON throw into an Error rejection', async () => { + const backend = backendAt(':memory:') + const unit = await backend.kv.open(DESCRIPTOR) + // JSON.stringify propagates a value's own toJSON throw verbatim; the unit + // must still reject with an Error instance. + const hostile = { toJSON: () => { throw 'not an error' } } + await expect(unit.putRecord('records', 'k', hostile)).rejects.toThrow('not an error') + await expect(unit.putRecord('records', 'k', hostile)).rejects.toBeInstanceOf(Error) + await backend.close() + }) + it('rejects setGlobal on a unit without a global slot and writes to undeclared tables', async () => { const backend = backendAt(':memory:') const unit = await backend.kv.open({ ...DESCRIPTOR, hasGlobal: false }) diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index 9b029e7895..d9070874ca 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -69,7 +69,7 @@ export interface KvUnit { * @returns every table's records keyed by table name, plus the global * singleton (`null` when never written or not declared). */ - loadAll(): Promise<{ tables: Record>; global: unknown | null }> + loadAll(): Promise<{ tables: Record>; global: unknown }> /** * Upsert one record durably. Overwrite semantics: an existing key is replaced. diff --git a/packages/storage/storage/src/index.ts b/packages/storage/storage/src/index.ts index 0e065d00f3..15fb70d778 100644 --- a/packages/storage/storage/src/index.ts +++ b/packages/storage/storage/src/index.ts @@ -76,7 +76,7 @@ export class Storage extends Service { /** Domain data form; present once the domain layer plugin is loaded. */ get domain(): StorageForms extends { domain: infer D } ? D : never { - return this.form('domain' as keyof StorageForms) as StorageForms extends { domain: infer D } ? D : never + return this.form('domain' as keyof StorageForms) } } diff --git a/packages/storage/storage/tests/registry.spec.ts b/packages/storage/storage/tests/registry.spec.ts index a0b11503c7..413bbe914c 100644 --- a/packages/storage/storage/tests/registry.spec.ts +++ b/packages/storage/storage/tests/registry.spec.ts @@ -69,7 +69,7 @@ expect.extend({ received() } catch (error) { const pass = Object.entries(expected).every( - (entry) => (error as Record)[entry[0]] === entry[1], + entry => (error as Record)[entry[0]] === entry[1], ) return { pass, message: () => `expected thrown error to match ${JSON.stringify(expected)}, got ${String(error)}` } }