fix(storage,workspace): post-review hardening

Review findings applied across the group:
- storage hub: stale disposers no longer remove a successor registration;
  the package now default-exports the Storage service class per the
  service-package export shape.
- json backend: failed publishes roll back the authoritative memory state
  (a rejected write can no longer resurface via get() or ride the next
  publish); close() drains in-flight writes and blocks in-flight opens;
  double-open rejects as a plain caller error instead of malformed-medium.
- sqlite backend: loadAll builds records on a null prototype (__proto__
  keys round-trip instead of polluting), user_version is stamped only
  after the schema is fully created, and corrupt record JSON rejects as
  malformed-medium instead of a bare SyntaxError.
- domain form: writes persist before mutating authoritative memory or
  emitting; DomainChanged is a put/deleted discriminated union.
- workspace: attach/detach idempotence decided on the write chain (stale
  snapshots no longer short-circuit), create() requires a directory, and
  startup fails loud on duplicate stored paths.

Eleven regression tests pin the fixed behaviors.
This commit is contained in:
imccyu
2026-07-24 21:30:32 +08:00
parent 3f16cb4c3c
commit 80b3b6d917
19 changed files with 470 additions and 136 deletions
@@ -81,10 +81,6 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
`storage database at "${path}" has schema version ${onDisk}, incompatible with this build (${STORAGE_SQLITE_SCHEMA_VERSION})`,
)
}
if (onDisk === 0) {
// Stamp fresh databases.
db.exec(`PRAGMA user_version = ${STORAGE_SQLITE_SCHEMA_VERSION}`)
}
db.exec(`
CREATE TABLE IF NOT EXISTS units (
name TEXT PRIMARY KEY,
@@ -97,6 +93,12 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
value TEXT NOT NULL
) STRICT
`)
if (onDisk === 0) {
// Stamp fresh databases LAST: the stamp asserts the layout is complete,
// so a failure above must leave the medium unstamped (a re-open after
// the obstruction is cleared retries materialization from scratch).
db.exec(`PRAGMA user_version = ${STORAGE_SQLITE_SCHEMA_VERSION}`)
}
}
/**
+18 -3
View File
@@ -66,20 +66,35 @@ export class SqliteKvUnit implements KvUnit {
this.ensureOpen()
const tables: Record<string, Record<string, unknown>> = {}
for (const [name, statements] of this.tables) {
const records: Record<string, unknown> = {}
// Null prototype: record keys are arbitrary strings, so '__proto__'
// must land as an own property instead of mutating the prototype.
const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>
for (const row of statements.selectAll.all() as unknown as Array<{ key: string; value: string }>) {
records[row.key] = JSON.parse(row.value)
records[row.key] = this.parseValue(row.value, `table '${name}' key '${row.key}'`)
}
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 = JSON.parse(row.value)
if (row !== undefined) global = this.parseValue(row.value, 'global slot')
}
return { tables, global }
}
/** Parse one stored value column, mapping bad JSON to `malformed-medium`. */
private parseValue(text: string, slot: string): unknown {
try {
return JSON.parse(text)
} catch (error) {
throw new StorageError(
'malformed-medium',
`kv unit '${this.descriptor.name}' holds unparsable JSON at ${slot}`,
{ cause: error },
)
}
}
async putRecord(table: string, key: string, value: unknown): Promise<void> {
this.ensureOpen()
this.statementsFor(table).upsert.run(key, JSON.stringify(value))