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:
@@ -1,9 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { apply as applyStorage } from '@deepseek-ai/dsh-storage'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility } from '@deepseek-ai/dsh-domain'
|
||||
import type { DomainChanged } from '@deepseek-ai/dsh-domain'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -26,7 +26,7 @@ async function harness(options?: {
|
||||
sessions?: SessionHeader[] | 'absent'
|
||||
}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin({ apply: applyStorage })
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(options?.pool))
|
||||
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
|
||||
let listed = options?.sessions === 'absent' ? undefined : options?.sessions ?? []
|
||||
@@ -106,6 +106,15 @@ describe('WorkspaceRegistry.create', () => {
|
||||
expect(registry.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a path resolving to a plain file', async () => {
|
||||
const dir = await makeDir('has-file')
|
||||
const file = join(dir, 'plain.txt')
|
||||
await writeFile(file, 'not a directory')
|
||||
const { registry } = await harness()
|
||||
await expect(registry.create(file)).rejects.toThrow(/not a directory/)
|
||||
expect(registry.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a duplicate path, including a symlink resolving to an existing workspace', async () => {
|
||||
const dir = await makeDir('real')
|
||||
const link = join(base, 'link')
|
||||
@@ -187,6 +196,22 @@ describe('Workspace.attachSession', () => {
|
||||
await workspace.detachSession(SessionId('absent'))
|
||||
expect(changes.length).toBe(written)
|
||||
})
|
||||
|
||||
it('decides membership at the write-chain slot: unawaited detach then attach re-attaches', async () => {
|
||||
const dir = await makeDir('race')
|
||||
const { registry } = await harness({ sessions: [header('s1', dir)] })
|
||||
const workspace = await registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
// Both fire before either lands. Snapshot-based idempotence would see
|
||||
// 's1' still on the account and turn the attach into a no-op, losing it;
|
||||
// chain-slot decisions replay detach → attach in order. (The attach skips
|
||||
// re-validation off the same stale snapshot — the cwd fact is immutable —
|
||||
// and enqueues immediately, keeping the chain order deterministic here.)
|
||||
const detached = workspace.detachSession(SessionId('s1'))
|
||||
const attached = workspace.attachSession(SessionId('s1'))
|
||||
await Promise.all([detached, attached])
|
||||
expect(workspace.sessionIds).toEqual(['s1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('consistency projections', () => {
|
||||
@@ -214,16 +239,29 @@ describe('consistency projections', () => {
|
||||
})
|
||||
|
||||
it('rejects startup over a medium accounting one session twice', async () => {
|
||||
const dir = await makeDir('double')
|
||||
const pool = pooledRecord('00000000-0000-4000-8000-000000000003', record(dir, ['dup']))
|
||||
const dirA = await makeDir('double-a')
|
||||
const dirB = await makeDir('double-b')
|
||||
const pool = pooledRecord('00000000-0000-4000-8000-000000000003', record(dirA, ['dup']))
|
||||
pool.media.get('workspace')!.tables.get('workspaces')!
|
||||
.set('00000000-0000-4000-8000-000000000004', record(dir, ['dup']))
|
||||
.set('00000000-0000-4000-8000-000000000004', record(dirB, ['dup']))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin({ apply: applyStorage })
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
|
||||
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
|
||||
await expect(Promise.resolve(ctx.plugin(WorkspaceRegistry))).rejects.toThrow(/accounted/)
|
||||
})
|
||||
|
||||
it('rejects startup over a medium where two records claim one path', async () => {
|
||||
const dirA = await makeDir('claimed')
|
||||
const pool = pooledRecord('00000000-0000-4000-8000-000000000005', record(dirA, []))
|
||||
pool.media.get('workspace')!.tables.get('workspaces')!
|
||||
.set('00000000-0000-4000-8000-000000000006', record(dirA, []))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
|
||||
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
|
||||
await expect(Promise.resolve(ctx.plugin(WorkspaceRegistry))).rejects.toThrow(/claimed/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace.status', () => {
|
||||
|
||||
Reference in New Issue
Block a user