@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.
- package/dist/auth/addressing.d.ts +32 -0
- package/dist/auth/addressing.d.ts.map +1 -0
- package/dist/auth/addressing.js +36 -0
- package/dist/auth/addressing.js.map +1 -0
- package/dist/auth/api.d.ts +69 -0
- package/dist/auth/api.d.ts.map +1 -0
- package/dist/auth/api.js +24 -0
- package/dist/auth/api.js.map +1 -0
- package/dist/auth/batch.d.ts +18 -0
- package/dist/auth/batch.d.ts.map +1 -0
- package/dist/auth/batch.js +25 -0
- package/dist/auth/batch.js.map +1 -0
- package/dist/auth/composition.d.ts +27 -0
- package/dist/auth/composition.d.ts.map +1 -0
- package/dist/auth/composition.js +83 -0
- package/dist/auth/composition.js.map +1 -0
- package/dist/auth/index.d.ts +14 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +9 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/minting.d.ts +35 -0
- package/dist/auth/minting.d.ts.map +1 -0
- package/dist/auth/minting.js +35 -0
- package/dist/auth/minting.js.map +1 -0
- package/dist/auth/permissions.d.ts +55 -0
- package/dist/auth/permissions.d.ts.map +1 -0
- package/dist/auth/permissions.js +47 -0
- package/dist/auth/permissions.js.map +1 -0
- package/dist/auth/principal.d.ts +19 -0
- package/dist/auth/principal.d.ts.map +1 -0
- package/dist/auth/principal.js +46 -0
- package/dist/auth/principal.js.map +1 -0
- package/dist/errors/errors.d.ts +53 -0
- package/dist/errors/errors.d.ts.map +1 -1
- package/dist/errors/errors.js +69 -1
- package/dist/errors/errors.js.map +1 -1
- package/dist/errors/guards.d.ts +39 -0
- package/dist/errors/guards.d.ts.map +1 -0
- package/dist/errors/guards.js +127 -0
- package/dist/errors/guards.js.map +1 -0
- package/dist/errors/index.d.ts +4 -2
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/index.js.map +1 -1
- package/dist/graph/api.d.ts +82 -0
- package/dist/graph/api.d.ts.map +1 -0
- package/dist/graph/api.js +64 -0
- package/dist/graph/api.js.map +1 -0
- package/dist/graph/index.d.ts +24 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +20 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/mutate/builder.d.ts +108 -0
- package/dist/graph/mutate/builder.d.ts.map +1 -0
- package/dist/graph/mutate/builder.js +127 -0
- package/dist/graph/mutate/builder.js.map +1 -0
- package/dist/graph/mutate/index.d.ts +5 -0
- package/dist/graph/mutate/index.d.ts.map +1 -0
- package/dist/graph/mutate/index.js +3 -0
- package/dist/graph/mutate/index.js.map +1 -0
- package/dist/graph/mutate/shorthands.d.ts +42 -0
- package/dist/graph/mutate/shorthands.d.ts.map +1 -0
- package/dist/graph/mutate/shorthands.js +65 -0
- package/dist/graph/mutate/shorthands.js.map +1 -0
- package/dist/graph/named.d.ts +38 -0
- package/dist/graph/named.d.ts.map +1 -0
- package/dist/graph/named.js +47 -0
- package/dist/graph/named.js.map +1 -0
- package/dist/graph/query/builder.d.ts +116 -0
- package/dist/graph/query/builder.d.ts.map +1 -0
- package/dist/graph/query/builder.js +136 -0
- package/dist/graph/query/builder.js.map +1 -0
- package/dist/graph/query/edge-ref.d.ts +13 -0
- package/dist/graph/query/edge-ref.d.ts.map +1 -0
- package/dist/graph/query/edge-ref.js +56 -0
- package/dist/graph/query/edge-ref.js.map +1 -0
- package/dist/graph/query/edges.d.ts +10 -0
- package/dist/graph/query/edges.d.ts.map +1 -0
- package/dist/graph/query/edges.js +11 -0
- package/dist/graph/query/edges.js.map +1 -0
- package/dist/graph/query/errors.d.ts +31 -0
- package/dist/graph/query/errors.d.ts.map +1 -0
- package/dist/graph/query/errors.js +28 -0
- package/dist/graph/query/errors.js.map +1 -0
- package/dist/graph/query/filter.d.ts +50 -0
- package/dist/graph/query/filter.d.ts.map +1 -0
- package/dist/graph/query/filter.js +32 -0
- package/dist/graph/query/filter.js.map +1 -0
- package/dist/graph/query/index.d.ts +12 -0
- package/dist/graph/query/index.d.ts.map +1 -0
- package/dist/graph/query/index.js +7 -0
- package/dist/graph/query/index.js.map +1 -0
- package/dist/graph/query/lower.d.ts +30 -0
- package/dist/graph/query/lower.d.ts.map +1 -0
- package/dist/graph/query/lower.js +171 -0
- package/dist/graph/query/lower.js.map +1 -0
- package/dist/graph/query/shorthands.d.ts +34 -0
- package/dist/graph/query/shorthands.d.ts.map +1 -0
- package/dist/graph/query/shorthands.js +71 -0
- package/dist/graph/query/shorthands.js.map +1 -0
- package/dist/graph/query/traverse.d.ts +59 -0
- package/dist/graph/query/traverse.d.ts.map +1 -0
- package/dist/graph/query/traverse.js +144 -0
- package/dist/graph/query/traverse.js.map +1 -0
- package/dist/graph/result/envelope.d.ts +37 -0
- package/dist/graph/result/envelope.d.ts.map +1 -0
- package/dist/graph/result/envelope.js +59 -0
- package/dist/graph/result/envelope.js.map +1 -0
- package/dist/graph/result/index.d.ts +6 -0
- package/dist/graph/result/index.d.ts.map +1 -0
- package/dist/graph/result/index.js +4 -0
- package/dist/graph/result/index.js.map +1 -0
- package/dist/graph/result/next.d.ts +16 -0
- package/dist/graph/result/next.d.ts.map +1 -0
- package/dist/graph/result/next.js +77 -0
- package/dist/graph/result/next.js.map +1 -0
- package/dist/graph/result/paged.d.ts +30 -0
- package/dist/graph/result/paged.d.ts.map +1 -0
- package/dist/graph/result/paged.js +41 -0
- package/dist/graph/result/paged.js.map +1 -0
- package/dist/graph/wire.d.ts +33 -0
- package/dist/graph/wire.d.ts.map +1 -0
- package/dist/graph/wire.js +16 -0
- package/dist/graph/wire.js.map +1 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/schema/bound/binder.d.ts +49 -0
- package/dist/schema/bound/binder.d.ts.map +1 -0
- package/dist/schema/bound/binder.js +192 -0
- package/dist/schema/bound/binder.js.map +1 -0
- package/dist/schema/bound/bound-node.d.ts +89 -0
- package/dist/schema/bound/bound-node.d.ts.map +1 -0
- package/dist/schema/bound/bound-node.js +77 -0
- package/dist/schema/bound/bound-node.js.map +1 -0
- package/dist/schema/bound/index.d.ts +10 -0
- package/dist/schema/bound/index.d.ts.map +1 -0
- package/dist/schema/bound/index.js +5 -0
- package/dist/schema/bound/index.js.map +1 -0
- package/dist/schema/bound/view.d.ts +48 -0
- package/dist/schema/bound/view.d.ts.map +1 -0
- package/dist/schema/bound/view.js +90 -0
- package/dist/schema/bound/view.js.map +1 -0
- package/dist/schema/bound/write-props.d.ts +38 -0
- package/dist/schema/bound/write-props.d.ts.map +1 -0
- package/dist/schema/bound/write-props.js +2 -0
- package/dist/schema/bound/write-props.js.map +1 -0
- package/dist/schema/extract.d.ts +12 -0
- package/dist/schema/extract.d.ts.map +1 -1
- package/dist/schema/extract.js +16 -0
- package/dist/schema/extract.js.map +1 -1
- package/dist/schema/index.d.ts +7 -3
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +4 -2
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/node-proxy.d.ts +9 -0
- package/dist/schema/node-proxy.d.ts.map +1 -1
- package/dist/schema/node-proxy.js +31 -1
- package/dist/schema/node-proxy.js.map +1 -1
- package/dist/{persistence → schema}/persistence.d.ts +1 -22
- package/dist/schema/persistence.d.ts.map +1 -0
- package/dist/schema/persistence.js +2 -0
- package/dist/schema/persistence.js.map +1 -0
- package/dist/schema/proxy-types.d.ts +28 -22
- package/dist/schema/proxy-types.d.ts.map +1 -1
- package/dist/schema/registry.d.ts +1 -2
- package/dist/schema/registry.d.ts.map +1 -1
- package/dist/schema/registry.js +1 -2
- package/dist/schema/registry.js.map +1 -1
- package/dist/session/bound.d.ts.map +1 -1
- package/dist/session/bound.js +15 -2
- package/dist/session/bound.js.map +1 -1
- package/dist/session/client-session.d.ts +1 -1
- package/dist/session/client-session.d.ts.map +1 -1
- package/dist/session/client-session.js +5 -5
- package/dist/session/client-session.js.map +1 -1
- package/dist/session/config.d.ts +8 -4
- package/dist/session/config.d.ts.map +1 -1
- package/dist/{delegation → session/delegation}/cache.d.ts +3 -4
- package/dist/session/delegation/cache.d.ts.map +1 -0
- package/dist/session/delegation/cache.js.map +1 -0
- package/dist/{delegation → session/delegation}/errors.d.ts +1 -1
- package/dist/session/delegation/errors.d.ts.map +1 -0
- package/dist/{delegation → session/delegation}/errors.js +1 -1
- package/dist/session/delegation/errors.js.map +1 -0
- package/dist/session/delegation/policy.d.ts.map +1 -0
- package/dist/{delegation → session/delegation}/policy.js +1 -1
- package/dist/session/delegation/policy.js.map +1 -0
- package/dist/session/delegation-mint.d.ts +13 -0
- package/dist/session/delegation-mint.d.ts.map +1 -0
- package/dist/session/delegation-mint.js +31 -0
- package/dist/session/delegation-mint.js.map +1 -0
- package/dist/session/errors/index.d.ts +2 -2
- package/dist/session/errors/index.d.ts.map +1 -1
- package/dist/session/errors/index.js +2 -2
- package/dist/session/errors/index.js.map +1 -1
- package/dist/session/helpers.js +2 -2
- package/dist/session/helpers.js.map +1 -1
- package/dist/session/index.d.ts +9 -1
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +6 -1
- package/dist/session/index.js.map +1 -1
- package/dist/{redirect → session/redirect}/errors.d.ts +1 -1
- package/dist/session/redirect/errors.d.ts.map +1 -0
- package/dist/{redirect → session/redirect}/errors.js +1 -1
- package/dist/session/redirect/errors.js.map +1 -0
- package/dist/session/redirect/policy.d.ts.map +1 -0
- package/dist/{redirect → session/redirect}/policy.js +1 -1
- package/dist/session/redirect/policy.js.map +1 -0
- package/dist/session/types.d.ts +13 -2
- package/dist/session/types.d.ts.map +1 -1
- package/dist/store/canonicalize.d.ts +44 -0
- package/dist/store/canonicalize.d.ts.map +1 -0
- package/dist/store/canonicalize.js +0 -0
- package/dist/store/canonicalize.js.map +1 -0
- package/dist/store/entities.d.ts +81 -0
- package/dist/store/entities.d.ts.map +1 -0
- package/dist/store/entities.js +202 -0
- package/dist/store/entities.js.map +1 -0
- package/dist/store/index.d.ts +26 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +19 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/invalidate.d.ts +66 -0
- package/dist/store/invalidate.d.ts.map +1 -0
- package/dist/store/invalidate.js +117 -0
- package/dist/store/invalidate.js.map +1 -0
- package/dist/store/memory.d.ts +97 -0
- package/dist/store/memory.d.ts.map +1 -0
- package/dist/store/memory.js +790 -0
- package/dist/store/memory.js.map +1 -0
- package/dist/store/overlay.d.ts +118 -0
- package/dist/store/overlay.d.ts.map +1 -0
- package/dist/store/overlay.js +237 -0
- package/dist/store/overlay.js.map +1 -0
- package/dist/store/schedule.d.ts +31 -0
- package/dist/store/schedule.d.ts.map +1 -0
- package/dist/store/schedule.js +76 -0
- package/dist/store/schedule.js.map +1 -0
- package/dist/store/shapes.d.ts +92 -0
- package/dist/store/shapes.d.ts.map +1 -0
- package/dist/store/shapes.js +369 -0
- package/dist/store/shapes.js.map +1 -0
- package/dist/store/types.d.ts +168 -0
- package/dist/store/types.d.ts.map +1 -0
- package/dist/store/types.js +7 -0
- package/dist/store/types.js.map +1 -0
- package/dist/transport/fetch.d.ts +1 -1
- package/dist/transport/fetch.js +1 -1
- package/package.json +16 -32
- package/dist/delegation/cache.d.ts.map +0 -1
- package/dist/delegation/cache.js.map +0 -1
- package/dist/delegation/errors.d.ts.map +0 -1
- package/dist/delegation/errors.js.map +0 -1
- package/dist/delegation/index.d.ts +0 -6
- package/dist/delegation/index.d.ts.map +0 -1
- package/dist/delegation/index.js +0 -4
- package/dist/delegation/index.js.map +0 -1
- package/dist/delegation/policy.d.ts.map +0 -1
- package/dist/delegation/policy.js.map +0 -1
- package/dist/persistence/index.d.ts +0 -3
- package/dist/persistence/index.d.ts.map +0 -1
- package/dist/persistence/index.js +0 -2
- package/dist/persistence/index.js.map +0 -1
- package/dist/persistence/persistence.d.ts.map +0 -1
- package/dist/persistence/persistence.js +0 -32
- package/dist/persistence/persistence.js.map +0 -1
- package/dist/redirect/errors.d.ts.map +0 -1
- package/dist/redirect/errors.js.map +0 -1
- package/dist/redirect/index.d.ts +0 -4
- package/dist/redirect/index.d.ts.map +0 -1
- package/dist/redirect/index.js +0 -3
- package/dist/redirect/index.js.map +0 -1
- package/dist/redirect/policy.d.ts.map +0 -1
- package/dist/redirect/policy.js.map +0 -1
- /package/dist/{delegation → session/delegation}/cache.js +0 -0
- /package/dist/{delegation → session/delegation}/policy.d.ts +0 -0
- /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
|