index.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  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. import type { Context, Fiber } from '@deepseek-ai/cordis'
  8. import { Context as CordisContext } from '@deepseek-ai/cordis'
  9. export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts'
  10. export type { ScopeLayer } from './store.ts'
  11. /** An opaque, identity-compared scope key. */
  12. export type ScopeKey = object
  13. /** Context tag written by {@link createScope}. */
  14. const kScope = Symbol('dsh.scope')
  15. declare const ScopedBrand: unique symbol
  16. /**
  17. * A routing-only event receiver built by {@link scopeTarget}. The type
  18. * parameter records the subject type for dispatch checking; the carrier does
  19. * not expose the subject's properties. Event payloads carry the real subject.
  20. */
  21. export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
  22. /** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */
  23. const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
  24. /**
  25. * The enclosing scope of each key. One relation powers both directions of
  26. * scope nesting: registration views inherit DOWN the chain (a child scope
  27. * sees its ancestors' layers — {@link ScopedLayers}), and event admission
  28. * extends UP it (a listener tagged with an ancestor receives events dispatched
  29. * to a descendant key — {@link scopeTarget}).
  30. */
  31. const scopeParents = new WeakMap<ScopeKey, ScopeKey>()
  32. /** The privileged handle to move one scope key's parent link. */
  33. export interface ScopeParentBinding {
  34. /**
  35. * Re-link the bound key to a different parent, with the same cycle check as
  36. * the bind. Valid only while nothing produced under the old parent is
  37. * retained — the blank-session recompose contract, which the holder upholds
  38. * because this relation cannot see what a session logged.
  39. * @param parent - the new enclosing scope key.
  40. */
  41. rebind(parent: ScopeKey): void
  42. }
  43. /** Cycle-checked write shared by the bind and every rebind. */
  44. function linkScopeParent(key: ScopeKey, parent: ScopeKey): void {
  45. for (let cursor: ScopeKey | undefined = parent; cursor !== undefined; cursor = scopeParents.get(cursor)) {
  46. if (cursor === key) throw new Error('dsh-scope: scope parent link would form a cycle')
  47. }
  48. scopeParents.set(key, parent)
  49. }
  50. /**
  51. * Bind `parent` as `key`'s enclosing scope, once.
  52. *
  53. * A key that already has a parent throws: there is no open re-link path, so a
  54. * scope's ancestry cannot be moved by anyone but the original binder, who
  55. * alone receives the {@link ScopeParentBinding}. A link that would close a
  56. * cycle is rejected, because every chain consumer walks parents to the root.
  57. * @param key - the child scope key.
  58. * @param parent - its enclosing scope key.
  59. * @returns the binding that alone may re-link this key.
  60. */
  61. export function bindScopeParent(key: ScopeKey, parent: ScopeKey): ScopeParentBinding {
  62. if (scopeParents.has(key)) {
  63. throw new Error('dsh-scope: scope key is already bound to a parent; re-linking requires the binding returned by the original bind')
  64. }
  65. linkScopeParent(key, parent)
  66. return {
  67. rebind(next: ScopeKey): void {
  68. linkScopeParent(key, next)
  69. },
  70. }
  71. }
  72. /**
  73. * Read one key's enclosing scope.
  74. * @param key - the scope key to inspect.
  75. * @returns its parent key, or `undefined` for a root scope.
  76. */
  77. export function scopeParentOf(key: ScopeKey): ScopeKey | undefined {
  78. return scopeParents.get(key)
  79. }
  80. /**
  81. * The chain from a key to its root ancestor.
  82. * @param key - the starting key, or `undefined` for the empty chain.
  83. * @returns keys nearest-first: `[key, parent, grandparent, …]`.
  84. */
  85. export function scopeChainOf(key: ScopeKey | undefined): ScopeKey[] {
  86. const chain: ScopeKey[] = []
  87. for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) chain.push(cursor)
  88. return chain
  89. }
  90. /** A minted registration scope and its quiescent disposal boundaries. */
  91. export interface Scope {
  92. /** Context through which scope-owned registrations are made. */
  93. ctx: Context
  94. /** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */
  95. rawDispose: () => Promise<void> | void
  96. /** Dispose every scope-owned registration; racing calls await the same completion. */
  97. dispose(): Promise<void>
  98. }
  99. /** Follow a Cordis fiber through asynchronous teardown even if its raw disposer was already claimed. */
  100. async function quiesceFiber(fiber: Fiber): Promise<void> {
  101. await Promise.resolve(fiber.dispose())
  102. while (fiber.inertia !== undefined) await fiber.inertia
  103. }
  104. /** Shared no-op plugin used as the backing scope fiber. */
  105. function scope(): void {}
  106. /** Options accepted by {@link createScope}. */
  107. export interface CreateScopeOptions {
  108. /** Enclosing scope bound via {@link bindScopeParent} before the scope is usable; the binding stays internal. */
  109. parent?: ScopeKey
  110. }
  111. /**
  112. * Mint a scope under `ctx`. The scoped context inherits the minting plugin's
  113. * dependency API and owns every registration made through it.
  114. * @param ctx - active context whose dependency API the scope inherits.
  115. * @param key - opaque identity used for listener routing.
  116. * @param options - optional scope-chain placement.
  117. * @returns the scoped context and exact/shared disposal boundaries.
  118. */
  119. export function createScope(ctx: Context, key: ScopeKey, options?: CreateScopeOptions): Scope {
  120. if (options?.parent !== undefined) bindScopeParent(key, options.parent)
  121. const fiber = ctx.plugin(scope)
  122. const scoped: Context = fiber.ctx.extend({ [kScope]: key })
  123. let disposing: Promise<void> | undefined
  124. return {
  125. ctx: scoped,
  126. rawDispose: fiber.dispose,
  127. dispose: () => (disposing ??= quiesceFiber(fiber)),
  128. }
  129. }
  130. /**
  131. * Read the nearest scope tag inherited by a context.
  132. * @param ctx - context to inspect.
  133. * @returns its scope key, or `undefined` for an unscoped context.
  134. */
  135. export function scopeOf(ctx: Context): ScopeKey | undefined {
  136. return (ctx as Context & { [kScope]?: ScopeKey })[kScope]
  137. }
  138. /**
  139. * Build an opaque receiver that preserves the base filter, admits untagged
  140. * listeners globally, and admits tagged listeners for a matching key or any
  141. * of its ancestors ({@link bindScopeParent}): a listener owned by an enclosing
  142. * scope receives every descendant scope's events, which is what lets one
  143. * standing composition observe each of the agents composed under it. A tag
  144. * BELOW the dispatch key stays excluded — events flow up the chain, never
  145. * down.
  146. * @param base - subject or service whose existing Cordis filter is preserved.
  147. * @param key - routed scope identity, or `undefined` for an unscoped subject.
  148. * @returns a carrier whose subject remains available only through event arguments.
  149. */
  150. export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {
  151. const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]
  152. const carrier = {
  153. [CordisContext.filter](ctx: Context): boolean {
  154. if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false
  155. const tag = scopeOf(ctx)
  156. if (tag === undefined) return true
  157. for (let cursor = key; cursor !== undefined; cursor = scopeParents.get(cursor)) {
  158. if (cursor === tag) return true
  159. }
  160. return false
  161. },
  162. }
  163. carrierKeys.set(carrier, key)
  164. return carrier as unknown as Scoped<T>
  165. }
  166. /**
  167. * Test whether a value is a scope carrier.
  168. * @param value - dispatch receiver to inspect.
  169. * @returns whether {@link scopeTarget} created it.
  170. */
  171. export function isScopeCarrier(value: unknown): value is Scoped<object> {
  172. return typeof value === 'object' && value !== null && carrierKeys.has(value)
  173. }
  174. /**
  175. * Read a carrier's routing key.
  176. * @param value - dispatch receiver to inspect.
  177. * @returns the carrier key, or `undefined` for an unkeyed/non-carrier value.
  178. */
  179. export function carrierKeyOf(value: unknown): ScopeKey | undefined {
  180. if (!isScopeCarrier(value)) return undefined
  181. return carrierKeys.get(value)
  182. }