| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267 |
- /**
- * Shared insertion-ordered storage and effect ownership for scope-aware registries.
- *
- * @module @deepseek-ai/dsh-scope
- */
- import type { Context } from '@deepseek-ai/cordis'
- import { scopeChainOf, 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. Deliberately chain-blind: callers
- * addressing one scope's OWN contributions (its restrictions, its guards)
- * must not silently pick up an ancestor's — use {@link chainLayers} where
- * inheritance is the point.
- * @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)
- }
- /**
- * Existing overlays along the scope's parent chain ({@link scopeChainOf}),
- * farthest ancestor first and the exact scope last, so a caller layering
- * them in order gives the nearest scope the final word.
- * @param scope - viewing scope, or `undefined` for no overlays.
- * @returns the existing layers, nearest last; absent overlays are skipped.
- */
- chainLayers(scope: ScopeKey | undefined): L[] {
- const layers: L[] = []
- for (const key of scopeChainOf(scope).reverse()) {
- const layer = this.scoped.get(key)
- if (layer !== undefined) layers.push(layer)
- }
- return layers
- }
- /**
- * Materialize global named entries followed by scope-chain shadows,
- * farthest ancestor first, so the nearest scope's entry wins a name.
- * @param scope - 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())
- for (const layer of this.chainLayers(scope)) {
- 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
- }
- }
|