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:
@@ -1,6 +1,6 @@
|
|||||||
# @deepseek-ai/dsh-domain
|
# @deepseek-ai/dsh-domain
|
||||||
|
|
||||||
Domain data form for the DeepSeek Harness storage hub: mounts `ctx.storage.domain`, opening schema-validated KV domains over configured storage backends. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`.
|
Domain data form for the DeepSeek Harness storage hub: mounts `ctx.storage.domain`, opening schema-validated KV domains over configured storage backends. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`. The opening consumer owns the handle's lifecycle and releases it with `Domain.close()` (idempotent; typically its own `ctx.effect` disposer); domains still open when the plugin unmounts are closed by the facility.
|
||||||
|
|
||||||
Design rationale, open semantics, and the storage/domain layer split live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
Design rationale, open semantics, and the storage/domain layer split live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ export interface Domain<S extends DomainSpec> {
|
|||||||
* @returns the typed table handle.
|
* @returns the typed table handle.
|
||||||
*/
|
*/
|
||||||
table<N extends keyof S['tables'] & string>(name: N): KvTable<TableKeyOf<S, N>, TableValueOf<S, N>>
|
table<N extends keyof S['tables'] & string>(name: N): KvTable<TableKeyOf<S, N>, TableValueOf<S, N>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close this domain: reject new writes immediately, drain already-queued
|
||||||
|
* writes (their events still emit), release the backend unit, then free
|
||||||
|
* the domain name for a later open. Idempotent — repeated calls share one
|
||||||
|
* teardown. The consumer owns this call (typically as its own `ctx.effect`
|
||||||
|
* disposer); the facility closes any domain left open when it unmounts.
|
||||||
|
* @returns resolution after the unit is released.
|
||||||
|
*/
|
||||||
|
close(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Internal seam handing table handles their domain-owned write machinery. */
|
/** Internal seam handing table handles their domain-owned write machinery. */
|
||||||
@@ -137,9 +147,9 @@ export class DomainImpl {
|
|||||||
|
|
||||||
/** Tail of the write chain; every link settles (rejections are observed by the caller's slice). */
|
/** Tail of the write chain; every link settles (rejections are observed by the caller's slice). */
|
||||||
private chain: Promise<void> = Promise.resolve()
|
private chain: Promise<void> = Promise.resolve()
|
||||||
/** Set when dispose begins: new writes reject while already-queued writes drain. */
|
/** Set when close begins: new writes reject while already-queued writes drain. */
|
||||||
private disposing = false
|
private disposing = false
|
||||||
/** Set when dispose finishes (chain drained, unit closed): reads reject from here on. */
|
/** Set when close finishes (chain drained, unit closed): reads reject from here on. */
|
||||||
private closed = false
|
private closed = false
|
||||||
private disposal?: Promise<void>
|
private disposal?: Promise<void>
|
||||||
|
|
||||||
@@ -152,6 +162,8 @@ export class DomainImpl {
|
|||||||
* the spec, so the entry set IS the table set.
|
* the spec, so the entry set IS the table set.
|
||||||
* @param globalValue - Validated stored global, or the spec's `initial`
|
* @param globalValue - Validated stored global, or the spec's `initial`
|
||||||
* when the medium held none; `undefined` when the spec declares no global.
|
* when the medium held none; `undefined` when the spec declares no global.
|
||||||
|
* @param onClosed - Facility hook run once after teardown completes; frees
|
||||||
|
* the domain name for a later open.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private readonly ctx: Context,
|
private readonly ctx: Context,
|
||||||
@@ -159,6 +171,7 @@ export class DomainImpl {
|
|||||||
private readonly unit: KvUnit,
|
private readonly unit: KvUnit,
|
||||||
records: Map<string, Map<string, unknown>>,
|
records: Map<string, Map<string, unknown>>,
|
||||||
globalValue: unknown,
|
globalValue: unknown,
|
||||||
|
private readonly onClosed: () => void,
|
||||||
) {
|
) {
|
||||||
this.name = spec.name
|
this.name = spec.name
|
||||||
const host: TableHost = {
|
const host: TableHost = {
|
||||||
@@ -166,7 +179,7 @@ export class DomainImpl {
|
|||||||
unit,
|
unit,
|
||||||
enqueue: job => this.enqueue(job),
|
enqueue: job => this.enqueue(job),
|
||||||
assertReadable: () => { this.assertReadable() },
|
assertReadable: () => { this.assertReadable() },
|
||||||
emitChanged: (change) => { this.ctx.emit('domain/changed', change) },
|
emitChanged: (change) => { this.emitChanged(change) },
|
||||||
}
|
}
|
||||||
for (const [table, tableRecords] of records) {
|
for (const [table, tableRecords] of records) {
|
||||||
this.tables.set(table, new KvTableImpl(host, table, tableRecords))
|
this.tables.set(table, new KvTableImpl(host, table, tableRecords))
|
||||||
@@ -181,7 +194,7 @@ export class DomainImpl {
|
|||||||
set: value => this.enqueue(async () => {
|
set: value => this.enqueue(async () => {
|
||||||
await this.unit.setGlobal(value)
|
await this.unit.setGlobal(value)
|
||||||
this.globalValue = value
|
this.globalValue = value
|
||||||
host.emitChanged({ domain: this.name, table: '', key: '', operation: 'put', value })
|
this.emitChanged({ domain: this.name, table: '', key: '', operation: 'put', value })
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,22 +224,40 @@ export class DomainImpl {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Close this domain: reject new writes immediately, drain already-queued
|
* Close this domain: reject new writes immediately, drain already-queued
|
||||||
* writes (their events still emit), then close the unit. Idempotent —
|
* writes (their events still emit), close the unit, then free the name via
|
||||||
* repeated calls share one teardown.
|
* the facility hook. Idempotent — repeated calls share one teardown.
|
||||||
* @returns resolution after the unit is released.
|
* @returns resolution after the unit is released.
|
||||||
*/
|
*/
|
||||||
dispose(): Promise<void> {
|
close(): Promise<void> {
|
||||||
this.disposal ??= this.runDispose()
|
this.disposal ??= this.runClose()
|
||||||
return this.disposal
|
return this.disposal
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runDispose(): Promise<void> {
|
private async runClose(): Promise<void> {
|
||||||
this.disposing = true
|
this.disposing = true
|
||||||
// Chain links never reject (each is settled via then(noop, noop)), so
|
// Chain links never reject (each is settled via then(noop, noop)), so
|
||||||
// this await is a pure drain barrier.
|
// this await is a pure drain barrier.
|
||||||
await this.chain
|
await this.chain
|
||||||
await this.unit.close()
|
await this.unit.close()
|
||||||
this.closed = true
|
this.closed = true
|
||||||
|
this.onClosed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch one post-durability change notification, containing observer
|
||||||
|
* failures: the write is already committed (medium and memory both hold
|
||||||
|
* the new state), so a throwing listener must not retroactively reject it.
|
||||||
|
*/
|
||||||
|
private emitChanged(change: DomainChanged): void {
|
||||||
|
try {
|
||||||
|
this.ctx.emit('domain/changed', change)
|
||||||
|
} catch (error) {
|
||||||
|
// Swallows synchronous observer exceptions only: emit dispatches
|
||||||
|
// listeners inline and nothing else runs in the try. The event is a
|
||||||
|
// notification, not a transaction participant — the commit point has
|
||||||
|
// passed, so containment (with a log) is the only correct outcome.
|
||||||
|
this.ctx.logger.warn(`domain '${this.name}': domain/changed listener failed: ${String(error)}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enqueue<T>(job: () => Promise<T>): Promise<T> {
|
private enqueue<T>(job: () => Promise<T>): Promise<T> {
|
||||||
|
|||||||
@@ -81,8 +81,12 @@ export class DomainFacility {
|
|||||||
* (`facet-unsupported`); open the unit projected from the spec (backend
|
* (`facet-unsupported`); open the unit projected from the spec (backend
|
||||||
* `version-mismatch`/`malformed-medium` pass through); load and validate
|
* `version-mismatch`/`malformed-medium` pass through); load and validate
|
||||||
* every stored record against the spec's zod schemas (`invalid-record`
|
* every stored record against the spec's zod schemas (`invalid-record`
|
||||||
* with the offending table and key); construct the domain and register its
|
* with the offending table and key); construct the domain.
|
||||||
* disposal effect (drain the write chain, close the unit).
|
*
|
||||||
|
* Lifecycle: the CALLER owns the returned handle and closes it via
|
||||||
|
* `Domain.close()` (typically as its own `ctx.effect` disposer) — the
|
||||||
|
* facility does not tie the domain to any consumer fiber. Domains still
|
||||||
|
* open when the facility unmounts are closed by the plugin disposer.
|
||||||
* @param spec - The domain declaration, typically from `defineDomain`.
|
* @param spec - The domain declaration, typically from `defineDomain`.
|
||||||
* @returns the opened domain handle, typed by the spec.
|
* @returns the opened domain handle, typed by the spec.
|
||||||
*/
|
*/
|
||||||
@@ -119,20 +123,15 @@ export class DomainFacility {
|
|||||||
: snapshot.global === null
|
: snapshot.global === null
|
||||||
? globalSpec.initial
|
? globalSpec.initial
|
||||||
: parseRecord(spec.name, '', '', () => globalSpec.schema.parse(snapshot.global))
|
: parseRecord(spec.name, '', '', () => globalSpec.schema.parse(snapshot.global))
|
||||||
const domain = new DomainImpl(this.ctx, spec, unit, tables, globalValue)
|
// The onClosed hook runs strictly after teardown completes: writes
|
||||||
// The open-domain table entry is itself the effect: registration and
|
// landing during the drain still emit domain/changed, and the domain
|
||||||
// the drain-then-unlist teardown live in one closure.
|
// stays resolvable (the package invariant cross-checks each event)
|
||||||
this.ctx.effect(() => {
|
// until fully closed — only then does the name free up for reopening.
|
||||||
this.domains.set(spec.name, domain)
|
const domain: DomainImpl = new DomainImpl(this.ctx, spec, unit, tables, globalValue, () => {
|
||||||
return async () => {
|
this.domains.delete(spec.name)
|
||||||
// Drain before unlisting: writes landing during the drain still
|
this.reserved.delete(spec.name)
|
||||||
// emit domain/changed, and the domain must stay resolvable (the
|
|
||||||
// package invariant cross-checks each event) until fully closed.
|
|
||||||
await domain.dispose()
|
|
||||||
this.domains.delete(spec.name)
|
|
||||||
this.reserved.delete(spec.name)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
this.domains.set(spec.name, domain)
|
||||||
// The single type-erasure point: DomainImpl is the untyped runtime,
|
// The single type-erasure point: DomainImpl is the untyped runtime,
|
||||||
// Domain<S> the spec-typed view; the unknown hop is required because
|
// Domain<S> the spec-typed view; the unknown hop is required because
|
||||||
// S's conditional global-handle type stays unresolved here.
|
// S's conditional global-handle type stays unresolved here.
|
||||||
@@ -142,7 +141,7 @@ export class DomainFacility {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Any failure means the effect never registered (nothing can throw
|
// Any failure means the domain never registered (nothing can throw
|
||||||
// after it), so releasing the name reservation is unconditional.
|
// after it), so releasing the name reservation is unconditional.
|
||||||
this.reserved.delete(spec.name)
|
this.reserved.delete(spec.name)
|
||||||
throw error
|
throw error
|
||||||
@@ -159,6 +158,16 @@ export class DomainFacility {
|
|||||||
get(name: string): DomainImpl | undefined {
|
get(name: string): DomainImpl | undefined {
|
||||||
return this.domains.get(name)
|
return this.domains.get(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close every domain still open on this facility. The unmount path for
|
||||||
|
* consumers that never called `Domain.close()` themselves; closing is
|
||||||
|
* idempotent, so double-closing an already-closed domain is harmless.
|
||||||
|
* @returns resolution after every unit is released.
|
||||||
|
*/
|
||||||
|
async closeAll(): Promise<void> {
|
||||||
|
await Promise.all([...this.domains.values()].map(domain => domain.close()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run one zod parse, translating failure to `invalid-record` with its location. */
|
/** Run one zod parse, translating failure to `invalid-record` with its location. */
|
||||||
@@ -181,5 +190,14 @@ function parseRecord<T>(domain: string, table: string, key: string, parse: () =>
|
|||||||
* @param config - Validated plugin config.
|
* @param config - Validated plugin config.
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: Context, config: Config) {
|
export function apply(ctx: Context, config: Config) {
|
||||||
ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config)))
|
const facility = new DomainFacility(ctx, config)
|
||||||
|
ctx.effect(() => {
|
||||||
|
const unmount = ctx.storage.mount('domain', facility)
|
||||||
|
return async () => {
|
||||||
|
// Close leftovers before unmounting: draining writes still emit
|
||||||
|
// domain/changed, whose invariant resolves the facility through the hub.
|
||||||
|
await facility.closeAll()
|
||||||
|
unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,10 +65,14 @@ export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identity helper that pins a spec's literal types and validates its names.
|
* Identity helper that pins a spec's literal types and validates its shape.
|
||||||
* Misconfiguration fails loud: a domain or table name outside `UNIT_NAME_RE`
|
* Misconfiguration fails loud at the owning package's module load, before any
|
||||||
* or a version that is not a non-negative integer throws here, at the owning
|
* medium is touched: a domain or table name outside `UNIT_NAME_RE`, a version
|
||||||
* package's module load, before any medium is touched.
|
* that is not a non-negative integer, or a global schema that accepts `null`
|
||||||
|
* all throw. The `null` rejection guards round-tripping: backends store the
|
||||||
|
* global as opaque JSON with `null` as the "never written" sentinel, so a
|
||||||
|
* nullable global would be indistinguishable from an absent one on reopen
|
||||||
|
* (a stored `null` silently reverts to `initial`).
|
||||||
* @param spec - The domain declaration.
|
* @param spec - The domain declaration.
|
||||||
* @returns the same spec, narrowed to its literal type.
|
* @returns the same spec, narrowed to its literal type.
|
||||||
*/
|
*/
|
||||||
@@ -84,6 +88,12 @@ export function defineDomain<S extends DomainSpec>(spec: S): S {
|
|||||||
throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`)
|
throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (spec.global !== undefined && spec.global.schema.safeParse(null).success) {
|
||||||
|
throw new Error(
|
||||||
|
`domain '${spec.name}' global schema must not accept null: `
|
||||||
|
+ 'null is the medium\'s "never written" sentinel, so a stored null could not round-trip',
|
||||||
|
)
|
||||||
|
}
|
||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ describe('defineDomain', () => {
|
|||||||
name: 'ok', version: 1, tables: { 'Bad Table': domainTable<string, Item>(itemSchema) },
|
name: 'ok', version: 1, tables: { 'Bad Table': domainTable<string, Item>(itemSchema) },
|
||||||
})).toThrow(/table name/)
|
})).toThrow(/table name/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects a global schema that accepts null (the never-written sentinel)', () => {
|
||||||
|
expect(() => defineDomain({
|
||||||
|
name: 'ok',
|
||||||
|
version: 1,
|
||||||
|
global: { schema: settingsSchema.nullable(), initial: null },
|
||||||
|
tables: {},
|
||||||
|
})).toThrow(/must not accept null/)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('DomainFacility.open', () => {
|
describe('DomainFacility.open', () => {
|
||||||
@@ -262,21 +271,56 @@ describe('global singleton', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('disposal', () => {
|
describe('close and lifecycle', () => {
|
||||||
it('drains queued writes, closes the unit, then rejects reads and writes', async () => {
|
it('close drains queued writes, then rejects reads and writes, and frees the name', async () => {
|
||||||
const pool = new MemoryMediaPool()
|
const pool = new MemoryMediaPool()
|
||||||
const { ctx, facility } = await harness({ pool })
|
const { facility } = await harness({ pool })
|
||||||
const domain = await facility.open(spec)
|
const domain = await facility.open(spec)
|
||||||
const table = domain.table('items')
|
const table = domain.table('items')
|
||||||
const pending = Promise.all([
|
const pending = Promise.all([
|
||||||
table.put('a', { label: 'x', count: 1 }),
|
table.put('a', { label: 'x', count: 1 }),
|
||||||
table.put('b', { label: 'y', count: 2 }),
|
table.put('b', { label: 'y', count: 2 }),
|
||||||
])
|
])
|
||||||
await ctx.fiber.dispose() // effect disposer: drain chain, close unit
|
await Promise.all([domain.close(), domain.close()]) // idempotent
|
||||||
await pending // queued before dispose → still landed
|
await pending // queued before close → still landed
|
||||||
// Durability is the drain contract: both queued writes reached the medium.
|
// Durability is the drain contract: both queued writes reached the medium.
|
||||||
expect([...pool.media.get('demo')!.tables.get('items')!.keys()].sort()).toEqual(['a', 'b'])
|
expect([...pool.media.get('demo')!.tables.get('items')!.keys()].sort()).toEqual(['a', 'b'])
|
||||||
await expect(table.put('c', { label: 'z', count: 3 })).rejects.toMatchObject({ code: 'closed' })
|
await expect(table.put('c', { label: 'z', count: 3 })).rejects.toMatchObject({ code: 'closed' })
|
||||||
expect(() => table.get('a')).toThrow(/closed/)
|
expect(() => table.get('a')).toThrow(/closed/)
|
||||||
|
// The name is free again: reopening sees the drained state.
|
||||||
|
const reopened = await facility.open(spec)
|
||||||
|
expect([...reopened.table('items').keys()].sort()).toEqual(['a', 'b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('facility unmount closes domains the consumer never closed', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(Storage)
|
||||||
|
ctx.storage.backend.register('memory', new MemoryStorageBackend())
|
||||||
|
const DomainPlugin = await import('../src/index.ts')
|
||||||
|
const fiber = await ctx.plugin(DomainPlugin, { backend: 'memory' })
|
||||||
|
const domain = await ctx.storage.domain.open(bareSpec)
|
||||||
|
const table = domain.table('rows')
|
||||||
|
await table.put('a', { label: 'x', count: 1 })
|
||||||
|
await fiber.dispose()
|
||||||
|
await expect(table.put('b', { label: 'y', count: 2 })).rejects.toMatchObject({ code: 'closed' })
|
||||||
|
expect(() => ctx.storage.form('domain')).toThrow(/not mounted/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('contains a throwing domain/changed listener without rejecting the committed write', async () => {
|
||||||
|
const pool = new MemoryMediaPool()
|
||||||
|
const { ctx, facility, changes } = await harness({ pool })
|
||||||
|
const domain = await facility.open(spec)
|
||||||
|
const table = domain.table('items')
|
||||||
|
ctx.on('domain/changed', () => {
|
||||||
|
throw new Error('hostile observer')
|
||||||
|
})
|
||||||
|
await expect(table.put('a', { label: 'x', count: 1 })).resolves.toBeUndefined()
|
||||||
|
// Commit survived intact on both planes, and well-behaved listeners
|
||||||
|
// (registered before the thrower) still observed the event.
|
||||||
|
expect(table.get('a')).toEqual({ label: 'x', count: 1 })
|
||||||
|
expect(pool.media.get('demo')!.tables.get('items')!.get('a')).toEqual({ label: 'x', count: 1 })
|
||||||
|
expect(changes).toHaveLength(1)
|
||||||
|
// The chain is unpoisoned: subsequent writes proceed normally.
|
||||||
|
await expect(table.delete('a')).resolves.toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ export class WorkspaceRegistry extends Service {
|
|||||||
/** Open the domain and rebuild the entity cache before the service is published as active. */
|
/** Open the domain and rebuild the entity cache before the service is published as active. */
|
||||||
protected async [Service.init](): Promise<void> {
|
protected async [Service.init](): Promise<void> {
|
||||||
const domain = await this.ctx.storage.domain.open(workspaceDomainSpec)
|
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')
|
this.table = domain.table('workspaces')
|
||||||
const persistence = this.ctx.get('sessionPersistence')
|
const persistence = this.ctx.get('sessionPersistence')
|
||||||
if (persistence !== undefined) {
|
if (persistence !== undefined) {
|
||||||
|
|||||||
@@ -185,6 +185,24 @@ describe('WorkspaceRegistry.create', () => {
|
|||||||
const registry = new WorkspaceRegistry(ctx)
|
const registry = new WorkspaceRegistry(ctx)
|
||||||
await expect(registry.create(dir)).rejects.toThrow(/not started/)
|
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', () => {
|
describe('Workspace.attachSession', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user