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
+9 -7
View File
@@ -1,9 +1,11 @@
/**
* Runtime of one open domain: authoritative in-memory state, the single
* per-domain write chain, and change-event emission. Reads are synchronous
* from memory; every write queues on the chain, mutates memory, awaits
* backend durability, then emits `domain/changed` — so events carry values
* that equal the in-memory state at emission and arrive in write order.
* from memory; every write queues on the chain, awaits backend durability
* FIRST, then mutates memory, then emits `domain/changed` — a rejected
* backend write leaves memory untouched (no divergence between reads and the
* medium), and events carry values that equal the in-memory state at
* emission, in write order.
* @module @deepseek-ai/dsh-domain/src/domain
*/
@@ -175,8 +177,8 @@ export class DomainImpl {
return this.globalValue
},
set: (value) => this.enqueue(async () => {
this.globalValue = value
await this.unit.setGlobal(value)
this.globalValue = value
host.emitChanged({ domain: this.name, table: '', key: '', operation: 'put', value })
}),
}
@@ -271,8 +273,8 @@ class KvTableImpl<K extends string, V> implements KvTable<K, V> {
put(key: K, value: V): Promise<void> {
return this.host.enqueue(async () => {
this.records.set(key, value)
await this.host.unit.putRecord(this.tableName, key, value)
this.records.set(key, value)
this.emitPut(key, value)
})
}
@@ -282,8 +284,8 @@ class KvTableImpl<K extends string, V> implements KvTable<K, V> {
// Existence is decided at this job's chain slot, not at call time: an
// earlier queued put of the same key makes this delete observe it.
if (!this.records.has(key)) return false
this.records.delete(key)
await this.host.unit.deleteRecord(this.tableName, key)
this.records.delete(key)
this.host.emitChanged({
domain: this.host.domainName,
table: this.tableName,
@@ -303,8 +305,8 @@ class KvTableImpl<K extends string, V> implements KvTable<K, V> {
)
}
const next = fn(this.records.get(key) as V)
this.records.set(key, next)
await this.host.unit.putRecord(this.tableName, key, next)
this.records.set(key, next)
this.emitPut(key, next)
return next
})
+19 -7
View File
@@ -7,20 +7,32 @@
* @module @deepseek-ai/dsh-domain/src/events
*/
/** One durable domain change: a record upsert/delete or a global write. */
export interface DomainChanged {
/** Shared location fields of one durable domain change. */
export interface DomainChangedBase {
/** Owning domain name. */
readonly domain: string
/** Table name; `''` for a global-singleton write. */
readonly table: string
/** Record key; `''` for a global-singleton write. */
readonly key: string
/** What happened: `put` covers insert and overwrite; `deleted` is a tombstone. */
readonly operation: 'put' | 'deleted'
/** The new snapshot; absent for `deleted`. */
readonly value?: unknown
}
/** A record (or the global singleton) was inserted or overwritten. */
export interface DomainChangedPut extends DomainChangedBase {
readonly operation: 'put'
/** The new snapshot. */
readonly value: unknown
}
/** A record was deleted; tombstones carry no value. */
export interface DomainChangedDeleted extends DomainChangedBase {
readonly operation: 'deleted'
readonly value?: never
}
/** One durable domain change; a closed union — switch on `operation`. */
export type DomainChanged = DomainChangedPut | DomainChangedDeleted
declare module 'cordis' {
interface Events {
/**
@@ -28,7 +40,7 @@ declare module 'cordis' {
* strictly after the backend acknowledged durability. Events of one
* domain arrive in its write-chain order.
* @param change - domain, table (`''` for global), key (`''` for global),
* operation discriminant, and the new snapshot (absent for deletions).
* operation discriminant, and on `put` the new snapshot.
* @mode emit
*/
'domain/changed'(change: DomainChanged): void
+19 -14
View File
@@ -35,20 +35,25 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
return
}
const current = domain.table(change.table).get(change.key)
if (change.operation === 'deleted') {
if (current !== undefined) {
return fail(
`domain/changed deletion of '${change.domain}'.'${change.table}'['${change.key}'] `
+ 'emitted while the record is still in memory',
)
}
return
}
if (current !== change.value) {
return fail(
`domain/changed value for '${change.domain}'.'${change.table}'['${change.key}'] `
+ 'differs from the in-memory record',
)
switch (change.operation) {
case 'deleted':
if (current !== undefined) {
return fail(
`domain/changed deletion of '${change.domain}'.'${change.table}'['${change.key}'] `
+ 'emitted while the record is still in memory',
)
}
return
case 'put':
if (current !== change.value) {
return fail(
`domain/changed value for '${change.domain}'.'${change.table}'['${change.key}'] `
+ 'differs from the in-memory record',
)
}
return
default:
change satisfies never
}
}, { global: true })
}, { inject: ['storage'] })
+37 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { z } from 'zod'
import { apply as applyStorage } from '@deepseek-ai/dsh-storage'
import Storage from '@deepseek-ai/dsh-storage'
import { DomainFacility, defineDomain, domainTable } from '../src/index.ts'
import type { Config } from '../src/index.ts'
import type { DomainChanged } from '../src/events.ts'
@@ -28,7 +28,7 @@ const bareSpec = defineDomain({
/** Boot a context with the storage hub, one memory backend, and a facility over it. */
async function harness(options?: { pool?: MemoryMediaPool; config?: Partial<Config> }) {
const ctx = new Context()
await ctx.plugin({ apply: applyStorage })
await ctx.plugin(Storage)
const backend = new MemoryStorageBackend(options?.pool)
ctx.storage.backend.register('memory', backend)
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {}, ...options?.config })
@@ -149,6 +149,41 @@ describe('KvTable writes', () => {
})
})
describe('durability failure', () => {
it('leaves memory untouched and emits nothing when the backend rejects a write', async () => {
const pool = new MemoryMediaPool()
const { facility, changes } = await harness({ pool })
const domain = await facility.open(spec)
const table = domain.table('items')
await table.put('a', { label: 'x', count: 1 })
const seen = changes.length
pool.failNextWrites = 3
await expect(table.put('a', { label: 'x', count: 99 })).rejects.toThrow(/injected/)
await expect(table.update('a', (c) => ({ ...c, count: c.count + 1 }))).rejects.toThrow(/injected/)
await expect(table.delete('a')).rejects.toThrow(/injected/)
// Reads still serve the pre-failure record; no events leaked.
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(seen)
// The chain survives rejections: the next write lands cleanly with no residue.
await table.update('a', (c) => ({ ...c, count: c.count + 1 }))
expect(table.get('a')).toEqual({ label: 'x', count: 2 })
})
it('keeps serving initial when the first global set fails durability', async () => {
const pool = new MemoryMediaPool()
const { facility } = await harness({ pool })
const domain = await facility.open(spec)
pool.failNextWrites = 1
await expect(domain.global.set({ theme: 'dark' })).rejects.toThrow(/injected/)
expect(domain.global.get()).toEqual({ theme: 'plain' })
expect(pool.media.get('demo')!.global).toBeNull()
})
})
describe('global singleton', () => {
it('serves initial before first set without materializing, then persists the first set', async () => {
const pool = new MemoryMediaPool()
@@ -28,13 +28,29 @@ export interface MemoryMedium {
* Shared media pool. Construct one and hand it to several
* {@link MemoryStorageBackend} instances to simulate reopening the same
* medium after a restart; `versions` holds the stamped unit versions and is
* writable by tests to inject a mismatching on-medium version.
* writable by tests to inject a mismatching on-medium version, and
* `failNextWrites` injects write-primitive failures.
*/
export class MemoryMediaPool {
/** Unit name → its records; a missing entry is a never-materialized unit. */
readonly media = new Map<string, MemoryMedium>()
/** Unit name → stamped version; tests may pre-stamp to force `version-mismatch`. */
readonly versions = new Map<string, number>()
/**
* When positive, that many subsequent write primitives (putRecord /
* deleteRecord / setGlobal) reject without touching the medium, decrementing
* per rejection. Negative-path seam: callers assert their state is
* untouched after a durability failure.
*/
failNextWrites = 0
/** Consume one injected failure, throwing in a rejected write's place. */
consumeInjectedFailure(): void {
if (this.failNextWrites > 0) {
this.failNextWrites -= 1
throw new Error('injected write failure')
}
}
}
/** In-memory KV unit over one pooled medium. */
@@ -42,6 +58,7 @@ class MemoryKvUnit implements KvUnit {
private closed = false
constructor(
private readonly pool: MemoryMediaPool,
private readonly medium: MemoryMedium,
private readonly descriptor: KvUnitDescriptor,
private readonly onClose: () => void,
@@ -64,6 +81,7 @@ class MemoryKvUnit implements KvUnit {
async putRecord(table: string, key: string, value: unknown): Promise<void> {
this.assertOpen()
this.pool.consumeInjectedFailure()
let records = this.medium.tables.get(table)
if (records === undefined) {
records = new Map()
@@ -74,11 +92,13 @@ class MemoryKvUnit implements KvUnit {
async deleteRecord(table: string, key: string): Promise<void> {
this.assertOpen()
this.pool.consumeInjectedFailure()
this.medium.tables.get(table)?.delete(key)
}
async setGlobal(value: unknown): Promise<void> {
this.assertOpen()
this.pool.consumeInjectedFailure()
this.medium.global = value
}
@@ -128,7 +148,7 @@ export class MemoryStorageBackend implements StorageBackend {
this.pool.media.set(descriptor.name, medium)
}
this.openUnits.add(descriptor.name)
return new MemoryKvUnit(medium, descriptor, () => this.openUnits.delete(descriptor.name))
return new MemoryKvUnit(this.pool, medium, descriptor, () => this.openUnits.delete(descriptor.name))
},
}
}
+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
}
}
@@ -70,11 +70,49 @@ describe('json backend specifics', () => {
await backend.close()
})
it('rejects double-open of one unit', async () => {
it('rejects double-open of one unit as a plain caller error', async () => {
const root = await freshRoot()
const backend = new JsonStorageBackend(root)
await backend.kv.open(descriptor)
await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
await expect(backend.kv.open(descriptor)).rejects.toThrowError(/already open/)
await backend.close()
})
it('rolls back memory when a publish fails', async () => {
const root = await freshRoot()
const backend = new JsonStorageBackend(root)
const unit = await backend.kv.open(descriptor)
await unit.putRecord('t', 'k', { v: 'committed' })
// Make the next publish fail: replace the unit file's parent with an
// unwritable directory path via chmod.
const { chmod } = await import('node:fs/promises')
await chmod(root, 0o500)
await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow()
await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow()
await chmod(root, 0o700)
const snapshot = await unit.loadAll()
expect(snapshot.tables['t']).toEqual({ k: { v: 'committed' } })
// The next successful publish must not carry rejected writes to disk.
await unit.putRecord('t', 'k3', { v: 'later' })
const text = await readFile(join(root, 'shape.json'), 'utf8')
expect(text).not.toContain('rejected')
await backend.close()
})
it('close drains in-flight writes and blocks in-flight opens', async () => {
const root = await freshRoot()
const backend = new JsonStorageBackend(root)
const unit = await backend.kv.open(descriptor)
const bigWrite = unit.putRecord('t', 'big', { blob: 'x'.repeat(4 * 1024 * 1024) })
await unit.close()
await expect(bigWrite).resolves.toBeUndefined()
const onDisk = JSON.parse(await readFile(join(root, 'shape.json'), 'utf8'))
expect(onDisk.tables.t.big).toBeDefined()
const backend2 = new JsonStorageBackend(root)
const opening = backend2.kv.open(descriptor)
const closing = backend2.close()
await expect(opening.then((u) => u.putRecord('t', 'x', {}))).rejects.toMatchObject({ code: 'closed' })
await closing
})
})
@@ -81,10 +81,6 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
`storage database at "${path}" has schema version ${onDisk}, incompatible with this build (${STORAGE_SQLITE_SCHEMA_VERSION})`,
)
}
if (onDisk === 0) {
// Stamp fresh databases.
db.exec(`PRAGMA user_version = ${STORAGE_SQLITE_SCHEMA_VERSION}`)
}
db.exec(`
CREATE TABLE IF NOT EXISTS units (
name TEXT PRIMARY KEY,
@@ -97,6 +93,12 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
value TEXT NOT NULL
) STRICT
`)
if (onDisk === 0) {
// Stamp fresh databases LAST: the stamp asserts the layout is complete,
// so a failure above must leave the medium unstamped (a re-open after
// the obstruction is cleared retries materialization from scratch).
db.exec(`PRAGMA user_version = ${STORAGE_SQLITE_SCHEMA_VERSION}`)
}
}
/**
+18 -3
View File
@@ -66,20 +66,35 @@ export class SqliteKvUnit implements KvUnit {
this.ensureOpen()
const tables: Record<string, Record<string, unknown>> = {}
for (const [name, statements] of this.tables) {
const records: Record<string, unknown> = {}
// Null prototype: record keys are arbitrary strings, so '__proto__'
// must land as an own property instead of mutating the prototype.
const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>
for (const row of statements.selectAll.all() as unknown as Array<{ key: string; value: string }>) {
records[row.key] = JSON.parse(row.value)
records[row.key] = this.parseValue(row.value, `table '${name}' key '${row.key}'`)
}
tables[name] = records
}
let global: unknown = null
if (this.globalSelect !== undefined) {
const row = this.globalSelect.get(this.descriptor.name) as { value: string } | undefined
if (row !== undefined) global = JSON.parse(row.value)
if (row !== undefined) global = this.parseValue(row.value, 'global slot')
}
return { tables, global }
}
/** Parse one stored value column, mapping bad JSON to `malformed-medium`. */
private parseValue(text: string, slot: string): unknown {
try {
return JSON.parse(text)
} catch (error) {
throw new StorageError(
'malformed-medium',
`kv unit '${this.descriptor.name}' holds unparsable JSON at ${slot}`,
{ cause: error },
)
}
}
async putRecord(table: string, key: string, value: unknown): Promise<void> {
this.ensureOpen()
this.statementsFor(table).upsert.run(key, JSON.stringify(value))
@@ -106,4 +106,85 @@ describe('sqlite backend specifics', () => {
await backend.close()
await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'closed' })
})
it('round-trips prototype-polluting keys as own properties', async () => {
const backend = backendAt(':memory:')
const unit = await backend.kv.open(DESCRIPTOR)
await unit.putRecord('records', '__proto__', { evil: true })
await unit.putRecord('records', 'constructor', { n: 1 })
const { tables } = await unit.loadAll()
const records = tables['records']!
expect(Object.hasOwn(records, '__proto__')).toBe(true)
expect(records['__proto__']).toEqual({ evil: true })
expect(records['constructor']).toEqual({ n: 1 })
expect(Object.getPrototypeOf({})).not.toHaveProperty('evil')
await backend.close()
})
it('leaves a failed materialization unstamped so a repaired medium reopens', async () => {
const path = await freshDbPath()
// Obstruct table creation: an index squatting on the unit_globals name
// makes CREATE TABLE IF NOT EXISTS throw AFTER the units table exists.
const setup = new DatabaseSync(path)
setup.exec('CREATE TABLE squatter (x TEXT)')
setup.exec('CREATE INDEX unit_globals ON squatter(x)')
setup.close()
const broken = backendAt(path)
await expect(broken.kv.open(DESCRIPTOR)).rejects.toThrow(/already an index/)
await broken.close()
// Clear the obstruction; the medium must still be version 0, not a
// half-materialized database stamped as current.
const repair = new DatabaseSync(path)
expect((repair.prepare('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(0)
repair.exec('DROP INDEX unit_globals')
repair.close()
const backend = backendAt(path)
const unit = await backend.kv.open(DESCRIPTOR)
await unit.putRecord('records', 'k', { n: 1 })
await backend.close()
})
it('rejects unparsable stored JSON with malformed-medium', async () => {
const path = await freshDbPath()
const backend = backendAt(path)
const unit = await backend.kv.open(DESCRIPTOR)
await unit.putRecord('records', 'good', { n: 1 })
await unit.setGlobal({ g: 1 })
await backend.close()
const db = new DatabaseSync(path)
db.prepare('UPDATE u_specimen_records SET value = ? WHERE key = ?').run('{not json', 'good')
db.close()
const reopened = backendAt(path)
const damaged = await reopened.kv.open(DESCRIPTOR)
await expect(damaged.loadAll()).rejects.toMatchObject({
name: 'StorageError',
code: 'malformed-medium',
})
await reopened.close()
})
it('rejects an unparsable global slot with malformed-medium', async () => {
const path = await freshDbPath()
const backend = backendAt(path)
const unit = await backend.kv.open(DESCRIPTOR)
await unit.setGlobal({ g: 1 })
await backend.close()
const db = new DatabaseSync(path)
db.prepare('UPDATE unit_globals SET value = ? WHERE unit = ?').run('][', 'specimen')
db.close()
const reopened = backendAt(path)
const damaged = await reopened.kv.open(DESCRIPTOR)
await expect(damaged.loadAll()).rejects.toMatchObject({
name: 'StorageError',
code: 'malformed-medium',
})
await reopened.close()
})
})
+8 -8
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
+5 -1
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)
}
}
}
@@ -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 }