返回源码地图

packages/core/scope/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 02 / 03;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Scoped-context primitive: mint a Cordis context that tags registrations with
3 * an opaque identity and build routing-only event carriers for that identity.
4 *
5 * @module @deepseek-ai/dsh-scope
6 */
7
8import type { Context, Fiber } from '@deepseek-ai/cordis'
9import { Context as CordisContext } from '@deepseek-ai/cordis'
10
11export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'
12export type { ScopeLayer } from './store.ts'
13
14/** An opaque, identity-compared scope key. */
15export type ScopeKey = object
16
17/** Context tag written by {@link createScope}. */
18const kScope = Symbol('dsh.scope')
19
20declare const ScopedBrand: unique symbol
21
22/**
23 * A routing-only event receiver built by {@link scopeTarget}. The type
24 * parameter records the subject type for dispatch checking; the carrier does
25 * not expose the subject's properties. Event payloads carry the real subject.
26 */
27export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
28
29/** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */
30const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
31
32/**
33 * The enclosing scope of each key. One relation powers both directions of
34 * scope nesting: registration views inherit DOWN the chain (a child scope
35 * sees its ancestors' layers — {@link ScopedLayers}), and event admission
36 * extends UP it (a listener tagged with an ancestor receives events dispatched
37 * to a descendant key — {@link scopeTarget}).
38 */
39const scopeParents = new WeakMap<ScopeKey, ScopeKey>()
40
41/** The privileged handle to move one scope key's parent link. */
42export interface ScopeParentBinding {
43 /**
44 * Re-link the bound key to a different parent, with the same cycle check as
45 * the bind. Valid only while nothing produced under the old parent is
46 * retained — the blank-session recompose contract, which the holder upholds
47 * because this relation cannot see what a session logged.
48 * @param parent - the new enclosing scope key.
49 */
50 rebind(parent: ScopeKey): void
51}
52
53/** Cycle-checked write shared by the bind and every rebind. */
54function 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}
60
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 a
65 * scope's ancestry cannot be moved by anyone but the original binder, who
66 * alone receives the {@link ScopeParentBinding}. A link that would close a
67 * 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 */
72export 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}
83
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 */
89export function scopeParentOf(key: ScopeKey): ScopeKey | undefined {
90 return scopeParents.get(key)
91}
92
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 */
98export 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 chain
102}
103
104/** A minted registration scope and its quiescent disposal boundaries. */
105export interface Scope {
106 /** Context through which scope-owned registrations are made. */
107 ctx: Context
108 /** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */
109 rawDispose: () => Promise<void> | void
110 /** Dispose every scope-owned registration; racing calls await the same completion. */
111 dispose(): Promise<void>
112}
113
114/** Follow a Cordis fiber through asynchronous teardown even if its raw disposer was already claimed. */
115async function quiesceFiber(fiber: Fiber): Promise<void> {
116 await Promise.resolve(fiber.dispose())
117 while (fiber.inertia !== undefined) await fiber.inertia
118}
119
120/** Shared no-op plugin used as the backing scope fiber. */
121function scope(): void {}
122
123/** Options accepted by {@link createScope}. */
124export interface CreateScopeOptions {
125 /** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */
126 parent?: ScopeKey
127}
128
129/**
130 * Mint a scope under `ctx`. The scoped context inherits the minting plugin's
131 * 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 */
137export 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> | undefined
142 return {
143 ctx: scoped,
144 rawDispose: fiber.dispose,
145 dispose: () => (disposing ??= quiesceFiber(fiber)),
146 }
147}
148
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 */
154export function scopeOf(ctx: Context): ScopeKey | undefined {
155 return (ctx as Context & { [kScope]?: ScopeKey })[kScope]
156}
157
158/**
159 * Build an opaque receiver that preserves the base filter, admits untagged
160 * listeners globally, and admits tagged listeners for a matching key or any
161 * of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing
162 * scope receives every descendant scope's events, which is what lets one
163 * standing composition observe each of the agents composed under it. A tag
164 * BELOW the dispatch key stays excluded — events flow up the chain, never
165 * 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 */
170export 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 false
175 const tag = scopeOf(ctx)
176 if (tag === undefined) return true
177 for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) {
178 if (cursor === tag) return true
179 }
180 return false
181 },
182 }
183 carrierKeys.set(carrier, key)
184 return carrier as unknown as Scoped<T>
185}
186
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 */
192export function isScopeCarrier(value: unknown): value is Scoped<object> {
193 return typeof value === 'object' && value !== null && carrierKeys.has(value)
194}
195
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 */
201export function carrierKeyOf(value: unknown): ScopeKey | undefined {
202 if (!isScopeCarrier(value)) return undefined
203 return carrierKeys.get(value)
204}