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

@@ -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', () => {