/** * Scoped-context primitive: mint a Cordis context that tags registrations with * an opaque identity and build routing-only event carriers for that identity. * * @module @deepseek-ai/dsh-scope */ import type { Context, Fiber } from '@deepseek-ai/cordis' import { Context as CordisContext } from '@deepseek-ai/cordis' export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts' export type { ScopeLayer } from './store.ts' /** An opaque, identity-compared scope key. */ export type ScopeKey = object /** Context tag written by {@link createScope}. */ const kScope = Symbol('dsh.scope') declare const ScopedBrand: unique symbol /** * A routing-only event receiver built by {@link scopeTarget}. The type * parameter records the subject type for dispatch checking; the carrier does * not expose the subject's properties. Event payloads carry the real subject. */ export type Scoped = object & { readonly [ScopedBrand]: T } /** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */ const carrierKeys = new WeakMap() /** * The enclosing scope of each key. One relation powers both directions of * scope nesting: registration views inherit DOWN the chain (a child scope * sees its ancestors' layers — {@link ScopedLayers}), and event admission * extends UP it (a listener tagged with an ancestor receives events dispatched * to a descendant key — {@link scopeTarget}). */ const scopeParents = new WeakMap() /** The privileged handle to move one scope key's parent link. */ export interface ScopeParentBinding { /** * Re-link the bound key to a different parent, with the same cycle check as * the bind. Valid only while nothing produced under the old parent is * retained — the blank-session recompose contract, which the holder upholds * because this relation cannot see what a session logged. * @param parent - the new enclosing scope key. */ rebind(parent: ScopeKey): void } /** Cycle-checked write shared by the bind and every rebind. */ function linkScopeParent(key: ScopeKey, parent: ScopeKey): void { for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) { if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle') } scopeParents.set(key, parent) } /** * Bind `parent` as `key`'s enclosing scope, once. * * A key that already has a parent throws: there is no open re-link path, so a * scope's ancestry cannot be moved by anyone but the original binder, who * alone receives the {@link ScopeParentBinding}. A link that would close a * cycle is rejected, because every chain consumer walks parents to the root. * @param key - the child scope key. * @param parent - its enclosing scope key. * @returns the binding that alone may re-link this key. */ export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding { if (scopeParents.has(key)) { throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind') } linkScopeParent(key, parent) return { rebind(next: ScopeKey): void { linkScopeParent(key, next) }, } } /** * Read one key's enclosing scope. * @param key - the scope key to inspect. * @returns its parent key, or `undefined` for a root scope. */ export function scopeParentOf(key: ScopeKey): ScopeKey | undefined { return scopeParents.get(key) } /** * The chain from a key to its root ancestor. * @param key - the starting key, or `undefined` for the empty chain. * @returns keys nearest-first: `[key, parent, grandparent, …]`. */ export function scopeChainOf(key: ScopeKey | undefined): ScopeKey[] { const chain: ScopeKey[] = [] for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) chain.push(cursor) return chain } /** A minted registration scope and its quiescent disposal boundaries. */ export interface Scope { /** Context through which scope-owned registrations are made. */ ctx: Context /** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */ rawDispose: () => Promise | void /** Dispose every scope-owned registration; racing calls await the same completion. */ dispose(): Promise } /** Follow a Cordis fiber through asynchronous teardown even if its raw disposer was already claimed. */ async function quiesceFiber(fiber: Fiber): Promise { await Promise.resolve(fiber.dispose()) while (fiber.inertia !== undefined) await fiber.inertia } /** Shared no-op plugin used as the backing scope fiber. */ function scope(): void {} /** Options accepted by {@link createScope}. */ export interface CreateScopeOptions { /** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */ parent?: ScopeKey } /** * Mint a scope under `ctx`. The scoped context inherits the minting plugin's * dependency API and owns every registration made through it. * @param ctx - active context whose dependency API the scope inherits. * @param key - opaque identity used for listener routing. * @param options - optional scope-chain placement. * @returns the scoped context and exact/shared disposal boundaries. */ export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope { if (options?.parent !== undefined) bindScopeParent(key, options.parent) const fiber = ctx.plugin(scope) const scoped: Context = fiber.ctx.extend({ [kScope]: key }) let disposing: Promise | undefined return { ctx: scoped, rawDispose: fiber.dispose, dispose: () => (disposing ??= quiesceFiber(fiber)), } } /** * Read the nearest scope tag inherited by a context. * @param ctx - context to inspect. * @returns its scope key, or `undefined` for an unscoped context. */ export function scopeOf(ctx: Context): ScopeKey | undefined { return (ctx as Context & { [kScope]?: ScopeKey })[kScope] } /** * Build an opaque receiver that preserves the base filter, admits untagged * listeners globally, and admits tagged listeners for a matching key or any * of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing * scope receives every descendant scope's events, which is what lets one * standing composition observe each of the agents composed under it. A tag * BELOW the dispatch key stays excluded — events flow up the chain, never * down. * @param base - subject or service whose existing Cordis filter is preserved. * @param key - routed scope identity, or `undefined` for an unscoped subject. * @returns a carrier whose subject remains available only through event arguments. */ export function scopeTarget(base: T, key: ScopeKey | undefined): Scoped { const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter] const carrier = { [CordisContext.filter](ctx: Context): boolean { if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false const tag = scopeOf(ctx) if (tag === undefined) return true for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) { if (cursor === tag) return true } return false }, } carrierKeys.set(carrier, key) return carrier as unknown as Scoped } /** * Test whether a value is a scope carrier. * @param value - dispatch receiver to inspect. * @returns whether {@link scopeTarget} created it. */ export function isScopeCarrier(value: unknown): value is Scoped { return typeof value === 'object' && value !== null && carrierKeys.has(value) } /** * Read a carrier's routing key. * @param value - dispatch receiver to inspect. * @returns the carrier key, or `undefined` for an unkeyed/non-carrier value. */ export function carrierKeyOf(value: unknown): ScopeKey | undefined { if (!isScopeCarrier(value)) return undefined return carrierKeys.get(value) }