@astrale-os/kernel-client 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/dist/auth/addressing.d.ts +32 -0
  2. package/dist/auth/addressing.d.ts.map +1 -0
  3. package/dist/auth/addressing.js +36 -0
  4. package/dist/auth/addressing.js.map +1 -0
  5. package/dist/auth/api.d.ts +69 -0
  6. package/dist/auth/api.d.ts.map +1 -0
  7. package/dist/auth/api.js +24 -0
  8. package/dist/auth/api.js.map +1 -0
  9. package/dist/auth/batch.d.ts +18 -0
  10. package/dist/auth/batch.d.ts.map +1 -0
  11. package/dist/auth/batch.js +25 -0
  12. package/dist/auth/batch.js.map +1 -0
  13. package/dist/auth/composition.d.ts +27 -0
  14. package/dist/auth/composition.d.ts.map +1 -0
  15. package/dist/auth/composition.js +83 -0
  16. package/dist/auth/composition.js.map +1 -0
  17. package/dist/auth/index.d.ts +14 -0
  18. package/dist/auth/index.d.ts.map +1 -0
  19. package/dist/auth/index.js +9 -0
  20. package/dist/auth/index.js.map +1 -0
  21. package/dist/auth/minting.d.ts +35 -0
  22. package/dist/auth/minting.d.ts.map +1 -0
  23. package/dist/auth/minting.js +35 -0
  24. package/dist/auth/minting.js.map +1 -0
  25. package/dist/auth/permissions.d.ts +55 -0
  26. package/dist/auth/permissions.d.ts.map +1 -0
  27. package/dist/auth/permissions.js +47 -0
  28. package/dist/auth/permissions.js.map +1 -0
  29. package/dist/auth/principal.d.ts +19 -0
  30. package/dist/auth/principal.d.ts.map +1 -0
  31. package/dist/auth/principal.js +46 -0
  32. package/dist/auth/principal.js.map +1 -0
  33. package/dist/errors/errors.d.ts +53 -0
  34. package/dist/errors/errors.d.ts.map +1 -1
  35. package/dist/errors/errors.js +69 -1
  36. package/dist/errors/errors.js.map +1 -1
  37. package/dist/errors/guards.d.ts +39 -0
  38. package/dist/errors/guards.d.ts.map +1 -0
  39. package/dist/errors/guards.js +127 -0
  40. package/dist/errors/guards.js.map +1 -0
  41. package/dist/errors/index.d.ts +4 -2
  42. package/dist/errors/index.d.ts.map +1 -1
  43. package/dist/errors/index.js +2 -1
  44. package/dist/errors/index.js.map +1 -1
  45. package/dist/graph/api.d.ts +82 -0
  46. package/dist/graph/api.d.ts.map +1 -0
  47. package/dist/graph/api.js +64 -0
  48. package/dist/graph/api.js.map +1 -0
  49. package/dist/graph/index.d.ts +24 -0
  50. package/dist/graph/index.d.ts.map +1 -0
  51. package/dist/graph/index.js +20 -0
  52. package/dist/graph/index.js.map +1 -0
  53. package/dist/graph/mutate/builder.d.ts +108 -0
  54. package/dist/graph/mutate/builder.d.ts.map +1 -0
  55. package/dist/graph/mutate/builder.js +127 -0
  56. package/dist/graph/mutate/builder.js.map +1 -0
  57. package/dist/graph/mutate/index.d.ts +5 -0
  58. package/dist/graph/mutate/index.d.ts.map +1 -0
  59. package/dist/graph/mutate/index.js +3 -0
  60. package/dist/graph/mutate/index.js.map +1 -0
  61. package/dist/graph/mutate/shorthands.d.ts +42 -0
  62. package/dist/graph/mutate/shorthands.d.ts.map +1 -0
  63. package/dist/graph/mutate/shorthands.js +65 -0
  64. package/dist/graph/mutate/shorthands.js.map +1 -0
  65. package/dist/graph/named.d.ts +38 -0
  66. package/dist/graph/named.d.ts.map +1 -0
  67. package/dist/graph/named.js +47 -0
  68. package/dist/graph/named.js.map +1 -0
  69. package/dist/graph/query/builder.d.ts +116 -0
  70. package/dist/graph/query/builder.d.ts.map +1 -0
  71. package/dist/graph/query/builder.js +136 -0
  72. package/dist/graph/query/builder.js.map +1 -0
  73. package/dist/graph/query/edge-ref.d.ts +13 -0
  74. package/dist/graph/query/edge-ref.d.ts.map +1 -0
  75. package/dist/graph/query/edge-ref.js +56 -0
  76. package/dist/graph/query/edge-ref.js.map +1 -0
  77. package/dist/graph/query/edges.d.ts +10 -0
  78. package/dist/graph/query/edges.d.ts.map +1 -0
  79. package/dist/graph/query/edges.js +11 -0
  80. package/dist/graph/query/edges.js.map +1 -0
  81. package/dist/graph/query/errors.d.ts +31 -0
  82. package/dist/graph/query/errors.d.ts.map +1 -0
  83. package/dist/graph/query/errors.js +28 -0
  84. package/dist/graph/query/errors.js.map +1 -0
  85. package/dist/graph/query/filter.d.ts +50 -0
  86. package/dist/graph/query/filter.d.ts.map +1 -0
  87. package/dist/graph/query/filter.js +32 -0
  88. package/dist/graph/query/filter.js.map +1 -0
  89. package/dist/graph/query/index.d.ts +12 -0
  90. package/dist/graph/query/index.d.ts.map +1 -0
  91. package/dist/graph/query/index.js +7 -0
  92. package/dist/graph/query/index.js.map +1 -0
  93. package/dist/graph/query/lower.d.ts +30 -0
  94. package/dist/graph/query/lower.d.ts.map +1 -0
  95. package/dist/graph/query/lower.js +171 -0
  96. package/dist/graph/query/lower.js.map +1 -0
  97. package/dist/graph/query/shorthands.d.ts +34 -0
  98. package/dist/graph/query/shorthands.d.ts.map +1 -0
  99. package/dist/graph/query/shorthands.js +71 -0
  100. package/dist/graph/query/shorthands.js.map +1 -0
  101. package/dist/graph/query/traverse.d.ts +59 -0
  102. package/dist/graph/query/traverse.d.ts.map +1 -0
  103. package/dist/graph/query/traverse.js +144 -0
  104. package/dist/graph/query/traverse.js.map +1 -0
  105. package/dist/graph/result/envelope.d.ts +37 -0
  106. package/dist/graph/result/envelope.d.ts.map +1 -0
  107. package/dist/graph/result/envelope.js +59 -0
  108. package/dist/graph/result/envelope.js.map +1 -0
  109. package/dist/graph/result/index.d.ts +6 -0
  110. package/dist/graph/result/index.d.ts.map +1 -0
  111. package/dist/graph/result/index.js +4 -0
  112. package/dist/graph/result/index.js.map +1 -0
  113. package/dist/graph/result/next.d.ts +16 -0
  114. package/dist/graph/result/next.d.ts.map +1 -0
  115. package/dist/graph/result/next.js +77 -0
  116. package/dist/graph/result/next.js.map +1 -0
  117. package/dist/graph/result/paged.d.ts +30 -0
  118. package/dist/graph/result/paged.d.ts.map +1 -0
  119. package/dist/graph/result/paged.js +41 -0
  120. package/dist/graph/result/paged.js.map +1 -0
  121. package/dist/graph/wire.d.ts +33 -0
  122. package/dist/graph/wire.d.ts.map +1 -0
  123. package/dist/graph/wire.js +16 -0
  124. package/dist/graph/wire.js.map +1 -0
  125. package/dist/index.d.ts +11 -3
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +10 -1
  128. package/dist/index.js.map +1 -1
  129. package/dist/schema/bound/binder.d.ts +49 -0
  130. package/dist/schema/bound/binder.d.ts.map +1 -0
  131. package/dist/schema/bound/binder.js +192 -0
  132. package/dist/schema/bound/binder.js.map +1 -0
  133. package/dist/schema/bound/bound-node.d.ts +89 -0
  134. package/dist/schema/bound/bound-node.d.ts.map +1 -0
  135. package/dist/schema/bound/bound-node.js +77 -0
  136. package/dist/schema/bound/bound-node.js.map +1 -0
  137. package/dist/schema/bound/index.d.ts +10 -0
  138. package/dist/schema/bound/index.d.ts.map +1 -0
  139. package/dist/schema/bound/index.js +5 -0
  140. package/dist/schema/bound/index.js.map +1 -0
  141. package/dist/schema/bound/view.d.ts +48 -0
  142. package/dist/schema/bound/view.d.ts.map +1 -0
  143. package/dist/schema/bound/view.js +90 -0
  144. package/dist/schema/bound/view.js.map +1 -0
  145. package/dist/schema/bound/write-props.d.ts +38 -0
  146. package/dist/schema/bound/write-props.d.ts.map +1 -0
  147. package/dist/schema/bound/write-props.js +2 -0
  148. package/dist/schema/bound/write-props.js.map +1 -0
  149. package/dist/schema/extract.d.ts +12 -0
  150. package/dist/schema/extract.d.ts.map +1 -1
  151. package/dist/schema/extract.js +16 -0
  152. package/dist/schema/extract.js.map +1 -1
  153. package/dist/schema/index.d.ts +7 -3
  154. package/dist/schema/index.d.ts.map +1 -1
  155. package/dist/schema/index.js +4 -2
  156. package/dist/schema/index.js.map +1 -1
  157. package/dist/schema/node-proxy.d.ts +9 -0
  158. package/dist/schema/node-proxy.d.ts.map +1 -1
  159. package/dist/schema/node-proxy.js +31 -1
  160. package/dist/schema/node-proxy.js.map +1 -1
  161. package/dist/{persistence → schema}/persistence.d.ts +1 -22
  162. package/dist/schema/persistence.d.ts.map +1 -0
  163. package/dist/schema/persistence.js +2 -0
  164. package/dist/schema/persistence.js.map +1 -0
  165. package/dist/schema/proxy-types.d.ts +28 -22
  166. package/dist/schema/proxy-types.d.ts.map +1 -1
  167. package/dist/schema/registry.d.ts +1 -2
  168. package/dist/schema/registry.d.ts.map +1 -1
  169. package/dist/schema/registry.js +1 -2
  170. package/dist/schema/registry.js.map +1 -1
  171. package/dist/session/bound.d.ts.map +1 -1
  172. package/dist/session/bound.js +15 -2
  173. package/dist/session/bound.js.map +1 -1
  174. package/dist/session/client-session.d.ts +1 -1
  175. package/dist/session/client-session.d.ts.map +1 -1
  176. package/dist/session/client-session.js +5 -5
  177. package/dist/session/client-session.js.map +1 -1
  178. package/dist/session/config.d.ts +8 -4
  179. package/dist/session/config.d.ts.map +1 -1
  180. package/dist/{delegation → session/delegation}/cache.d.ts +3 -4
  181. package/dist/session/delegation/cache.d.ts.map +1 -0
  182. package/dist/session/delegation/cache.js.map +1 -0
  183. package/dist/{delegation → session/delegation}/errors.d.ts +1 -1
  184. package/dist/session/delegation/errors.d.ts.map +1 -0
  185. package/dist/{delegation → session/delegation}/errors.js +1 -1
  186. package/dist/session/delegation/errors.js.map +1 -0
  187. package/dist/session/delegation/policy.d.ts.map +1 -0
  188. package/dist/{delegation → session/delegation}/policy.js +1 -1
  189. package/dist/session/delegation/policy.js.map +1 -0
  190. package/dist/session/delegation-mint.d.ts +13 -0
  191. package/dist/session/delegation-mint.d.ts.map +1 -0
  192. package/dist/session/delegation-mint.js +31 -0
  193. package/dist/session/delegation-mint.js.map +1 -0
  194. package/dist/session/errors/index.d.ts +2 -2
  195. package/dist/session/errors/index.d.ts.map +1 -1
  196. package/dist/session/errors/index.js +2 -2
  197. package/dist/session/errors/index.js.map +1 -1
  198. package/dist/session/helpers.js +2 -2
  199. package/dist/session/helpers.js.map +1 -1
  200. package/dist/session/index.d.ts +9 -1
  201. package/dist/session/index.d.ts.map +1 -1
  202. package/dist/session/index.js +6 -1
  203. package/dist/session/index.js.map +1 -1
  204. package/dist/{redirect → session/redirect}/errors.d.ts +1 -1
  205. package/dist/session/redirect/errors.d.ts.map +1 -0
  206. package/dist/{redirect → session/redirect}/errors.js +1 -1
  207. package/dist/session/redirect/errors.js.map +1 -0
  208. package/dist/session/redirect/policy.d.ts.map +1 -0
  209. package/dist/{redirect → session/redirect}/policy.js +1 -1
  210. package/dist/session/redirect/policy.js.map +1 -0
  211. package/dist/session/types.d.ts +13 -2
  212. package/dist/session/types.d.ts.map +1 -1
  213. package/dist/store/canonicalize.d.ts +44 -0
  214. package/dist/store/canonicalize.d.ts.map +1 -0
  215. package/dist/store/canonicalize.js +0 -0
  216. package/dist/store/canonicalize.js.map +1 -0
  217. package/dist/store/entities.d.ts +81 -0
  218. package/dist/store/entities.d.ts.map +1 -0
  219. package/dist/store/entities.js +202 -0
  220. package/dist/store/entities.js.map +1 -0
  221. package/dist/store/index.d.ts +26 -0
  222. package/dist/store/index.d.ts.map +1 -0
  223. package/dist/store/index.js +19 -0
  224. package/dist/store/index.js.map +1 -0
  225. package/dist/store/invalidate.d.ts +66 -0
  226. package/dist/store/invalidate.d.ts.map +1 -0
  227. package/dist/store/invalidate.js +117 -0
  228. package/dist/store/invalidate.js.map +1 -0
  229. package/dist/store/memory.d.ts +97 -0
  230. package/dist/store/memory.d.ts.map +1 -0
  231. package/dist/store/memory.js +790 -0
  232. package/dist/store/memory.js.map +1 -0
  233. package/dist/store/overlay.d.ts +118 -0
  234. package/dist/store/overlay.d.ts.map +1 -0
  235. package/dist/store/overlay.js +237 -0
  236. package/dist/store/overlay.js.map +1 -0
  237. package/dist/store/schedule.d.ts +31 -0
  238. package/dist/store/schedule.d.ts.map +1 -0
  239. package/dist/store/schedule.js +76 -0
  240. package/dist/store/schedule.js.map +1 -0
  241. package/dist/store/shapes.d.ts +92 -0
  242. package/dist/store/shapes.d.ts.map +1 -0
  243. package/dist/store/shapes.js +369 -0
  244. package/dist/store/shapes.js.map +1 -0
  245. package/dist/store/types.d.ts +168 -0
  246. package/dist/store/types.d.ts.map +1 -0
  247. package/dist/store/types.js +7 -0
  248. package/dist/store/types.js.map +1 -0
  249. package/dist/transport/fetch.d.ts +1 -1
  250. package/dist/transport/fetch.js +1 -1
  251. package/package.json +16 -32
  252. package/dist/delegation/cache.d.ts.map +0 -1
  253. package/dist/delegation/cache.js.map +0 -1
  254. package/dist/delegation/errors.d.ts.map +0 -1
  255. package/dist/delegation/errors.js.map +0 -1
  256. package/dist/delegation/index.d.ts +0 -6
  257. package/dist/delegation/index.d.ts.map +0 -1
  258. package/dist/delegation/index.js +0 -4
  259. package/dist/delegation/index.js.map +0 -1
  260. package/dist/delegation/policy.d.ts.map +0 -1
  261. package/dist/delegation/policy.js.map +0 -1
  262. package/dist/persistence/index.d.ts +0 -3
  263. package/dist/persistence/index.d.ts.map +0 -1
  264. package/dist/persistence/index.js +0 -2
  265. package/dist/persistence/index.js.map +0 -1
  266. package/dist/persistence/persistence.d.ts.map +0 -1
  267. package/dist/persistence/persistence.js +0 -32
  268. package/dist/persistence/persistence.js.map +0 -1
  269. package/dist/redirect/errors.d.ts.map +0 -1
  270. package/dist/redirect/errors.js.map +0 -1
  271. package/dist/redirect/index.d.ts +0 -4
  272. package/dist/redirect/index.d.ts.map +0 -1
  273. package/dist/redirect/index.js +0 -3
  274. package/dist/redirect/index.js.map +0 -1
  275. package/dist/redirect/policy.d.ts.map +0 -1
  276. package/dist/redirect/policy.js.map +0 -1
  277. /package/dist/{delegation → session/delegation}/cache.js +0 -0
  278. /package/dist/{delegation → session/delegation}/policy.d.ts +0 -0
  279. /package/dist/{redirect → session/redirect}/policy.d.ts +0 -0
