Bladeren bron

feat(scope): add scoped-layer storage

Tianyi Cui 2 maanden geleden
bovenliggende
commit
8351cdbe65
3 gewijzigde bestanden met toevoegingen van 507 en 0 verwijderingen
  1. 3 0
      packages/core/scope/src/index.ts
  2. 241 0
      packages/core/scope/src/store.ts
  3. 263 0
      packages/core/scope/tests/store.spec.ts

+ 3 - 0
packages/core/scope/src/index.ts

@@ -8,6 +8,9 @@
 import type { Context, Fiber } from 'cordis'
 import { Context as CordisContext } from 'cordis'
 
+export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'
+export type { ScopeLayer } from './store.ts'
+
 /** An opaque, identity-compared scope key. */
 export type ScopeKey = object
 

+ 241 - 0
packages/core/scope/src/store.ts

@@ -0,0 +1,241 @@
+/**
+ * Shared insertion-ordered storage and effect ownership for scope-aware registries.
+ *
+ * @module @deepseek-ai/dsh-scope
+ */
+
+import type { Context } from 'cordis'
+import { scopeOf } from './index.ts'
+import type { ScopeKey } from './index.ts'
+
+/** One scope's aggregate contribution to a registry. */
+export interface ScopeLayer {
+  /** Whether every table in this layer is empty. */
+  isEmpty(): boolean
+}
+
+/** Internal common read contract for the two entry-table implementations. */
+interface EntryValues<V> {
+  values(): IterableIterator<V>
+  isEmpty(): boolean
+}
+
+/**
+ * Insertion-ordered named entries with caller-owned duplicate diagnostics.
+ *
+ * Values are borrowed. Iterators are live native `Map` iterators, and each
+ * successful insertion returns an idempotent undo for that exact entry.
+ */
+export class NamedEntries<V> implements EntryValues<V> {
+  private readonly data = new Map<string, V>()
+
+  constructor(
+    private readonly duplicateError: (name: string) => Error,
+  ) {}
+
+  /**
+   * Insert one unique name.
+   * @param name - name unique within this table.
+   * @param value - borrowed value to retain.
+   * @returns an idempotent undo that removes only this insertion.
+   */
+  insert(name: string, value: V): () => void {
+    if (this.data.has(name)) throw this.duplicateError(name)
+    this.data.set(name, value)
+    let active = true
+    return () => {
+      if (!active) return
+      active = false
+      this.data.delete(name)
+    }
+  }
+
+  /**
+   * Read one named value.
+   * @param name - name to resolve.
+   * @returns the retained value, or `undefined` when absent.
+   */
+  get(name: string): V | undefined {
+    return this.data.get(name)
+  }
+
+  /**
+   * Test one name for membership.
+   * @param name - name to test.
+   * @returns whether the table contains that name.
+   */
+  has(name: string): boolean {
+    return this.data.has(name)
+  }
+
+  /**
+   * Iterate live names in insertion order.
+   * @returns the native live key iterator.
+   */
+  keys(): IterableIterator<string> {
+    return this.data.keys()
+  }
+
+  /**
+   * Iterate live entries in insertion order.
+   * @returns the native live entry iterator.
+   */
+  entries(): IterableIterator<[string, V]> {
+    return this.data.entries()
+  }
+
+  /**
+   * Iterate live values in insertion order.
+   * @returns the native live value iterator.
+   */
+  values(): IterableIterator<V> {
+    return this.data.values()
+  }
+
+  /**
+   * Test whether this table has no entries.
+   * @returns whether the table is empty.
+   */
+  isEmpty(): boolean {
+    return this.data.size === 0
+  }
+}
+
+/**
+ * Insertion-ordered anonymous entries with independent registration identity.
+ *
+ * Equal values remain separate registrations. Values are borrowed and the
+ * returned iterator retains native live `Map` semantics.
+ */
+export class AnonymousEntries<V> implements EntryValues<V> {
+  private readonly data = new Map<symbol, V>()
+
+  /**
+   * Append one independently owned value.
+   * @param value - borrowed value to retain.
+   * @returns an idempotent undo for this exact append.
+   */
+  append(value: V): () => void {
+    const key = Symbol()
+    this.data.set(key, value)
+    let active = true
+    return () => {
+      if (!active) return
+      active = false
+      this.data.delete(key)
+    }
+  }
+
+  /**
+   * Iterate live values in insertion order.
+   * @returns the native live value iterator.
+   */
+  values(): IterableIterator<V> {
+    return this.data.values()
+  }
+
+  /**
+   * Test whether this table has no entries.
+   * @returns whether the table is empty.
+   */
+  isEmpty(): boolean {
+    return this.data.size === 0
+  }
+}
+
+/**
+ * Own the global and exact-scope layers for one registry.
+ *
+ * Reads never create scoped layers. Registrations derive both visibility and
+ * effect ownership from the supplied Cordis context, collect undo before
+ * notification, and reclaim only a completely empty aggregate layer.
+ */
+export class ScopedLayers<L extends ScopeLayer> {
+  /** The eagerly constructed context-global layer. */
+  readonly global: L
+
+  private readonly scoped = new Map<ScopeKey, L>()
+
+  constructor(
+    private readonly createLayer: (scope: ScopeKey | undefined) => L,
+    private readonly onChange: () => void,
+  ) {
+    this.global = createLayer(undefined)
+  }
+
+  /**
+   * Read an existing exact-scope overlay.
+   * @param scope - exact scope key; `undefined` denotes no overlay.
+   * @returns the existing scoped layer, or `undefined` without creating one.
+   */
+  peek(scope: ScopeKey | undefined): L | undefined {
+    if (scope === undefined) return undefined
+    return this.scoped.get(scope)
+  }
+
+  /**
+   * Materialize global named entries followed by exact-scope shadows.
+   * @param scope - exact viewing scope, or `undefined` for the global view.
+   * @param pick - select the named table from a layer.
+   * @returns an insertion-ordered effective map.
+   */
+  merge<V>(
+    scope: ScopeKey | undefined,
+    pick: (layer: L) => NamedEntries<V>,
+  ): Map<string, V> {
+    const merged = new Map(pick(this.global).entries())
+    const layer = this.peek(scope)
+    if (layer === undefined) return merged
+    for (const [name, value] of pick(layer).entries()) merged.set(name, value)
+    return merged
+  }
+
+  /**
+   * Attach one synchronous layer mutation to its registration context.
+   * @param ctx - context that determines both scope visibility and effect ownership.
+   * @param action - atomic mutation returning its synchronous undo.
+   * @param options - Cordis effect label and optional change notification.
+   * @returns the exact disposer returned by `ctx.effect()`.
+   */
+  effect(
+    ctx: Context,
+    action: (layer: L) => () => void,
+    options: { label: string; notify?: boolean },
+  ): () => void {
+    const scope = scopeOf(ctx)
+    const notify = options.notify ?? true
+    const dispose = ctx.effect(function* (this: ScopedLayers<L>) {
+      let layer: L
+      let created = false
+      if (scope === undefined) {
+        layer = this.global
+      } else {
+        const existing = this.scoped.get(scope)
+        if (existing === undefined) {
+          layer = this.createLayer(scope)
+          this.scoped.set(scope, layer)
+          created = true
+        } else {
+          layer = existing
+        }
+      }
+
+      let undo: () => void
+      try {
+        undo = action(layer)
+      } catch (error) {
+        if (scope !== undefined && created && layer.isEmpty()) this.scoped.delete(scope)
+        throw error
+      }
+
+      yield () => {
+        undo()
+        if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope)
+        if (notify) this.onChange()
+      }
+      if (notify) this.onChange()
+    }.bind(this), options.label)
+    // eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
+    return dispose
+  }
+}

