store.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /**
  2. * Shared insertion-ordered storage and effect ownership for scope-aware registries.
  3. *
  4. * @module @deepseek-ai/dsh-scope
  5. */
  6. import type { Context } from '@deepseek-ai/cordis'
  7. import { scopeChainOf, scopeOf } from './index.ts'
  8. import type { ScopeKey } from './index.ts'
  9. /** One scope's aggregate contribution to a registry. */
  10. export interface ScopeLayer {
  11. /** Whether every table in this layer is empty. */
  12. isEmpty(): boolean
  13. }
  14. /** Internal common read contract for the two entry-table implementations. */
  15. interface EntryValues<V> {
  16. values(): IterableIterator<V>
  17. isEmpty(): boolean
  18. }
  19. /**
  20. * Insertion-ordered named entries with caller-owned duplicate diagnostics.
  21. *
  22. * Values are borrowed. Iterators are live within one nonempty table
  23. * generation; draining the table detaches them from later insertions. Each
  24. * successful insertion returns an idempotent undo for that exact entry.
  25. */
  26. export class NamedEntries<V> implements EntryValues<V> {
  27. private data = new Map<string, V>()
  28. constructor(
  29. private readonly duplicateError: (name: string) => Error,
  30. ) {}
  31. /**
  32. * Insert one unique name.
  33. * @param name - name unique within this table.
  34. * @param value - borrowed value to retain.
  35. * @returns an idempotent undo that removes only this insertion.
  36. */
  37. insert(name: string, value: V): () => void {
  38. const data = this.data
  39. if (data.has(name)) throw this.duplicateError(name)
  40. data.set(name, value)
  41. let active = true
  42. return () => {
  43. if (!active) return
  44. active = false
  45. data.delete(name)
  46. if (data.size === 0 && this.data === data) this.data = new Map()
  47. }
  48. }
  49. /**
  50. * Read one named value.
  51. * @param name - name to resolve.
  52. * @returns the retained value, or `undefined` when absent.
  53. */
  54. get(name: string): V | undefined {
  55. return this.data.get(name)
  56. }
  57. /**
  58. * Test one name for membership.
  59. * @param name - name to test.
  60. * @returns whether the table contains that name.
  61. */
  62. has(name: string): boolean {
  63. return this.data.has(name)
  64. }
  65. /**
  66. * Iterate live names in insertion order.
  67. * @returns the native live key iterator.
  68. */
  69. keys(): IterableIterator<string> {
  70. return this.data.keys()
  71. }
  72. /**
  73. * Iterate live entries in insertion order.
  74. * @returns the native live entry iterator.
  75. */
  76. entries(): IterableIterator<[string, V]> {
  77. return this.data.entries()
  78. }
  79. /**
  80. * Iterate live values in insertion order.
  81. * @returns the native live value iterator.
  82. */
  83. values(): IterableIterator<V> {
  84. return this.data.values()
  85. }
  86. /**
  87. * Test whether this table has no entries.
  88. * @returns whether the table is empty.
  89. */
  90. isEmpty(): boolean {
  91. return this.data.size === 0
  92. }
  93. }
  94. /**
  95. * Insertion-ordered anonymous entries with independent registration identity.
  96. *
  97. * Equal values remain separate registrations. Values are borrowed, and
  98. * iterators are live within one nonempty table generation; draining the table
  99. * detaches them from later appends.
  100. */
  101. export class AnonymousEntries<V> implements EntryValues<V> {
  102. private data = new Map<symbol, V>()
  103. /**
  104. * Append one independently owned value.
  105. * @param value - borrowed value to retain.
  106. * @returns an idempotent undo for this exact append.
  107. */
  108. append(value: V): () => void {
  109. const data = this.data
  110. const key = Symbol()
  111. data.set(key, value)
  112. let active = true
  113. return () => {
  114. if (!active) return
  115. active = false
  116. data.delete(key)
  117. if (data.size === 0 && this.data === data) this.data = new Map()
  118. }
  119. }
  120. /**
  121. * Iterate live values in insertion order.
  122. * @returns the native live value iterator.
  123. */
  124. values(): IterableIterator<V> {
  125. return this.data.values()
  126. }
  127. /**
  128. * Test whether this table has no entries.
  129. * @returns whether the table is empty.
  130. */
  131. isEmpty(): boolean {
  132. return this.data.size === 0
  133. }
  134. }
  135. /**
  136. * Own the global and exact-scope layers for one registry.
  137. *
  138. * Reads never create scoped layers. Registrations derive both visibility and
  139. * effect ownership from the supplied Cordis context, collect undo before
  140. * notification, and reclaim only a completely empty aggregate layer.
  141. */
  142. export class ScopedLayers<L extends ScopeLayer> {
  143. /** The eagerly constructed context-global layer. */
  144. readonly global: L
  145. private readonly scoped = new Map<ScopeKey, L>()
  146. constructor(
  147. private readonly createLayer: (scope: ScopeKey | undefined) => L,
  148. private readonly onChange: () => void,
  149. ) {
  150. this.global = createLayer(undefined)
  151. }
  152. /**
  153. * Read an existing exact-scope overlay. Deliberately chain-blind: callers
  154. * addressing one scope's OWN contributions (its restrictions, its guards)
  155. * must not silently pick up an ancestor's — use {@link chainLayers} where
  156. * inheritance is the point.
  157. * @param scope - exact scope key; `undefined` denotes no overlay.
  158. * @returns the existing scoped layer, or `undefined` without creating one.
  159. */
  160. peek(scope: ScopeKey | undefined): L | undefined {
  161. if (scope === undefined) return undefined
  162. return this.scoped.get(scope)
  163. }
  164. /**
  165. * Existing overlays along the scope's parent chain ({@link scopeChainOf}),
  166. * farthest ancestor first and the exact scope last, so a caller layering
  167. * them in order gives the nearest scope the final word.
  168. * @param scope - viewing scope, or `undefined` for no overlays.
  169. * @returns the existing layers, nearest last; absent overlays are skipped.
  170. */
  171. chainLayers(scope: ScopeKey | undefined): L[] {
  172. const layers: L[] = []
  173. for (const key of scopeChainOf(scope).reverse()) {
  174. const layer = this.scoped.get(key)
  175. if (layer !== undefined) layers.push(layer)
  176. }
  177. return layers
  178. }
  179. /**
  180. * Materialize global named entries followed by scope-chain shadows,
  181. * farthest ancestor first, so the nearest scope's entry wins a name.
  182. * @param scope - viewing scope, or `undefined` for the global view.
  183. * @param pick - select the named table from a layer.
  184. * @returns an insertion-ordered effective map.
  185. */
  186. merge<V>(
  187. scope: ScopeKey | undefined,
  188. pick: (layer: L) => NamedEntries<V>,
  189. ): Map<string, V> {
  190. const merged = new Map(pick(this.global).entries())
  191. for (const layer of this.chainLayers(scope)) {
  192. for (const [name, value] of pick(layer).entries()) merged.set(name, value)
  193. }
  194. return merged
  195. }
  196. /**
  197. * Attach one synchronous layer mutation to its registration context.
  198. * @param ctx - context that determines both scope visibility and effect ownership.
  199. * @param action - atomic mutation returning its synchronous undo.
  200. * @param options - Cordis effect label and optional change notification.
  201. * @returns the exact disposer returned by `ctx.effect()`.
  202. */
  203. effect(
  204. ctx: Context,
  205. action: (layer: L) => () => void,
  206. options: { label: string; notify?: boolean },
  207. ): () => void {
  208. const scope = scopeOf(ctx)
  209. const notify = options.notify ?? true
  210. const dispose = ctx.effect(function* (this: ScopedLayers<L>) {
  211. let layer: L
  212. let created = false
  213. if (scope === undefined) {
  214. layer = this.global
  215. } else {
  216. const existing = this.scoped.get(scope)
  217. if (existing === undefined) {
  218. layer = this.createLayer(scope)
  219. this.scoped.set(scope, layer)
  220. created = true
  221. } else {
  222. layer = existing
  223. }
  224. }
  225. let undo: () => void
  226. try {
  227. undo = action(layer)
  228. } catch (error) {
  229. if (scope !== undefined && created && layer.isEmpty()) this.scoped.delete(scope)
  230. throw error
  231. }
  232. yield () => {
  233. undo()
  234. if (scope !== undefined && layer.isEmpty()) this.scoped.delete(scope)
  235. if (notify) this.onChange()
  236. }
  237. if (notify) this.onChange()
  238. }.bind(this), options.label)
  239. // oxlint-disable-next-line typescript/no-misused-promises -- exact synchronous disposer preserves Cordis effect identity
  240. return dispose
  241. }
  242. }