@@ -0,0 +1,790 @@
1
+ /**
2
+ * createMemory (FRONTEND §4.5, SHELL_REACT §5) — the framework-free memory
3
+ * facade over a `GraphApi`.
4
+ *
5
+ * One partial client copy of the graph per scope (= principal): entity store +
6
+ * shape store + optimistic overlay. Reads are shape-keyed, deduped, and
7
+ * stale-while-revalidate; a `subscribe`/`getSnapshot` pair gives
8
+ * useSyncExternalStore semantics (tear-free, referentially stable when
9
+ * unchanged) and `promise` a cold-shape handle for React suspense.
10
+ *
11
+ * SAFE MODE (§5.7 — ratified until the §6 wire amendments land). One rule:
12
+ * **confirmed state comes only from server responses; client predictions live
13
+ * only in a display layer guaranteed to be discarded and replaced by server
14
+ * truth.** Concretely:
15
+ * - Confirmed state (EntityStore + shape membership) is written ONLY by read
16
+ * responses (`integrate` / `integrateShape`).
17
+ * - Every write DISPATCHES, then marks the shapes it touched stale and
18
+ * REVALIDATES them (refetch = reconcile). "Slow but never wrong."
19
+ * - The optimistic overlay is DISPLAY-ONLY. Prop updates on cached nodes and
20
+ * deletes get an overlay (no-flicker prediction); creates/links/unlinks are
21
+ * reconcile-only (a create/link misprediction is a phantom, forbidden; a
22
+ * delete misprediction is loud — a brief reappearance). The overlay is held
23
+ * from apply until the reconciling refetch lands, then dropped — never
24
+ * folded into confirmed state.
25
+ * - Ordering: the logical merge clock is stamped at response ARRIVAL, and a
26
+ * per-scope mutation EPOCH closes the read-straddles-commit window (a
27
+ * response older than a commit is integrated-then-restaled).
28
+ * - Isolation: the `with` lens partitions the ENTITY store, not just the
29
+ * shape key — lenses never share entities.
30
+ *
31
+ * The public surface (`GraphMemory`/`LiveResult`, `createMemory`) is stable:
32
+ * when seq/rev/delta/watch (§6/§7) land, this becomes "fast and never wrong"
33
+ * with no surface change (`commitOverlay` is already the server-delta seam).
34
+ */
35
+ import { Graph, rawOf } from '@astrale-os/kernel-core';
36
+ import { edgeKey } from '../graph';
37
+ import { mintedId, PatchBuilderImpl } from '../graph/mutate';
38
+ import { HAS_PARENT_CLASS } from '../graph/query/edges';
39
+ import { shapeInputToAST, shapeKey, stripCursors } from './canonicalize';
40
+ import { EntityStore } from './entities';
41
+ import { affectedShapes } from './invalidate';
42
+ import { normalizePatch, Overlay } from './overlay';
43
+ import { TaskQueue } from './schedule';
44
+ import { integrateShape, materialize, ShapeStore, shapeViewOf } from './shapes';
45
+ /** Bind the memory onto a `GraphApi`. */
46
+ export function createMemory(api, opts) {
47
+ return new Memory(api, opts).facade(opts?.scope ?? 'default');
48
+ }
49
+ class Memory {
50
+ defaultApi;
51
+ scopes = new Map();
52
+ apis = new Map();
53
+ now;
54
+ ttlMs;
55
+ tombstoneTtlMs;
56
+ onError;
57
+ dev;
58
+ sweepEvery;
59
+ scheduler;
60
+ clock = 0;
61
+ mutationSeq = 0;
62
+ opCount = 0;
63
+ constructor(defaultApi, opts) {
64
+ this.defaultApi = defaultApi;
65
+ this.now = opts?.now ?? Date.now;
66
+ this.ttlMs = opts?.ttlMs ?? 30_000;
67
+ this.tombstoneTtlMs = opts?.tombstoneTtlMs ?? 300_000;
68
+ this.onError = opts?.onError;
69
+ this.dev = opts?.dev ?? false;
70
+ this.sweepEvery = opts?.sweepEvery ?? 128;
71
+ this.scheduler = new TaskQueue(opts?.concurrency ?? 4);
72
+ }
73
+ facade(scope) {
74
+ return {
75
+ scope,
76
+ api: this.apiFor(scope),
77
+ query: (shape, o) => this.query(scope, shape, o),
78
+ peek: (shape, o) => this.peek(scope, shape, o),
79
+ preload: (shape, o) => this.preload(scope, shape, o),
80
+ subscribe: (shape, cb, o) => this.subscribe(scope, shape, cb, o),
81
+ getSnapshot: (shape, o) => this.getSnapshot(scope, shape, o),
82
+ promise: (shape, o) => this.promise(scope, shape, o),
83
+ watch: (shape, cb, o) => this.watchShape(scope, shape, cb, o),
84
+ mutate: (arg, o) => this.mutate(scope, arg, o),
85
+ update: (node, props, o) => this.update(scope, node, props, o),
86
+ create: (cls, at, props, o) => this.create(scope, cls, at, props, o),
87
+ remove: (node, o) => this.remove(scope, node, o),
88
+ link: (edge, o) => this.link(scope, edge, o),
89
+ unlink: (edge, o) => this.unlink(scope, edge, o),
90
+ overlay: (build, o) => this.openOverlay(o?.scope ?? scope, build),
91
+ forScope: (s, a) => {
92
+ if (a !== undefined)
93
+ this.apis.set(s, a);
94
+ return this.facade(s);
95
+ },
96
+ sweep: () => this.sweep(),
97
+ };
98
+ }
99
+ // ── Scope + key plumbing ────────────────────────────────────────────────────
100
+ apiFor(scope) {
101
+ return this.apis.get(scope) ?? this.defaultApi;
102
+ }
103
+ /**
104
+ * The isolation key partitions the store by `(principal, with-lens)` (§5.7):
105
+ * two lenses over the same principal never share entities, shapes, or overlay.
106
+ */
107
+ isoKey(principal, withPerm) {
108
+ return withPerm === undefined ? principal : `${principal}::with=${withPerm}`;
109
+ }
110
+ state(principal, withPerm) {
111
+ const key = this.isoKey(principal, withPerm);
112
+ let s = this.scopes.get(key);
113
+ if (s === undefined) {
114
+ s = {
115
+ principal,
116
+ isoKey: key,
117
+ entities: new EntityStore(),
118
+ shapes: new ShapeStore(),
119
+ overlay: new Overlay(),
120
+ epoch: 0,
121
+ };
122
+ this.scopes.set(key, s);
123
+ }
124
+ return s;
125
+ }
126
+ /** Every lens partition of a principal — a write reconciles across all of them. */
127
+ partitionsOf(principal) {
128
+ return [...this.scopes.values()].filter((s) => s.principal === principal);
129
+ }
130
+ keyOf(state, scope, ast) {
131
+ return shapeKey(ast, scope, (raw) => state.entities.resolvePathToId(raw));
132
+ }
133
+ /** Normalize an input to its AST, folding a per-read `with` onto it so it keys the shape. */
134
+ astFor(input, opts) {
135
+ const ast = shapeInputToAST(input);
136
+ if (opts?.with !== undefined && ast.with !== opts.with)
137
+ return { ...ast, with: opts.with };
138
+ return ast;
139
+ }
140
+ /** Resolve (and create) the entry for a shape input. */
141
+ entryFor(state, scope, input, opts) {
142
+ const ast = this.astFor(input, opts);
143
+ const key = this.keyOf(state, scope, ast);
144
+ const created = state.shapes.get(key) === undefined;
145
+ const entry = state.shapes.ensure(key, scope, stripCursors(ast), ast.from.map(rawOf));
146
+ // window is per-shape and fixed at creation — a later caller's option can't
147
+ // retune a shape other observers already read under.
148
+ if (created && opts?.window !== undefined)
149
+ entry.window = opts.window;
150
+ if (entry.observers === 0 && entry.releasedAt === null)
151
+ entry.releasedAt = this.now();
152
+ return entry;
153
+ }
154
+ /** Look up an existing entry without creating one (peek / render-pure getSnapshot). */
155
+ lookup(state, scope, input, opts) {
156
+ return state.shapes.get(this.keyOf(state, scope, this.astFor(input, opts)));
157
+ }
158
+ /** Follow the provisional→resolved alias to a subscription's live entry (post-merge). */
159
+ live(state, entry) {
160
+ return state.shapes.get(entry.key) ?? entry;
161
+ }
162
+ // ── Reads ────────────────────────────────────────────────────────────────────
163
+ async query(scope, input, opts) {
164
+ const state = this.state(scope, opts?.with);
165
+ let entry = this.entryFor(state, scope, input, opts);
166
+ this.tick();
167
+ if (!entry.hasData || entry.stale) {
168
+ try {
169
+ await this.revalidate(state, entry, entry.hasData ? 'reconcile' : 'fresh');
170
+ }
171
+ catch (error) {
172
+ // Warm shape: keep last-good data + `error` (§5.5); only a cold read throws.
173
+ if (!this.live(state, entry).hasData)
174
+ throw error;
175
+ }
176
+ entry = this.live(state, entry);
177
+ }
178
+ return this.snapshot(state, entry);
179
+ }
180
+ peek(scope, input, opts) {
181
+ const state = this.state(scope, opts?.with);
182
+ const entry = this.lookup(state, scope, input, opts);
183
+ if (entry === undefined || !entry.hasData)
184
+ return null;
185
+ return this.snapshot(state, entry);
186
+ }
187
+ async preload(scope, input, opts) {
188
+ const state = this.state(scope, opts?.with);
189
+ const entry = this.entryFor(state, scope, input, opts);
190
+ this.tick();
191
+ if (!entry.hasData)
192
+ await this.revalidate(state, entry, 'fresh').catch(() => { });
193
+ }
194
+ subscribe(scope, input, onStoreChange, opts) {
195
+ const state = this.state(scope, opts?.with);
196
+ const entry = this.entryFor(state, scope, input, opts);
197
+ entry.observers++;
198
+ entry.releasedAt = null;
199
+ entry.listeners.add(onStoreChange);
200
+ this.tick();
201
+ // Fetch when cold, and revalidate when known-stale (a prior write or a failed
202
+ // refresh left it stale) — §5.5. Reconcile preserves the accumulated window.
203
+ if ((!entry.hasData || entry.stale) && entry.inflight === null && opts?.live !== false) {
204
+ void this.revalidate(state, entry, entry.hasData ? 'reconcile' : 'fresh').catch(() => { });
205
+ }
206
+ return () => {
207
+ // A merge (rehome) may have moved this subscription onto the surviving
208
+ // entry — re-resolve so the count/listener land on the live one.
209
+ const e = this.live(state, entry);
210
+ e.listeners.delete(onStoreChange);
211
+ e.observers = Math.max(0, e.observers - 1);
212
+ if (e.observers === 0)
213
+ e.releasedAt = this.now();
214
+ };
215
+ }
216
+ /**
217
+ * Render-pure (§5.7): reads an EXISTING entry, never creates one (entry
218
+ * creation is a subscribe/query concern — creating here double-mints entries
219
+ * under StrictMode's double render). A cold shape returns a shared, stable
220
+ * placeholder so useSyncExternalStore sees a referentially constant snapshot.
221
+ */
222
+ getSnapshot(scope, input, opts) {
223
+ const state = this.state(scope, opts?.with);
224
+ const entry = this.lookup(state, scope, input, opts);
225
+ if (entry === undefined)
226
+ return COLD;
227
+ return this.snapshot(state, entry);
228
+ }
229
+ promise(scope, input, opts) {
230
+ const state = this.state(scope, opts?.with);
231
+ const entry = this.entryFor(state, scope, input, opts);
232
+ if (entry.hasData)
233
+ return Promise.resolve(this.snapshot(state, entry));
234
+ return this.revalidate(state, entry, 'fresh').then(() => this.snapshot(state, this.live(state, entry)));
235
+ }
236
+ watchShape(scope, input, cb, opts) {
237
+ const state = this.state(scope, opts?.with);
238
+ const entry = this.entryFor(state, scope, input, opts);
239
+ const unsub = this.subscribe(scope, input, () => cb(this.snapshot(state, this.live(state, entry))), opts);
240
+ cb(this.snapshot(state, this.live(state, entry)));
241
+ return unsub;
242
+ }
243
+ // ── Fetch / integrate ─────────────────────────────────────────────────────────
244
+ /** The AST + membership mode a revalidation intent requests (§5.7 window-preserving). */
245
+ fetchPlan(entry, intent) {
246
+ if (intent === 'more') {
247
+ return {
248
+ ast: entry.contAst ?? entry.baseAst,
249
+ mode: entry.window === 'paged' ? 'replace' : 'append',
250
+ };
251
+ }
252
+ // A reconcile re-requests the whole accumulated window so an 'append' window
253
+ // never collapses to page 1 on a write; a fresh eval resets to page 1.
254
+ if (intent === 'reconcile')
255
+ return { ast: windowAst(entry.baseAst, entry.windowPages), mode: 'replace' };
256
+ return { ast: entry.baseAst, mode: 'replace' };
257
+ }
258
+ /**
259
+ * Fetch a page and fold it in. Deduped on `entry.inflight` (two identical
260
+ * in-flight shapes share ONE api call). The merge clock is stamped at response
261
+ * ARRIVAL, and a response older than a committed mutation (dispatch-epoch <
262
+ * current epoch) is integrated-then-restaled to close the straddle window.
263
+ */
264
+ revalidate(state, entry, intent) {
265
+ if (entry.inflight !== null)
266
+ return entry.inflight;
267
+ const { ast, mode } = this.fetchPlan(entry, intent);
268
+ const dispatchEpoch = state.epoch;
269
+ const p = this.apiFor(entry.scope)
270
+ .query(ast)
271
+ .then((result) => {
272
+ const wire = result.wire;
273
+ const seq = ++this.clock; // ARRIVAL order (§5.7), not dispatch order
274
+ state.entities.integrate(wire, seq);
275
+ const resolvedKey = this.keyOf(state, entry.scope, entry.baseAst);
276
+ const live = state.shapes.rehome(entry, resolvedKey);
277
+ live.inflight = null;
278
+ if (intent === 'more' && mode === 'append')
279
+ live.windowPages++;
280
+ else if (intent === 'fresh')
281
+ live.windowPages = 1;
282
+ integrateShape(live, wire, seq, mode);
283
+ if (dispatchEpoch < state.epoch) {
284
+ // Read straddled a commit: integrate (so a cold shape still gets data
285
+ // and continuous writes can't starve it) THEN restale (so it converges
286
+ // — at quiescence the last refetch's epoch matches). Chosen over
287
+ // discard for those two reasons.
288
+ live.stale = true;
289
+ void this.scheduleReconcile(state, live);
290
+ }
291
+ this.notify(live);
292
+ this.audit(state);
293
+ return wire;
294
+ })
295
+ .catch((error) => {
296
+ const live = state.shapes.get(entry.key) ?? entry;
297
+ live.inflight = null;
298
+ live.error = error;
299
+ live.stale = true;
300
+ live.dirty = true;
301
+ this.emit({ kind: 'refresh', shape: live.key, error });
302
+ this.notify(live);
303
+ throw error;
304
+ });
305
+ entry.inflight = p;
306
+ entry.dirty = true;
307
+ this.notify(entry);
308
+ return p;
309
+ }
310
+ /** Coalesced, concurrency-capped reconcile refetch — resolves when it lands (§5.7). */
311
+ scheduleReconcile(state, entry) {
312
+ return this.scheduler.request(entry, () => this.revalidate(state, this.live(state, entry), 'reconcile').then(() => undefined, () => undefined));
313
+ }
314
+ // ── Snapshot (referential stability) ──────────────────────────────────────────
315
+ /** The live result, rebuilt only when the shape is dirty (stable ref otherwise). */
316
+ snapshot(state, entry) {
317
+ if (!entry.dirty && entry.result !== null)
318
+ return entry.result;
319
+ const mat = materialize(entry, state.entities, state.overlay);
320
+ const result = {
321
+ graph: mat.graph,
322
+ roots: mat.roots,
323
+ nodes: mat.nodes,
324
+ first: mat.first,
325
+ node: mat.first,
326
+ children: mat.children,
327
+ gathered: (alias) => mat.gathered(alias),
328
+ pending: entry.inflight !== null,
329
+ stale: entry.stale,
330
+ error: entry.error,
331
+ live: entry.observers > 0,
332
+ seq: 0,
333
+ more: {
334
+ has: !entry.exhausted && entry.contAst !== null,
335
+ pending: entry.inflight !== null,
336
+ load: async () => {
337
+ const e = this.live(state, entry);
338
+ if (e.exhausted || e.contAst === null)
339
+ return;
340
+ await this.revalidate(state, e, 'more');
341
+ },
342
+ },
343
+ refetch: async () => {
344
+ const e = this.live(state, entry);
345
+ e.stale = true;
346
+ await this.revalidate(state, e, 'reconcile'); // window-preserving
347
+ },
348
+ };
349
+ entry.result = result;
350
+ entry.dirty = false;
351
+ return result;
352
+ }
353
+ notify(entry) {
354
+ entry.dirty = true;
355
+ for (const listener of entry.listeners)
356
+ listener();
357
+ }
358
+ // ── Writes (dispatch → reconcile; display-only overlay) ─────────────────────────
359
+ async mutate(scope, arg, opts) {
360
+ if (typeof arg === 'function') {
361
+ const builder = new PatchBuilderImpl();
362
+ await arg(builder);
363
+ const patch = builder.build();
364
+ const result = await this.applyWrite(scope, patch, opts);
365
+ builder.resolve(result.createdNodes);
366
+ return result;
367
+ }
368
+ return this.applyWrite(scope, arg, opts);
369
+ }
370
+ async update(scope, node, props, opts) {
371
+ const state = this.state(scope);
372
+ const patch = {
373
+ nodes: { update: [{ class: this.classOf(state, node), path: refPathRaw(node), props }] },
374
+ };
375
+ await this.applyWrite(scope, patch, opts);
376
+ }
377
+ async create(scope, cls, at, props, opts) {
378
+ const atRaw = rawOf(at);
379
+ const patch = {
380
+ nodes: { create: [{ class: rawOf(cls), at: atRaw, props: props ?? {} }] },
381
+ };
382
+ const result = await this.applyWrite(scope, patch, opts);
383
+ return mintedId(result.createdNodes, atRaw, 'node');
384
+ }
385
+ async remove(scope, node, opts) {
386
+ const state = this.state(scope);
387
+ const patch = {
388
+ nodes: { delete: [{ class: this.classOf(state, node), path: refPathRaw(node) }] },
389
+ };
390
+ await this.applyWrite(scope, patch, opts);
391
+ }
392
+ async link(scope, edge, opts) {
393
+ const { props, ...identity } = edge;
394
+ const patch = {
395
+ edges: { create: [{ ...rawIdentity(identity), props: props ?? {} }] },
396
+ };
397
+ const result = await this.applyWrite(scope, patch, opts);
398
+ return mintedId(result.createdEdges, edgeKey(identity), 'edge');
399
+ }
400
+ async unlink(scope, edge, opts) {
401
+ const patch = { edges: { delete: [rawIdentity(edge)] } };
402
+ await this.applyWrite(scope, patch, opts);
403
+ }
404
+ /**
405
+ * SAFE MODE write (§5.7): dispatch, then reconcile the touched shapes by
406
+ * refetch. `optimistic` selects the DISPLAY overlay (never folded): `'auto'`
407
+ * (default) overlays the display-safe arms of the authored patch (prop updates
408
+ * on cached nodes + deletes); a function overlays a manually-built patch's
409
+ * display-safe arms; `false` skips the overlay. Creates/links/unlinks are
410
+ * always reconcile-only — no structural membership is ever predicted.
411
+ */
412
+ async applyWrite(scope, patch, opts) {
413
+ const home = this.state(scope);
414
+ const mode = opts?.optimistic ?? 'auto';
415
+ const dispatched = normalizePatch(patch);
416
+ const display = mode === false
417
+ ? null
418
+ : this.displayArms(home, mode === 'auto' ? dispatched : normalizePatch(this.buildPatch(mode)));
419
+ const txn = display !== null && display.has ? this.beginOverlay(home, display.patch) : null;
420
+ this.tick();
421
+ try {
422
+ const result = await this.apiFor(scope).mutate(patch);
423
+ // Commit acked (§5.7 ordering): bump every lens partition's epoch so any
424
+ // read now in flight (dispatched pre-commit) is caught as a straddle.
425
+ for (const part of this.partitionsOf(scope))
426
+ part.epoch++;
427
+ this.reconcile(scope, dispatched, home, txn);
428
+ opts?.onCommit?.(result);
429
+ return result;
430
+ }
431
+ catch (error) {
432
+ if (txn !== null && opts?.rollback !== false)
433
+ this.rollbackOverlay(home, txn);
434
+ opts?.onError?.(error);
435
+ this.emit({ kind: 'mutation', mutationId: txn?.mutationId ?? '(none)', error });
436
+ throw error;
437
+ }
438
+ }
439
+ /**
440
+ * The display-safe subset of a patch: prop updates on ALREADY-CACHED nodes
441
+ * whose every key is unambiguously qualifiable, plus deletes. Creates, edges,
442
+ * uncached/ambiguous updates are dropped (reconcile-only) — a create/link
443
+ * misprediction would be a phantom (forbidden); a delete misprediction is
444
+ * merely loud (§5.7).
445
+ */
446
+ displayArms(state, dispatched) {
447
+ const update = [];
448
+ for (const u of dispatched.nodes.update) {
449
+ const id = state.entities.resolvePathToId(u.path);
450
+ if (id === undefined)
451
+ continue; // not cached → reconcile-only (can't predict blind)
452
+ const ref = state.entities.node(id)?.wire.props;
453
+ const q = qualifyKeys(u.props, ref);
454
+ if (q.unresolved)
455
+ continue; // ambiguous/unqualifiable key → reconcile-only
456
+ update.push({ ...u, props: q.props });
457
+ }
458
+ const del = dispatched.nodes.delete;
459
+ const patch = {
460
+ nodes: { create: [], update, delete: del },
461
+ edges: { create: [], update: [], delete: [] },
462
+ };
463
+ return { patch, has: update.length > 0 || del.length > 0 };
464
+ }
465
+ /** Apply a display overlay unit (no membership mutation) and notify its shapes. */
466
+ beginOverlay(state, displayPatch) {
467
+ const mutationId = `m${++this.mutationSeq}`;
468
+ state.overlay.add({
469
+ mutationId,
470
+ patch: displayPatch,
471
+ provisionalNodes: new Map(),
472
+ provisionalEdges: new Map(),
473
+ });
474
+ const affected = this.affectedEntries(state, displayPatch);
475
+ for (const entry of affected)
476
+ this.notify(entry);
477
+ this.audit(state);
478
+ return { mutationId, patch: displayPatch, affected };
479
+ }
480
+ /** Drop a rolled-back display overlay (the dispatch failed). No membership to undo. */
481
+ rollbackOverlay(state, txn) {
482
+ state.overlay.drop(txn.mutationId);
483
+ for (const entry of txn.affected)
484
+ this.notify(entry);
485
+ this.audit(state);
486
+ }
487
+ /**
488
+ * After a commit ACK: mark every shape the write touched stale and revalidate
489
+ * the observed ones (across all lens partitions). Then HOLD the display overlay
490
+ * until those reconciling refetches land, and DROP it — server truth has
491
+ * replaced the prediction.
492
+ */
493
+ reconcile(scope, dispatched, home, txn) {
494
+ const promises = [];
495
+ for (const part of this.partitionsOf(scope)) {
496
+ for (const p of this.markStaleByPatch(part, dispatched))
497
+ promises.push(p);
498
+ }
499
+ if (txn === null)
500
+ return;
501
+ void Promise.allSettled(promises).then(() => this.dropOverlay(home, txn));
502
+ }
503
+ /**
504
+ * Drop a held overlay once its reconciling refetch has landed, emitting a
505
+ * DIVERGENCE event if what it displayed differs from the refetched truth
506
+ * (a delete whose node the server still returns; an update the server didn't
507
+ * apply). This is the Tier-0 tripwire that a prediction was wrong.
508
+ */
509
+ dropOverlay(state, txn) {
510
+ this.detectDivergence(state, txn);
511
+ state.overlay.drop(txn.mutationId);
512
+ for (const entry of txn.affected)
513
+ this.notify(entry);
514
+ this.audit(state);
515
+ }
516
+ detectDivergence(state, txn) {
517
+ if (this.onError === undefined)
518
+ return;
519
+ const shape = firstKey(txn.affected);
520
+ const eff = state.overlay.effects(state.entities);
521
+ for (const u of txn.patch.nodes.update) {
522
+ const id = state.entities.resolvePathToId(u.path);
523
+ if (id === undefined)
524
+ continue;
525
+ const predicted = state.overlay.effectiveNode(id, state.entities, eff);
526
+ const confirmed = state.entities.node(id)?.wire ?? null;
527
+ for (const key of Object.keys(u.props)) {
528
+ if (!Object.is(predicted?.props[key], confirmed?.props[key])) {
529
+ this.emit({ kind: 'divergence', shape, id: String(id), detail: `update:${key}` });
530
+ break;
531
+ }
532
+ }
533
+ }
534
+ for (const d of txn.patch.nodes.delete) {
535
+ const id = state.entities.resolvePathToId(d.path);
536
+ // The predicted absence diverges iff a reconciled shape still holds it.
537
+ if (id !== undefined && [...txn.affected].some((e) => e.memberSet.has(id))) {
538
+ this.emit({ kind: 'divergence', shape, id: String(id), detail: 'delete:still-present' });
539
+ }
540
+ }
541
+ }
542
+ /** The set of shape entries a patch affects (§5.6 matcher over this partition). */
543
+ affectedEntries(state, patch) {
544
+ const ctx = {
545
+ hasParentClass: HAS_PARENT_CLASS,
546
+ resolve: (raw) => state.entities.resolvePathToId(raw),
547
+ };
548
+ const entries = [...state.shapes.all()];
549
+ const ids = affectedShapes(patch, entries.map((e) => shapeViewOf(e, state.entities)), ctx);
550
+ return new Set(entries.filter((e) => ids.has(e.key)));
551
+ }
552
+ /**
553
+ * Mark shapes a write affects stale and coalesce a reconcile refetch for the
554
+ * observed ones (§5.5) — server-computed effects and every reconcile-only op
555
+ * land here. Unobserved shapes revalidate lazily on their next subscribe/query.
556
+ * Returns the observed shapes' reconcile promises so a held overlay knows when
557
+ * to drop.
558
+ */
559
+ markStaleByPatch(state, patch) {
560
+ const promises = [];
561
+ for (const entry of this.affectedEntries(state, patch)) {
562
+ entry.stale = true;
563
+ this.notify(entry);
564
+ if (entry.observers > 0)
565
+ promises.push(this.scheduleReconcile(state, entry));
566
+ }
567
+ return promises;
568
+ }
569
+ /** Build a wire patch from a manual overlay builder callback. */
570
+ buildPatch(build) {
571
+ const b = new PatchBuilderImpl();
572
+ build(b);
573
+ return b.build();
574
+ }
575
+ /**
576
+ * Open a manual display overlay decoupled from any dispatch (§1.6). The caller
577
+ * dispatches its own call (e.g. a domain method) and then `commit`s (reconcile
578
+ * the touched shapes from the server, drop the display) or `rollback`s.
579
+ */
580
+ openOverlay(scope, build) {
581
+ const state = this.state(scope);
582
+ const display = this.displayArms(state, normalizePatch(this.buildPatch(build)));
583
+ const txn = display.has ? this.beginOverlay(state, display.patch) : null;
584
+ let settled = false;
585
+ return {
586
+ commit: (_result) => {
587
+ if (settled)
588
+ return;
589
+ settled = true;
590
+ for (const part of this.partitionsOf(scope))
591
+ part.epoch++;
592
+ if (txn === null)
593
+ return;
594
+ const promises = [];
595
+ for (const entry of txn.affected) {
596
+ entry.stale = true;
597
+ this.notify(entry);
598
+ if (entry.observers > 0)
599
+ promises.push(this.scheduleReconcile(state, entry));
600
+ }
601
+ void Promise.allSettled(promises).then(() => this.dropOverlay(state, txn));
602
+ },
603
+ rollback: () => {
604
+ if (settled)
605
+ return;
606
+ settled = true;
607
+ if (txn !== null)
608
+ this.rollbackOverlay(state, txn);
609
+ },
610
+ };
611
+ }
612
+ classOf(state, node) {
613
+ if (isNodeValue(node))
614
+ return rawOf(node.class);
615
+ const id = state.entities.resolvePathToId(rawOf(node));
616
+ const cls = id !== undefined ? state.entities.node(id)?.wire.class : undefined;
617
+ if (cls === undefined) {
618
+ throw new Error(`memory: cannot resolve the class of "${rawOf(node)}" for a write — ` +
619
+ 'pass the Node value (not a bare path) for a node not yet in the memory.');
620
+ }
621
+ return cls;
622
+ }
623
+ // ── GC / dev invariants ──────────────────────────────────────────────────────
624
+ /** Amortized housekeeping — sweep GC every `sweepEvery` ops (§5.7, no binding trust). */
625
+ tick() {
626
+ if (++this.opCount % this.sweepEvery === 0)
627
+ this.sweep();
628
+ }
629
+ sweep() {
630
+ const now = this.now();
631
+ for (const state of this.scopes.values()) {
632
+ const expired = [];
633
+ for (const entry of state.shapes.all()) {
634
+ if (entry.observers === 0 &&
635
+ entry.releasedAt !== null &&
636
+ now - entry.releasedAt > this.ttlMs) {
637
+ expired.push(entry);
638
+ }
639
+ }
640
+ for (const entry of expired)
641
+ state.shapes.delete(entry);
642
+ const reach = state.shapes.reachable();
643
+ state.entities.gc(reach.nodes, reach.edges, now, this.tombstoneTtlMs);
644
+ }
645
+ }
646
+ /**
647
+ * Dev-mode invariants after a notify batch (§5.7 loudness): no optimistic id
648
+ * survives in SETTLED confirmed membership, every settled membership id
649
+ * resolves in the entity store, and listener count == observer count. Off by
650
+ * default; violations surface on the event channel as `{ kind: 'invariant' }`.
651
+ */
652
+ audit(state) {
653
+ if (!this.dev)
654
+ return;
655
+ for (const entry of state.shapes.all()) {
656
+ if (entry.listeners.size !== entry.observers) {
657
+ this.emit({
658
+ kind: 'invariant',
659
+ shape: entry.key,
660
+ detail: `listeners ${entry.listeners.size} != observers ${entry.observers}`,
661
+ });
662
+ }
663
+ if (entry.inflight !== null)
664
+ continue; // only settled shapes are checked
665
+ for (const id of entry.nodeMembers) {
666
+ if (String(id).startsWith('optimistic:')) {
667
+ this.emit({
668
+ kind: 'invariant',
669
+ shape: entry.key,
670
+ detail: `optimistic id ${id} in membership`,
671
+ });
672
+ }
673
+ }
674
+ if (state.overlay.size === 0) {
675
+ for (const id of entry.nodeMembers) {
676
+ if (state.entities.node(id) === undefined && !state.entities.isNodeTombstoned(id)) {
677
+ this.emit({
678
+ kind: 'invariant',
679
+ shape: entry.key,
680
+ detail: `dangling membership id ${id}`,
681
+ });
682
+ }
683
+ }
684
+ }
685
+ }
686
+ }
687
+ emit(event) {
688
+ this.onError?.(event);
689
+ }
690
+ }
691
+ // ── COLD placeholder (render-pure getSnapshot) ─────────────────────────────────
692
+ /**
693
+ * The stable snapshot a cold shape returns from `getSnapshot` (§5.7). One frozen
694
+ * value shared across all cold shapes so useSyncExternalStore never tears on a
695
+ * fresh object; subscribe/query create the real entry, after which snapshots are
696
+ * per-entry and memoized.
697
+ */
698
+ const COLD = Object.freeze({
699
+ graph: Graph.fromWire({ nodes: [], edges: [] }),
700
+ roots: [],
701
+ nodes: [],
702
+ first: null,
703
+ node: null,
704
+ children: [],
705
+ gathered: () => ({ nodes: [], first: null }),
706
+ pending: false,
707
+ stale: false,
708
+ error: undefined,
709
+ live: false,
710
+ seq: 0,
711
+ more: Object.freeze({ has: false, pending: false, load: async () => { } }),
712
+ refetch: async () => { },
713
+ });
714
+ // ── ref helpers ────────────────────────────────────────────────────────────────
715
+ function firstKey(entries) {
716
+ for (const e of entries)
717
+ return e.key;
718
+ return '';
719
+ }
720
+ function isNodeValue(x) {
721
+ return typeof x === 'object' && x !== null && 'id' in x && 'class' in x && 'path' in x;
722
+ }
723
+ function refPathRaw(x) {
724
+ return isNodeValue(x) ? rawOf(x.path) : rawOf(x);
725
+ }
726
+ function rawIdentity(edge) {
727
+ return {
728
+ class: rawOf(edge.class),
729
+ source: rawOf(edge.source),
730
+ ...(edge.slug !== undefined ? { slug: edge.slug } : {}),
731
+ target: rawOf(edge.target),
732
+ };
733
+ }
734
+ /**
735
+ * Fold the accumulated window into a reconcile AST (§5.7): each paged expand's
736
+ * limit grows to `baseLimit × windowPages`, so a post-write refetch re-requests
737
+ * everything the window had scrolled to instead of collapsing to page 1. An
738
+ * unbounded (no-limit) expand already covers its whole selection — left as is.
739
+ */
740
+ function windowAst(base, pages) {
741
+ if (pages <= 1 || base.steps === undefined)
742
+ return base;
743
+ const steps = base.steps.map((step) => {
744
+ if (!('expand' in step))
745
+ return step;
746
+ const limit = step.expand.page?.limit;
747
+ if (limit === undefined)
748
+ return step;
749
+ return { expand: { ...step.expand, page: { ...step.expand.page, limit: limit * pages } } };
750
+ });
751
+ return { ...base, steps };
752
+ }
753
+ // ── prop-key canonicalization ────────────────────────────────────────────────
754
+ /** A fully-qualified prop key carries both a domain origin (`:`) and `.property.`. */
755
+ function isQualifiedKey(key) {
756
+ return key.includes(':') && key.includes('.property.');
757
+ }
758
+ /** The prop leaf — the final dot-segment (`title` from `…​.property.title`, or a bare key). */
759
+ function leafOf(key) {
760
+ return key.slice(key.lastIndexOf('.') + 1);
761
+ }
762
+ /**
763
+ * Rewrite non-qualified write keys to the fully-qualified key the reference node
764
+ * already carries with the same leaf. A key with NO match, or with MORE THAN ONE
765
+ * match (two qualified keys sharing a leaf — ambiguous, never first-match-wins,
766
+ * §5.7 7a), passes through and flags `unresolved` so the write is reconcile-only
767
+ * rather than mispredicted.
768
+ */
769
+ function qualifyKeys(props, ref) {
770
+ let unresolved = false;
771
+ const out = {};
772
+ for (const [k, v] of Object.entries(props)) {
773
+ if (isQualifiedKey(k)) {
774
+ out[k] = v;
775
+ continue;
776
+ }
777
+ const leaf = leafOf(k);
778
+ const matches = ref !== undefined
779
+ ? Object.keys(ref).filter((rk) => isQualifiedKey(rk) && leafOf(rk) === leaf)
780
+ : [];
781
+ if (matches.length === 1)
782
+ out[matches[0]] = v;
783
+ else {
784
+ out[k] = v;
785
+ unresolved = true; // 0 matches OR >1 (ambiguous) → reconcile, don't guess
786
+ }
787
+ }
788
+ return { props: out, unresolved };
789
+ }
790
+ //# sourceMappingURL=memory.js.map