+ 263 - 0
packages/core/scope/tests/store.spec.ts

@@ -0,0 +1,263 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import {
+  AnonymousEntries,
+  createScope,
+  NamedEntries,
+  ScopedLayers,
+  type Scope,
+  type ScopeKey,
+  type ScopeLayer,
+} from '@deepseek-ai/dsh-scope'
+
+class TestLayer implements ScopeLayer {
+  readonly named: NamedEntries<number>
+  readonly anonymous = new AnonymousEntries<string>()
+
+  constructor(scope: ScopeKey | undefined) {
+    this.named = new NamedEntries(name =>
+      new Error(`${scope === undefined ? 'global' : 'scoped'} duplicate: ${name}`))
+  }
+
+  isEmpty(): boolean {
+    return this.named.isEmpty() && this.anonymous.isEmpty()
+  }
+}
+
+/** Mint one active scope for lifecycle tests. */
+async function mintScope(ctx: Context, key: ScopeKey): Promise<Scope> {
+  let scope!: Scope
+  await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
+  return scope
+}
+
+describe('NamedEntries', () => {
+  it('owns duplicate diagnostics, lookup, insertion order, live iteration, and exact idempotent undo', () => {
+    const duplicate = new Error('caller duplicate')
+    const duplicateError = vi.fn(() => duplicate)
+    const entries = new NamedEntries<number>(duplicateError)
+    const undoA = entries.insert('a', 1)
+    const values = entries.values()
+    expect(values.next()).toEqual({ value: 1, done: false })
+    const undoB = entries.insert('b', 2)
+
+    expect([...values]).toEqual([2])
+    expect([...entries.keys()]).toEqual(['a', 'b'])
+    expect([...entries.entries()]).toEqual([['a', 1], ['b', 2]])
+    expect(entries.get('a')).toBe(1)
+    expect(entries.get('missing')).toBeUndefined()
+    expect(entries.has('b')).toBe(true)
+    expect(entries.has('missing')).toBe(false)
+    expect(entries.isEmpty()).toBe(false)
+    expect(() => entries.insert('a', 3)).toThrow(duplicate)
+    expect(duplicateError).toHaveBeenCalledWith('a')
+
+    undoA()
+    entries.insert('a', 3)
+    undoA()
+    expect(entries.get('a')).toBe(3)
+    undoB()
+    expect([...entries.entries()]).toEqual([['a', 3]])
+  })
+})
+
+describe('AnonymousEntries', () => {
+  it('owns equal values independently with live insertion-ordered iteration and idempotent undo', () => {
+    const entries = new AnonymousEntries<object>()
+    const value = {}
+    const undoFirst = entries.append(value)
+    const values = entries.values()
+    expect(values.next()).toEqual({ value, done: false })
+    const undoSecond = entries.append(value)
+
+    expect([...values]).toEqual([value])
+    expect([...entries.values()]).toEqual([value, value])
+    undoFirst()
+    undoFirst()
+    expect([...entries.values()]).toEqual([value])
+    undoSecond()
+    expect(entries.isEmpty()).toBe(true)
+  })
+})
+
+describe('ScopedLayers', () => {
+  it('constructs global state eagerly while reads stay non-creating and merge named shadows in order', () => {
+    const created: Array<ScopeKey | undefined> = []
+    const layers = new ScopedLayers(
+      (scope) => {
+        created.push(scope)
+        return new TestLayer(scope)
+      },
+      vi.fn(),
+    )
+    const key = {}
+    layers.global.named.insert('a', 1)
+    layers.global.named.insert('shared', 2)
+
+    expect(created).toEqual([undefined])
+    expect(layers.peek(undefined)).toBeUndefined()
+    expect(layers.peek(key)).toBeUndefined()
+    expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2]])
+    expect(created).toEqual([undefined])
+  })
+
+  it('uses the same scoped context for lazy visibility and ownership, and reclaims only an empty aggregate', async () => {
+    const ctx = new Context()
+    const key = {}
+    const scope = await mintScope(ctx, key)
+    const changed = vi.fn()
+    const created: Array<ScopeKey | undefined> = []
+    const layers = new ScopedLayers(
+      (selected) => {
+        created.push(selected)
+        return new TestLayer(selected)
+      },
+      changed,
+    )
+    layers.global.named.insert('a', 1)
+    layers.global.named.insert('shared', 1)
+    const removeNamed = layers.effect(
+      scope.ctx,
+      layer => layer.named.insert('shared', 2),
+      { label: 'test.named', notify: false },
+    )
+    const removeTail = layers.effect(
+      scope.ctx,
+      layer => layer.named.insert('c', 3),
+      { label: 'test.tail', notify: false },
+    )
+    const removeAnonymous = layers.effect(
+      scope.ctx,
+      layer => layer.anonymous.append('kept'),
+      { label: 'test.anonymous', notify: false },
+    )
+
+    expect(created).toEqual([undefined, key])
+    expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 2], ['c', 3]])
+    expect(changed).not.toHaveBeenCalled()
+    removeNamed()
+    expect(layers.peek(key)).toBeDefined()
+    expect([...layers.merge(key, layer => layer.named)]).toEqual([['a', 1], ['shared', 1], ['c', 3]])
+    removeTail()
+    expect(layers.peek(key)).toBeDefined()
+    removeAnonymous()
+    expect(layers.peek(key)).toBeUndefined()
+    await scope.dispose()
+  })
+
+  it('runs action, notification, undo, and disposal notification in order with Cordis idempotence and labels', async () => {
+    const ctx = new Context()
+    const events: string[] = []
+    const layers = new ScopedLayers(
+      scope => new TestLayer(scope),
+      () => void events.push('notify'),
+    )
+    const dispose = layers.effect(
+      ctx,
+      (layer) => {
+        events.push('action')
+        const undo = layer.named.insert('x', 1)
+        return () => {
+          events.push('undo')
+          undo()
+        }
+      },
+      { label: 'store.order' },
+    )
+
+    expect(events).toEqual(['action', 'notify'])
+    expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain('store.order')
+    dispose()
+    dispose()
+    expect(events).toEqual(['action', 'notify', 'undo', 'notify'])
+    expect(layers.global.isEmpty()).toBe(true)
+  })
+
+  it('returns the exact context effect disposer', () => {
+    const rawDispose = vi.fn()
+    const effect = vi.fn(() => rawDispose)
+    const ctx = { effect } as unknown as Context
+    const action = vi.fn(() => vi.fn())
+    const layers = new ScopedLayers(scope => new TestLayer(scope), vi.fn())
+
+    const returned = layers.effect(ctx, action, { label: 'store.identity', notify: false })
+
+    expect(returned).toBe(rawDispose)
+    expect(effect).toHaveBeenCalledWith(expect.any(Function), 'store.identity')
+    expect(action).not.toHaveBeenCalled()
+  })
+
+  it('cleans up failed factories and empty failed actions without discarding an existing layer', async () => {
+    const ctx = new Context()
+    const key = {}
+    const scope = await mintScope(ctx, key)
+    let failFactory = true
+    const layers = new ScopedLayers(
+      (selected) => {
+        if (selected !== undefined && failFactory) throw new Error('factory failed')
+        return new TestLayer(selected)
+      },
+      vi.fn(),
+    )
+
+    expect(() => layers.effect(
+      scope.ctx,
+      layer => layer.named.insert('never', 1),
+      { label: 'store.factory', notify: false },
+    )).toThrow('factory failed')
+    expect(layers.peek(key)).toBeUndefined()
+
+    failFactory = false
+    expect(() => layers.effect(
+      scope.ctx,
+      () => { throw new Error('action failed') },
+      { label: 'store.action', notify: false },
+    )).toThrow('action failed')
+    expect(layers.peek(key)).toBeUndefined()
+
+    const dispose = layers.effect(
+      scope.ctx,
+      layer => layer.named.insert('kept', 1),
+      { label: 'store.kept', notify: false },
+    )
+    expect(() => layers.effect(
+      scope.ctx,
+      () => { throw new Error('second action failed') },
+      { label: 'store.existing-action', notify: false },
+    )).toThrow('second action failed')
+    expect(layers.peek(key)?.named.get('kept')).toBe(1)
+    dispose()
+    await scope.dispose()
+  })
+
+  it('rolls back a scoped insertion when notification throws', async () => {
+    const ctx = new Context()
+    const key = {}
+    const scope = await mintScope(ctx, key)
+    const events: string[] = []
+    let notifications = 0
+    const layers = new ScopedLayers(
+      selected => new TestLayer(selected),
+      () => {
+        events.push('notify')
+        if (++notifications === 1) throw new Error('change failed')
+      },
+    )
+
+    expect(() => layers.effect(
+      scope.ctx,
+      (layer) => {
+        const undo = layer.named.insert('rollback', 1)
+        return () => {
+          events.push('undo')
+          undo()
+        }
+      },
+      { label: 'store.rollback' },
+    )).toThrow('change failed')
+
+    expect(events).toEqual(['notify', 'undo', 'notify'])
+    expect(layers.peek(key)).toBeUndefined()
+    await scope.dispose()
+  })
+})