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

View File

@@ -55,7 +55,10 @@ export class Storage extends Service {
}
this.forms.set(form, facility)
return () => {
this.forms.delete(form)
// Same stale-disposer guard as BackendRegistry.register.
if (this.forms.get(form) === facility) {
this.forms.delete(form)
}
}
}
@@ -77,10 +80,7 @@ export class Storage extends Service {
}
}
/**
* Mount the storage hub service.
* @param ctx - Plugin context.
*/
export function apply(ctx: Context) {
ctx.plugin(Storage)
}
// Service packages default-export their service class and nothing else
// plugin-shaped (packages/AGENTS.md): mixing a default export with a
// function-plugin `apply` makes the Loader drop the plugin namespace.
export default Storage

View File

@@ -28,7 +28,11 @@ export class BackendRegistry {
}
this.backends.set(name, backend)
return () => {
this.backends.delete(name)
// Remove only this registration's contribution: after dispose + re-register,
// a stale disposer firing again must not remove the successor.
if (this.backends.get(name) === backend) {
this.backends.delete(name)
}
}
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { BackendRegistry, Storage, apply } from '../src/index.ts'
import Storage, { BackendRegistry } from '../src/index.ts'
import type { StorageBackend } from '../src/index.ts'
const fakeBackend = (): StorageBackend => ({ close: async () => {} })
@@ -27,7 +27,7 @@ describe('BackendRegistry', () => {
describe('Storage service', () => {
it('mounts on the context and exposes registry plus form mounting', async () => {
const ctx = new Context()
await ctx.plugin({ apply })
await ctx.plugin(Storage)
expect(ctx.storage).toBeInstanceOf(Storage)
const facility = { marker: true }