1
/**2
* Scoped-context primitive: mint a Cordis context that tags registrations with3
* an opaque identity and build routing-only event carriers for that identity.4
*5
* @module @deepseek-ai/dsh-scope6
*/8
import type { Context, Fiber } from '@deepseek-ai/cordis'9
import { Context as CordisContext } from '@deepseek-ai/cordis'11
export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'12
export type { ScopeLayer } from './store.ts'14
/** An opaque, identity-compared scope key. */15
export type ScopeKey = object17
/** Context tag written by {@link createScope}. */18
const kScope = Symbol('dsh.scope')20
declare const ScopedBrand: unique symbol22
/**23
* A routing-only event receiver built by {@link scopeTarget}. The type24
* parameter records the subject type for dispatch checking; the carrier does25
* not expose the subject's properties. Event payloads carry the real subject.26
*/27
export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }29
/** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */30
const carrierKeys = new WeakMap<object, ScopeKey | undefined>()32
/**33
* The enclosing scope of each key. One relation powers both directions of34
* scope nesting: registration views inherit DOWN the chain (a child scope35
* sees its ancestors' layers — {@link ScopedLayers}), and event admission36
* extends UP it (a listener tagged with an ancestor receives events dispatched37
* to a descendant key — {@link scopeTarget}).38
*/39
const scopeParents = new WeakMap<ScopeKey, ScopeKey>()41
/** The privileged handle to move one scope key's parent link. */42
export interface ScopeParentBinding {43
/**44
* Re-link the bound key to a different parent, with the same cycle check as45
* the bind. Valid only while nothing produced under the old parent is46
* retained — the blank-session recompose contract, which the holder upholds47
* because this relation cannot see what a session logged.48
* @param parent - the new enclosing scope key.49
*/50
rebind(parent: ScopeKey): void51
}53
/** Cycle-checked write shared by the bind and every rebind. */54
function linkScopeParent(key: ScopeKey, parent: ScopeKey): void {55
for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) {56
if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle')57
}58
scopeParents.set(key, parent)59
}61
/**62
* Bind `parent` as `key`'s enclosing scope, once.63
*64
* A key that already has a parent throws: there is no open re-link path, so a65
* scope's ancestry cannot be moved by anyone but the original binder, who66
* alone receives the {@link ScopeParentBinding}. A link that would close a67
* cycle is rejected, because every chain consumer walks parents to the root.68
* @param key - the child scope key.69
* @param parent - its enclosing scope key.70
* @returns the binding that alone may re-link this key.71
*/72
export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding {73
if (scopeParents.has(key)) {74
throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind')75
}76
linkScopeParent(key, parent)77
return {78
rebind(next: ScopeKey): void {79
linkScopeParent(key, next)80
},81
}82
}84
/**85
* Read one key's enclosing scope.86
* @param key - the scope key to inspect.87
* @returns its parent key, or `undefined` for a root scope.88
*/89
export function scopeParentOf(key: ScopeKey): ScopeKey | undefined {90
return scopeParents.get(key)91
}93
/**94
* The chain from a key to its root ancestor.95
* @param key - the starting key, or `undefined` for the empty chain.96
* @returns keys nearest-first: `[key, parent, grandparent, …]`.97
*/98
export function scopeChainOf(key: ScopeKey | undefined): ScopeKey[] {99
const chain: ScopeKey[] = []100
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) chain.push(cursor)101
return chain102
}104
/** A minted registration scope and its quiescent disposal boundaries. */105
export interface Scope {106
/** Context through which scope-owned registrations are made. */107
ctx: Context108
/** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */109
rawDispose: () => Promise<void> | void110
/** Dispose every scope-owned registration; racing calls await the same completion. */111
dispose(): Promise<void>112
}114
/** Follow a Cordis fiber through asynchronous teardown even if its raw disposer was already claimed. */115
async function quiesceFiber(fiber: Fiber): Promise<void> {116
await Promise.resolve(fiber.dispose())117
while (fiber.inertia !== undefined) await fiber.inertia118
}120
/** Shared no-op plugin used as the backing scope fiber. */121
function scope(): void {}123
/** Options accepted by {@link createScope}. */124
export interface CreateScopeOptions {125
/** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */126
parent?: ScopeKey127
}129
/**130
* Mint a scope under `ctx`. The scoped context inherits the minting plugin's131
* dependency API and owns every registration made through it.132
* @param ctx - active context whose dependency API the scope inherits.133
* @param key - opaque identity used for listener routing.134
* @param options - optional scope-chain placement.135
* @returns the scoped context and exact/shared disposal boundaries.136
*/137
export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {138
if (options?.parent !== undefined) bindScopeParent(key, options.parent)139
const fiber = ctx.plugin(scope)140
const scoped: Context = fiber.ctx.extend({ [kScope]: key })141
let disposing: Promise<void> | undefined142
return {143
ctx: scoped,144
rawDispose: fiber.dispose,145
dispose: () => (disposing ??= quiesceFiber(fiber)),146
}147
}149
/**150
* Read the nearest scope tag inherited by a context.151
* @param ctx - context to inspect.152
* @returns its scope key, or `undefined` for an unscoped context.153
*/154
export function scopeOf(ctx: Context): ScopeKey | undefined {155
return (ctx as Context & { [kScope]?: ScopeKey })[kScope]156
}158
/**159
* Build an opaque receiver that preserves the base filter, admits untagged160
* listeners globally, and admits tagged listeners for a matching key or any161
* of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing162
* scope receives every descendant scope's events, which is what lets one163
* standing composition observe each of the agents composed under it. A tag164
* BELOW the dispatch key stays excluded — events flow up the chain, never165
* down.166
* @param base - subject or service whose existing Cordis filter is preserved.167
* @param key - routed scope identity, or `undefined` for an unscoped subject.168
* @returns a carrier whose subject remains available only through event arguments.169
*/170
export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {171
const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]172
const carrier = {173
[CordisContext.filter](ctx: Context): boolean {174
if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false175
const tag = scopeOf(ctx)176
if (tag === undefined) return true177
for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) {178
if (cursor === tag) return true179
}180
return false181
},182
}183
carrierKeys.set(carrier, key)184
return carrier as unknown as Scoped<T>185
}187
/**188
* Test whether a value is a scope carrier.189
* @param value - dispatch receiver to inspect.190
* @returns whether {@link scopeTarget} created it.191
*/192
export function isScopeCarrier(value: unknown): value is Scoped<object> {193
return typeof value === 'object' && value !== null && carrierKeys.has(value)194
}196
/**197
* Read a carrier's routing key.198
* @param value - dispatch receiver to inspect.199
* @returns the carrier key, or `undefined` for an unkeyed/non-carrier value.200
*/201
export function carrierKeyOf(value: unknown): ScopeKey | undefined {202
if (!isScopeCarrier(value)) return undefined203
return carrierKeys.get(value)204
}