From 8351cdbe65d51677d96360d415dcae322c72e755 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:09:19 +0800 Subject: [PATCH] feat(scope): add scoped-layer storage --- packages/core/scope/src/index.ts | 3 + packages/core/scope/src/store.ts | 241 ++++++++++++++++++++++ packages/core/scope/tests/store.spec.ts | 263 ++++++++++++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 packages/core/scope/src/store.ts create mode 100644 packages/core/scope/tests/store.spec.ts diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index f09844e9ab..fc5b1fa5fa 100644 --- a/packages/core/scope/src/index.ts +++ b/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 diff --git a/packages/core/scope/src/store.ts b/packages/core/scope/src/store.ts new file mode 100644 index 0000000000..5f016df929 --- /dev/null +++ b/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 { + values(): IterableIterator + 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 implements EntryValues { + private readonly data = new Map() + + 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 { + 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 { + 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 implements EntryValues { + private readonly data = new Map() + + /** + * 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 { + 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 { + /** The eagerly constructed context-global layer. */ + readonly global: L + + private readonly scoped = new Map() + + 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( + scope: ScopeKey | undefined, + pick: (layer: L) => NamedEntries, + ): Map { + 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) { + 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 + } +} diff --git a/packages/core/scope/tests/store.spec.ts b/packages/core/scope/tests/store.spec.ts new file mode 100644 index 0000000000..9f70d2b527 --- /dev/null +++ b/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 + readonly anonymous = new AnonymousEntries() + + 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 { + 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(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() + 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 = [] + 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 = [] + 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() + }) +})