feat(scope): add scoped-layer storage
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user