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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user