248 lines
6.9 KiB
TypeScript
248 lines
6.9 KiB
TypeScript
/**
|
|
* 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 within one nonempty table
|
|
* generation; draining the table detaches them from later insertions. Each
|
|
* successful insertion returns an idempotent undo for that exact entry.
|
|
*/
|
|
export class NamedEntries<V> implements EntryValues<V> {
|
|
private 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 {
|
|
const data = this.data
|
|
if (data.has(name)) throw this.duplicateError(name)
|
|
data.set(name, value)
|
|
let active = true
|
|
return () => {
|
|
if (!active) return
|
|
active = false
|
|
data.delete(name)
|
|
if (data.size === 0 && this.data === data) this.data = new Map()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* iterators are live within one nonempty table generation; draining the table
|
|
* detaches them from later appends.
|
|
*/
|
|
export class AnonymousEntries<V> implements EntryValues<V> {
|
|
private 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 data = this.data
|
|
const key = Symbol()
|
|
data.set(key, value)
|
|
let active = true
|
|
return () => {
|
|
if (!active) return
|
|
active = false
|
|
data.delete(key)
|
|
if (data.size === 0 && this.data === data) this.data = new Map()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
// oxlint-disable-next-line typescript/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
|
|
return dispose
|
|
}
|
|
}
|