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
+30 -13
View File
@@ -37,31 +37,48 @@ export const Config: z<Config> = z.object({
/** JSON backend: owns the file-tree root and serves the `kv` facet. */
export class JsonStorageBackend implements StorageBackend {
private readonly open = new Map<string, KvUnit>()
// Reserved synchronously at open() entry so a concurrent open of the same
// unit fails, and close() can await opens still in flight.
private readonly opening = new Map<string, Promise<KvUnit>>()
private closed = false
constructor(private readonly root: string) {}
readonly kv: KvFacet = {
open: async (descriptor: KvUnitDescriptor): Promise<KvUnit> => {
if (this.closed) throw new StorageError('closed', 'json backend is closed')
open: (descriptor: KvUnitDescriptor): Promise<KvUnit> => {
if (this.closed) return Promise.reject(new StorageError('closed', 'json backend is closed'))
validateDescriptor(descriptor)
if (this.open.has(descriptor.name)) {
throw new StorageError(
'malformed-medium',
`unit '${descriptor.name}' is already open; a unit has exactly one live handle`,
if (this.open.has(descriptor.name) || this.opening.has(descriptor.name)) {
// Double-open is a caller bug, not a medium condition.
return Promise.reject(
new Error(`unit '${descriptor.name}' is already open; a unit has exactly one live handle`),
)
}
await mkdir(this.root, { recursive: true, mode: 0o700 })
const path = join(this.root, `${descriptor.name}.json`)
const unit = await openJsonUnit(descriptor, path, () => this.open.delete(descriptor.name))
this.open.set(descriptor.name, unit)
return unit
const opening = this.openUnit(descriptor)
this.opening.set(descriptor.name, opening)
return opening.finally(() => this.opening.delete(descriptor.name))
},
}
private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
await mkdir(this.root, { recursive: true, mode: 0o700 })
const path = join(this.root, `${descriptor.name}.json`)
const unit = await openJsonUnit(descriptor, path, () => this.open.delete(descriptor.name))
if (this.closed) {
// The backend closed while this open was in flight: do not hand out a
// live unit past close().
await unit.close()
throw new StorageError('closed', 'json backend is closed')
}
this.open.set(descriptor.name, unit)
return unit
}
async close(): Promise<void> {
if (this.closed) return
this.closed = true
if (!this.closed) {
this.closed = true
}
await Promise.allSettled([...this.opening.values()])
for (const unit of [...this.open.values()]) {
await unit.close()
}
+37 -8
View File
@@ -40,6 +40,8 @@ export async function openJsonUnit(
class JsonKvUnit implements KvUnit {
private closed = false
/** In-flight publishes; close() drains them before releasing the unit. */
private readonly inFlight = new Set<Promise<void>>()
constructor(
private readonly descriptor: KvUnitDescriptor,
@@ -59,15 +61,29 @@ class JsonKvUnit implements KvUnit {
async putRecord(table: string, key: string, value: unknown): Promise<void> {
this.assertOpen()
this.records(table).set(key, value)
await this.publish()
const records = this.records(table)
const hadKey = records.has(key)
const previous = records.get(key)
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) => {
if (hadKey) records.set(key, previous)
else records.delete(key)
throw error
})
}
async deleteRecord(table: string, key: string): Promise<void> {
this.assertOpen()
if (this.records(table).delete(key)) {
await this.publish()
}
const records = this.records(table)
if (!records.has(key)) return
const previous = records.get(key)
records.delete(key)
await this.publish().catch(async (error) => {
records.set(key, previous)
throw error
})
}
async setGlobal(value: unknown): Promise<void> {
@@ -75,13 +91,21 @@ class JsonKvUnit implements KvUnit {
if (!this.descriptor.hasGlobal) {
throw new Error(`unit '${this.descriptor.name}' does not declare a global slot`)
}
const previous = this.state.global
this.state.global = value
await this.publish()
await this.publish().catch(async (error) => {
this.state.global = previous
throw error
})
}
async close(): Promise<void> {
if (this.closed) return
if (this.closed) {
await Promise.allSettled(this.inFlight)
return
}
this.closed = true
await Promise.allSettled(this.inFlight)
this.onClose()
}
@@ -100,6 +124,11 @@ class JsonKvUnit implements KvUnit {
}
private publish(): Promise<void> {
return writeAtomic(this.path, serialize(this.descriptor.name, this.state))
const write = writeAtomic(this.path, serialize(this.descriptor.name, this.state))
this.inFlight.add(write)
// Swallow only on the tracking branch: the caller still awaits `write`
// itself, so rejections stay observed exactly once.
write.catch(() => {}).finally(() => this.inFlight.delete(write))
return write
}
}