fix(storage,workspace): review-bot findings — emit isolation, domain ownership, null globals

- domain/changed emission is isolated from the write path: an observer
  throwing synchronously can no longer turn a committed (durable +
  in-memory) write into a rejection; the failure is logged and later
  writes proceed.
- Domain lifecycle belongs to the opening consumer: Domain gains an
  idempotent close() (drain, unit close, reservation release), the
  facility stops registering effects on its own context and instead
  closes any still-open domains on unmount; WorkspaceRegistry holds its
  domain through its own effect, so disposing and re-mounting the
  consumer no longer wedges on already-open.
- defineDomain rejects a global schema accepting null at declaration
  time: JSON null is the medium's absence sentinel, so a nullable global
  could never round-trip; failing loud at the spec keeps set(null)
  unrepresentable.

Regression tests cover all three (hostile listener, close/reopen and
consumer re-mount, nullable-global rejection).
This commit is contained in:
imccyu
2026-07-25 00:01:21 +08:00
parent 1bddf5269a
commit 507dd25a3a
7 changed files with 160 additions and 36 deletions

View File

@@ -84,6 +84,9 @@ export class WorkspaceRegistry extends Service {
/** Open the domain and rebuild the entity cache before the service is published as active. */
protected async [Service.init](): Promise<void> {
const domain = await this.ctx.storage.domain.open(workspaceDomainSpec)
// This registry owns the domain handle it opened: closing on fiber
// disposal frees the domain name, so a re-plugged registry can reopen it.
this.ctx.effect(() => () => domain.close(), 'workspace.domainClose')
this.table = domain.table('workspaces')
const persistence = this.ctx.get('sessionPersistence')
if (persistence !== undefined) {

View File

@@ -185,6 +185,24 @@ describe('WorkspaceRegistry.create', () => {
const registry = new WorkspaceRegistry(ctx)
await expect(registry.create(dir)).rejects.toThrow(/not started/)
})
it('closes its domain on fiber disposal so a re-plugged registry reopens it', async () => {
const dir = await makeDir('replug')
const ctx = new Context()
await ctx.plugin(Storage)
ctx.storage.backend.register('memory', new MemoryStorageBackend())
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
const fiber = ctx.plugin(WorkspaceRegistry)
await fiber
const first = await ctx.workspace.create(dir)
await fiber.dispose()
// The registry's effect closed the domain, freeing the name: a second
// plugin of the same registry must reopen it (not already-open) and see
// the durable record.
await ctx.plugin(WorkspaceRegistry)
const reloaded = await ctx.workspace.resolveByPath(dir)
expect(reloaded?.id).toBe(first.id)
})
})
describe('Workspace.attachSession', () => {