instar 1.3.522 → 1.3.524

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 (48) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +56 -0
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +13 -1
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/CredentialIdentityOracle.d.ts +59 -0
  8. package/dist/core/CredentialIdentityOracle.d.ts.map +1 -0
  9. package/dist/core/CredentialIdentityOracle.js +89 -0
  10. package/dist/core/CredentialIdentityOracle.js.map +1 -0
  11. package/dist/core/CredentialLocationLedger.d.ts +187 -0
  12. package/dist/core/CredentialLocationLedger.d.ts.map +1 -0
  13. package/dist/core/CredentialLocationLedger.js +351 -0
  14. package/dist/core/CredentialLocationLedger.js.map +1 -0
  15. package/dist/core/CredentialWriteFunnel.d.ts +77 -0
  16. package/dist/core/CredentialWriteFunnel.d.ts.map +1 -0
  17. package/dist/core/CredentialWriteFunnel.js +128 -0
  18. package/dist/core/CredentialWriteFunnel.js.map +1 -0
  19. package/dist/core/MeshRpc.d.ts +5 -0
  20. package/dist/core/MeshRpc.d.ts.map +1 -1
  21. package/dist/core/MeshRpc.js +8 -0
  22. package/dist/core/MeshRpc.js.map +1 -1
  23. package/dist/core/StoreSnapshot.d.ts +455 -0
  24. package/dist/core/StoreSnapshot.d.ts.map +1 -0
  25. package/dist/core/StoreSnapshot.js +786 -0
  26. package/dist/core/StoreSnapshot.js.map +1 -0
  27. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  28. package/dist/core/devGatedFeatures.js +5 -0
  29. package/dist/core/devGatedFeatures.js.map +1 -1
  30. package/dist/core/stateSyncConfig.js +1 -1
  31. package/dist/core/stateSyncConfig.js.map +1 -1
  32. package/dist/core/storeSnapshotBuild.worker.d.ts +2 -0
  33. package/dist/core/storeSnapshotBuild.worker.d.ts.map +1 -0
  34. package/dist/core/storeSnapshotBuild.worker.js +32 -0
  35. package/dist/core/storeSnapshotBuild.worker.js.map +1 -0
  36. package/dist/core/types.d.ts +35 -0
  37. package/dist/core/types.d.ts.map +1 -1
  38. package/dist/core/types.js.map +1 -1
  39. package/package.json +1 -1
  40. package/scripts/lint-no-direct-llm-http.js +6 -0
  41. package/src/data/builtin-manifest.json +2 -2
  42. package/upgrades/1.3.523.md +31 -0
  43. package/upgrades/1.3.524.md +31 -0
  44. package/upgrades/side-effects/hlc-step3-snapshot-tail.md +133 -0
  45. package/upgrades/side-effects/live-credential-repointing-increment-a-foundation.md +75 -0
  46. package/upgrades/side-effects/live-credential-repointing-increment-a-funnel-primitive.md +66 -0
  47. package/upgrades/side-effects/live-credential-repointing-increment-a-ledger.md +71 -0
  48. package/upgrades/side-effects/live-credential-repointing-increment-a-oracle.md +68 -0
@@ -0,0 +1,786 @@
1
+ /**
2
+ * StoreSnapshot — single-origin snapshot-then-tail machinery (WS2 replicated-store
3
+ * foundation, Component 4 / build-order step 3).
4
+ *
5
+ * Spec: docs/specs/multi-machine-replicated-store-foundation.md §6 (snapshot-then-
6
+ * tail), §6.1 (single-origin anti-forgery), §6.2 (snapshot format + watermark
7
+ * VECTOR), §6.3 (the cutover apply path), §6.4 (HLC demoted to SECONDARY dedup),
8
+ * §6.5 (tombstone high-water seed), §6.6 (why a per-(origin,kind) seq-watermark
9
+ * vector, not a scalar), §8.2 (snapshot-cache fixed ceiling).
10
+ *
11
+ * GENERIC foundation machinery ONLY — there is NO concrete store kind here
12
+ * (preferences/relationships are later consumers, WS2.1+). Everything ships DARK
13
+ * behind `multiMachine.stateSync.<store>.enabled` (default false); a single-machine
14
+ * agent (no peers to pull from) is a strict no-op.
15
+ *
16
+ * This module is PURE LOGIC. The 67MB-class whole-store materialization happens OFF
17
+ * the event loop in `storeSnapshotBuild.worker.ts` (the instar#1069 requirement,
18
+ * mirroring CartographerSweepEngine / cartographerDetect.worker.ts) — this file
19
+ * exports the pure `materializeSnapshot()` the worker dispatches to, plus the
20
+ * non-worker cache/breaker/cutover seams that run on the main thread but do only
21
+ * bounded, O(records-in-one-batch) work. No fs, no Date directly, no network.
22
+ *
23
+ * THE CORRECTNESS BORROW (§6.3): the whole no-gap/no-double-apply guarantee is
24
+ * inherited from the EXISTING seq-contiguous transport. This module:
25
+ * 1. builds a single-origin snapshot (origin === serving machine, §6.1),
26
+ * 2. computes a per-(origin,kind) seq-watermark VECTOR from the entries that
27
+ * actually materialized (§6.2 — it cannot lie),
28
+ * 3. seeds `PeerMeta.kinds[kind].lastHeldSeq = snapshotSeq` (§6.3 step 3),
29
+ * 4. then TAILS via the UNCHANGED `buildServeBatch(kind, snapshotSeq, M)` — the
30
+ * existing applier seq-contiguity (lastHeldSeq+1) does the gap/dup work.
31
+ * There is NO new gap-detection here. HLC is a SECONDARY dedup hint only (§6.4).
32
+ */
33
+ import { Worker } from 'node:worker_threads';
34
+ import { HybridLogicalClock, coerceHlc, serializeHlcKey, } from './HybridLogicalClock.js';
35
+ // ───────────────────────────────────────────────────────────────────────────
36
+ // Pure materializer (§6.1 + §6.2) — dispatched by the worker, OFF the event loop
37
+ // ───────────────────────────────────────────────────────────────────────────
38
+ /** Defensive re-narrow of one entry's envelope fields. Returns null to drop. */
39
+ function narrowEnvelope(data) {
40
+ const recordKey = data.recordKey;
41
+ if (typeof recordKey !== 'string' || recordKey.length === 0)
42
+ return null;
43
+ const op = data.op;
44
+ if (op !== 'put' && op !== 'delete')
45
+ return null;
46
+ const origin = data.origin;
47
+ if (typeof origin !== 'string' || origin.length === 0)
48
+ return null;
49
+ let hlc;
50
+ try {
51
+ hlc = coerceHlc(data.hlc);
52
+ }
53
+ catch {
54
+ // @silent-fallback-ok: a malformed hlc on an entry that already passed the
55
+ // applier's schema is a corrupt on-disk row; the materializer DROPS it
56
+ // (counted as malformedDropped + surfaced in the result) rather than crashing
57
+ // the whole snapshot build. The drop is the safe direction — a record with no
58
+ // total-order position cannot participate in an HLC-max merge.
59
+ return null;
60
+ }
61
+ return { recordKey, hlc, op, origin };
62
+ }
63
+ /**
64
+ * Materialize a single-origin snapshot (§6.1 + §6.2). PURE — no I/O, no Date, no
65
+ * network. This is the function the worker dispatches to (the heavy walk runs on
66
+ * the worker thread).
67
+ *
68
+ * Algorithm:
69
+ * 1. For every contributing kind, fold the own-stream entries into a per-recordKey
70
+ * LATEST record (HLC-max via HybridLogicalClock.compare). A cross-origin entry
71
+ * (`entry.machine !== origin`) is DROPPED + counted — the §6.1 anti-forgery
72
+ * invariant enforced at materialization, not trusted to the caller.
73
+ * 2. Track the per-(origin,kind) `snapshotSeq` = the highest origin-authored
74
+ * `entry.seq` reflected (§6.2 — computed from what materialized, cannot lie).
75
+ * 3. Track the per-recordKey delete high-water (§6.5) for the seed.
76
+ * 4. Emit the materialized set (latest per key, deletes tombstoned) + the
77
+ * watermark vector + the secondary maxHlc.
78
+ *
79
+ * Boundedness (instar#1069): the per-key fold is O(entries); the result is the
80
+ * live key count (≤ entries). `maxSnapshotBytes` deterministically truncates a
81
+ * pathologically-large materialization (highest-seq-first kept).
82
+ */
83
+ export function materializeSnapshot(input) {
84
+ const { store, origin, entriesByKind } = input;
85
+ let crossOriginDropped = 0;
86
+ let malformedDropped = 0;
87
+ // recordKey → the winning (highest-HLC) record so far, plus the kind it rode +
88
+ // the seq it came from (for the watermark vector + the delete high-water).
89
+ const winners = new Map();
90
+ const kindWatermarks = {};
91
+ const deleteWatermarks = {};
92
+ let maxHlc = null;
93
+ for (const [kind, entries] of Object.entries(entriesByKind)) {
94
+ let snapshotSeq = 0;
95
+ for (const entry of entries) {
96
+ // §6.1 ANTI-FORGERY: a single-origin snapshot serves ONLY records the
97
+ // serving machine authored. A cross-origin entry is dropped + counted —
98
+ // never materialized into another origin's snapshot.
99
+ if (entry.machine !== origin) {
100
+ crossOriginDropped++;
101
+ continue;
102
+ }
103
+ const env = narrowEnvelope(entry.data);
104
+ if (env === null) {
105
+ malformedDropped++;
106
+ continue;
107
+ }
108
+ // origin field must also equal the serving machine (defense-in-depth: the
109
+ // envelope `origin` is authoritative, but a row whose data.origin disagrees
110
+ // with entry.machine is corrupt — drop it).
111
+ if (env.origin !== origin) {
112
+ crossOriginDropped++;
113
+ continue;
114
+ }
115
+ // §6.2 watermark: the highest origin-authored seq reflected for this kind.
116
+ if (typeof entry.seq === 'number' && Number.isFinite(entry.seq) && entry.seq > snapshotSeq) {
117
+ snapshotSeq = entry.seq;
118
+ }
119
+ // Track the global max HLC (secondary dedup hint, §6.4).
120
+ if (maxHlc === null || HybridLogicalClock.compare(env.hlc, maxHlc) > 0) {
121
+ maxHlc = env.hlc;
122
+ }
123
+ // Store-specific data = the entry data minus the reserved envelope fields.
124
+ const storeData = stripEnvelopeFields(entry.data);
125
+ const rec = {
126
+ recordKey: env.recordKey,
127
+ hlc: env.hlc,
128
+ op: env.op,
129
+ origin,
130
+ data: storeData,
131
+ };
132
+ const prior = winners.get(env.recordKey);
133
+ if (!prior || HybridLogicalClock.compare(env.hlc, prior.rec.hlc) > 0) {
134
+ winners.set(env.recordKey, { rec });
135
+ }
136
+ // §6.5 delete high-water seed: per key, the highest-HLC delete reflected.
137
+ if (env.op === 'delete') {
138
+ const dw = deleteWatermarks[env.recordKey];
139
+ if (!dw || HybridLogicalClock.compare(env.hlc, dw) > 0) {
140
+ deleteWatermarks[env.recordKey] = env.hlc;
141
+ }
142
+ }
143
+ }
144
+ kindWatermarks[kind] = { snapshotSeq };
145
+ }
146
+ // A delete-watermark entry is only meaningful while the WINNING record for that
147
+ // key is still the tombstone (a later put for the same key — a legitimate
148
+ // re-create — supersedes it and clears the high-water seed; the always-on
149
+ // receiver guard re-derives the live floor, §6.5 guard 1). Clear a seed whose
150
+ // winner is now a put with a strictly-greater HLC.
151
+ for (const [key, dw] of Object.entries(deleteWatermarks)) {
152
+ const w = winners.get(key);
153
+ if (w && w.rec.op === 'put' && HybridLogicalClock.compare(w.rec.hlc, dw) > 0) {
154
+ delete deleteWatermarks[key];
155
+ }
156
+ }
157
+ // Emit the materialized records (deterministic order: by recordKey for stable
158
+ // serialization → stable cache key bytes).
159
+ let records = [...winners.values()]
160
+ .map((w) => w.rec)
161
+ .sort((a, b) => (a.recordKey < b.recordKey ? -1 : a.recordKey > b.recordKey ? 1 : 0));
162
+ // Deterministic byte-bounded truncation (instar#1069): if the materialized set
163
+ // exceeds maxSnapshotBytes, keep the highest-HLC records first and FLAG truncated.
164
+ // We deliberately keep the FULL watermark (we do NOT lower snapshotSeq) — lowering
165
+ // it to match the kept records is impossible to do correctly across a multi-kind
166
+ // vector. Instead, `truncated` is a HARD REFUSAL signal, NOT a "partial is fine"
167
+ // hint: StoreSnapshotEngine.serveSnapshot returns `build-truncated` (never caches
168
+ // or serves it) and applySnapshotCutover THROWS on a truncated snapshot — so a
169
+ // truncated build can never seed `lastHeldSeq` past the dropped records (the silent
170
+ // sub-watermark gap trap). The caller falls back to a from-genesis tail; the real
171
+ // fix is a store whose materialized state fits its bound (§8 retention).
172
+ let truncated = false;
173
+ const cap = input.maxSnapshotBytes ?? 0;
174
+ if (cap > 0) {
175
+ const full = Buffer.byteLength(JSON.stringify(records), 'utf-8');
176
+ if (full > cap) {
177
+ truncated = true;
178
+ // Keep highest-HLC records until the cap; the rest are excluded from THIS
179
+ // build. The caller treats `truncated` as "this store is too large to
180
+ // snapshot under the current cap" — a bounded, surfaced condition, never a
181
+ // silent partial.
182
+ const byHlcDesc = [...records].sort((a, b) => HybridLogicalClock.compare(b.hlc, a.hlc));
183
+ const kept = [];
184
+ let bytes = 2; // "[]"
185
+ for (const r of byHlcDesc) {
186
+ const rb = Buffer.byteLength(JSON.stringify(r), 'utf-8') + 1;
187
+ if (kept.length > 0 && bytes + rb > cap)
188
+ break;
189
+ kept.push(r);
190
+ bytes += rb;
191
+ }
192
+ records = kept.sort((a, b) => (a.recordKey < b.recordKey ? -1 : a.recordKey > b.recordKey ? 1 : 0));
193
+ }
194
+ }
195
+ const watermark = {
196
+ origin,
197
+ kinds: kindWatermarks,
198
+ maxHlc: maxHlc ?? { physical: 0, logical: 0, node: origin },
199
+ };
200
+ const sizeBytes = Buffer.byteLength(JSON.stringify(records), 'utf-8');
201
+ const snapshot = {
202
+ store,
203
+ origin,
204
+ records,
205
+ watermark,
206
+ deleteWatermarks,
207
+ sizeBytes,
208
+ truncated,
209
+ };
210
+ return { snapshot, crossOriginDropped, malformedDropped, truncated };
211
+ }
212
+ /** The reserved replicated-record envelope field names (kept local to avoid a
213
+ * cyclic import with ReplicatedRecordEnvelope; asserted equal by a unit test). */
214
+ const RESERVED_ENVELOPE_FIELDS = ['recordKey', 'hlc', 'op', 'origin', 'observed'];
215
+ /** Return the store-specific portion of a journal `data` (envelope fields stripped). */
216
+ function stripEnvelopeFields(data) {
217
+ const reserved = new Set(RESERVED_ENVELOPE_FIELDS);
218
+ const out = {};
219
+ for (const [k, v] of Object.entries(data)) {
220
+ if (!reserved.has(k))
221
+ out[k] = v;
222
+ }
223
+ return out;
224
+ }
225
+ // ───────────────────────────────────────────────────────────────────────────
226
+ // Snapshot cache — a FIXED-ceiling LRU ring (§8.2)
227
+ // ───────────────────────────────────────────────────────────────────────────
228
+ /** A cache key (§8.2): keyed by (origin, store, maxHlc). Deterministic string. */
229
+ export function snapshotCacheKey(origin, store, maxHlc) {
230
+ return `${origin}${store}${serializeHlcKey(maxHlc)}`;
231
+ }
232
+ /**
233
+ * The snapshot cache — a FIXED-ceiling ring (§8.2), NOT pool-size-scaled. Bounded
234
+ * by BOTH `maxCachedSnapshots` (count) AND `maxCacheBytes` (bytes), whichever
235
+ * binds first; LRU eviction with a monotonic `cacheLossCounter` (mirroring the
236
+ * quarantine ring's lossCounter). An evicted snapshot is just rebuilt on next
237
+ * demand (breaker-gated, §6.3) — eviction is a recompute, never a correctness
238
+ * loss; the counter makes the recompute visible in degradation.
239
+ *
240
+ * A stale entry (its source stream advanced past the cached maxHlc) is dropped on
241
+ * the next put for the same (origin, store) — the cache key embeds maxHlc, so a
242
+ * fresher build is a DIFFERENT key; `invalidateOlder` drops the superseded ones.
243
+ */
244
+ export class SnapshotCache {
245
+ maxCount;
246
+ maxBytes;
247
+ entries = new Map();
248
+ clock = 0;
249
+ bytes = 0;
250
+ _cacheLossCounter = 0;
251
+ constructor(opts) {
252
+ if (!(opts.maxCachedSnapshots > 0))
253
+ throw new Error('SnapshotCache: maxCachedSnapshots must be > 0');
254
+ if (!(opts.maxCacheBytes > 0))
255
+ throw new Error('SnapshotCache: maxCacheBytes must be > 0');
256
+ this.maxCount = Math.floor(opts.maxCachedSnapshots);
257
+ this.maxBytes = Math.floor(opts.maxCacheBytes);
258
+ }
259
+ /** Monotonic count of LRU evictions (§8.2 — surfaced in degradation). */
260
+ get cacheLossCounter() {
261
+ return this._cacheLossCounter;
262
+ }
263
+ /** Current live entry count. */
264
+ get size() {
265
+ return this.entries.size;
266
+ }
267
+ /** Current total cached bytes. */
268
+ get byteSize() {
269
+ return this.bytes;
270
+ }
271
+ /** Get a cached snapshot by key (refreshes its LRU position). */
272
+ get(key) {
273
+ const e = this.entries.get(key);
274
+ if (!e)
275
+ return undefined;
276
+ e.lastUsed = ++this.clock;
277
+ return e.snapshot;
278
+ }
279
+ /**
280
+ * Put a freshly-built snapshot. Drops any STALE entry for the same (origin,
281
+ * store) whose maxHlc is older than this one (the source stream advanced past
282
+ * it, §8.2) — those are superseded, not LRU-evicted (no lossCounter bump). Then
283
+ * enforces the count + byte ceilings via LRU eviction (lossCounter bumped).
284
+ */
285
+ put(snapshot) {
286
+ const key = snapshotCacheKey(snapshot.origin, snapshot.store, snapshot.watermark.maxHlc);
287
+ // Idempotent re-put: replace in place (refresh bytes + LRU).
288
+ const existing = this.entries.get(key);
289
+ if (existing) {
290
+ this.bytes -= existing.snapshot.sizeBytes;
291
+ existing.snapshot = snapshot;
292
+ existing.lastUsed = ++this.clock;
293
+ this.bytes += snapshot.sizeBytes;
294
+ }
295
+ else {
296
+ // Drop superseded entries for the same (origin, store) with an OLDER maxHlc.
297
+ this.invalidateOlder(snapshot);
298
+ this.entries.set(key, { key, snapshot, lastUsed: ++this.clock });
299
+ this.bytes += snapshot.sizeBytes;
300
+ }
301
+ this.evictToBounds();
302
+ }
303
+ /** Drop cached entries for the same (origin, store) with a strictly-older maxHlc. */
304
+ invalidateOlder(fresh) {
305
+ for (const [k, e] of this.entries) {
306
+ if (e.snapshot.origin === fresh.origin && e.snapshot.store === fresh.store) {
307
+ if (HybridLogicalClock.compare(e.snapshot.watermark.maxHlc, fresh.watermark.maxHlc) < 0) {
308
+ this.bytes -= e.snapshot.sizeBytes;
309
+ this.entries.delete(k);
310
+ // NOT a loss — superseded by a fresher build for the same target, not
311
+ // an LRU eviction. A subsequent demand uses the fresher entry.
312
+ }
313
+ }
314
+ }
315
+ }
316
+ /** Evict LRU entries until BOTH the count and byte ceilings hold (§8.2). */
317
+ evictToBounds() {
318
+ while (this.entries.size > this.maxCount || this.bytes > this.maxBytes) {
319
+ // Find the LRU entry.
320
+ let lru = null;
321
+ for (const e of this.entries.values()) {
322
+ if (lru === null || e.lastUsed < lru.lastUsed)
323
+ lru = e;
324
+ }
325
+ if (lru === null)
326
+ break;
327
+ this.bytes -= lru.snapshot.sizeBytes;
328
+ this.entries.delete(lru.key);
329
+ this._cacheLossCounter++;
330
+ }
331
+ }
332
+ /** Iterate cached snapshots for a given (origin, store) — bounded by maxCount.
333
+ * Does NOT touch LRU (a read-only scan; the caller touches LRU on a real serve). */
334
+ *entriesFor(origin, store) {
335
+ for (const e of this.entries.values()) {
336
+ if (e.snapshot.origin === origin && e.snapshot.store === store)
337
+ yield e.snapshot;
338
+ }
339
+ }
340
+ /** Drop ALL cached entries for an origin (the §7.4 rollback-unmerge hook). */
341
+ dropOrigin(origin) {
342
+ for (const [k, e] of this.entries) {
343
+ if (e.snapshot.origin === origin) {
344
+ this.bytes -= e.snapshot.sizeBytes;
345
+ this.entries.delete(k);
346
+ }
347
+ }
348
+ }
349
+ }
350
+ /**
351
+ * Per-peer snapshot-build-frequency breaker (§6.3). Prevents rebuild storms from a
352
+ * flapping peer: within `minRebuildIntervalMs` the cached snapshot is served; more
353
+ * than `maxRebuildsPerWindow` actual rebuilds in `windowMs` trips a breaker that
354
+ * stays open for `cooldownMs`. Bounded (No-Unbounded-Loops). PURE-ish: the clock
355
+ * is injected so it is unit-testable across simulated windows.
356
+ *
357
+ * Keyed by (peer, origin, store) so one peer flapping on store A does not throttle
358
+ * its store-B rebuilds, and two peers requesting the same snapshot are independent.
359
+ */
360
+ export class SnapshotRebuildBreaker {
361
+ minIntervalMs;
362
+ maxPerWindow;
363
+ windowMs;
364
+ cooldownMs;
365
+ now;
366
+ /** Per key: the recent rebuild timestamps (bounded by the window) + breaker state.
367
+ * `lastRebuildAt === null` means "never rebuilt" — distinct from a real now()=0
368
+ * timestamp, so the min-interval check is correct even when the clock starts at 0. */
369
+ state = new Map();
370
+ constructor(opts) {
371
+ this.minIntervalMs = opts.minRebuildIntervalMs ?? 30_000;
372
+ this.maxPerWindow = opts.maxRebuildsPerWindow ?? 5;
373
+ this.windowMs = opts.windowMs ?? 5 * 60_000;
374
+ this.cooldownMs = opts.cooldownMs ?? 60_000;
375
+ this.now = opts.now;
376
+ }
377
+ keyOf(peer, origin, store) {
378
+ return `${peer}${origin}${store}`;
379
+ }
380
+ /**
381
+ * Decide whether a fresh rebuild is allowed RIGHT NOW for (peer, origin, store).
382
+ * Returns `{ allow: true }` to build; otherwise a refusal with `serveCache`
383
+ * (true = serve the cached snapshot instead of building). This does NOT record
384
+ * the rebuild — call `recordRebuild()` after an ACTUAL build completes, so a
385
+ * cache-served request never counts against the frequency cap.
386
+ */
387
+ shouldRebuild(peer, origin, store) {
388
+ const key = this.keyOf(peer, origin, store);
389
+ const now = this.now();
390
+ const s = this.state.get(key);
391
+ if (!s)
392
+ return { allow: true };
393
+ // Breaker open?
394
+ if (s.openUntil > now) {
395
+ return { allow: false, reason: 'breaker-open', serveCache: true };
396
+ }
397
+ // Minimum-rebuild window: within it, serve the cache.
398
+ if (s.lastRebuildAt !== null && now - s.lastRebuildAt < this.minIntervalMs) {
399
+ return { allow: false, reason: 'within-min-interval', serveCache: true };
400
+ }
401
+ // Frequency cap: prune the window, then check.
402
+ const recent = s.rebuilds.filter((t) => now - t < this.windowMs);
403
+ if (recent.length >= this.maxPerWindow) {
404
+ // Trip the breaker.
405
+ s.openUntil = now + this.cooldownMs;
406
+ s.rebuilds = recent;
407
+ this.state.set(key, s);
408
+ return { allow: false, reason: 'breaker-open', serveCache: true };
409
+ }
410
+ return { allow: true };
411
+ }
412
+ /** Record that an actual rebuild completed for (peer, origin, store). */
413
+ recordRebuild(peer, origin, store) {
414
+ const key = this.keyOf(peer, origin, store);
415
+ const now = this.now();
416
+ const s = this.state.get(key) ?? { rebuilds: [], openUntil: 0, lastRebuildAt: null };
417
+ s.rebuilds = s.rebuilds.filter((t) => now - t < this.windowMs);
418
+ s.rebuilds.push(now);
419
+ s.lastRebuildAt = now;
420
+ this.state.set(key, s);
421
+ }
422
+ /** Is the breaker currently open for (peer, origin, store)? (for tests/observability). */
423
+ isOpen(peer, origin, store) {
424
+ const s = this.state.get(this.keyOf(peer, origin, store));
425
+ return !!s && s.openUntil > this.now();
426
+ }
427
+ }
428
+ /**
429
+ * Apply a single-origin snapshot at cutover (§6.3 steps 3–5 + §6.4 + §6.5). This
430
+ * is the load-bearing seam: it (a) applies each record via HLC-max merge through
431
+ * the injected applier, (b) seeds the deleted-keys high-water from the snapshot,
432
+ * (c) seeds `lastHeldSeq = snapshotSeq` per contributing kind so the NEXT ordinary
433
+ * tail is already in-contiguity, and (d) uses the §6.4 secondary HLC-identity set
434
+ * as a belt-and-suspenders dedup.
435
+ *
436
+ * It does NOT do the tail — that is the UNCHANGED `buildServeBatch(kind,
437
+ * snapshotSeq, M)` path the caller drives next. There is NO gap-detection here;
438
+ * the seq contiguity already there does the work (§6.3 step 4).
439
+ *
440
+ * Idempotency (§6.3 step 5): re-running is safe — re-seeding the cursor to the
441
+ * same snapshotSeq + re-applying is a per-recordKey HLC-max merge (an
442
+ * already-present newer record is not overwritten) and the seq tail drops
443
+ * at-or-below entries as duplicates.
444
+ *
445
+ * PURE-ish: all state mutation is funnelled through the injected seams.
446
+ */
447
+ export function applySnapshotCutover(snapshot, seams) {
448
+ // STRUCTURAL REFUSAL of a truncated snapshot (the sub-watermark gap trap, §6.3):
449
+ // a truncated snapshot seeds the full `snapshotSeq` but is MISSING records
450
+ // at-or-below it, so seeding `lastHeldSeq = snapshotSeq` + tailing `seq >
451
+ // snapshotSeq` would silently never replay them — a gap the seq-contiguity cannot
452
+ // catch. A truncated snapshot must NEVER be applied; the caller falls back to a
453
+ // from-genesis tail. We throw (a programmer error: serveSnapshot already refuses a
454
+ // truncated build, so a truncated snapshot should never reach the cutover) rather
455
+ // than silently under-seed — the loud, safe direction.
456
+ if (snapshot.truncated) {
457
+ throw new Error(`applySnapshotCutover: refusing a TRUNCATED snapshot for store="${snapshot.store}" origin="${snapshot.origin}" — ` +
458
+ `applying it would seed lastHeldSeq past dropped records and create a silent sub-watermark gap. ` +
459
+ `The caller must fall back to a from-genesis tail (StoreSnapshotEngine.serveSnapshot returns 'build-truncated').`);
460
+ }
461
+ const tally = { applied: 0, dedupSkipped: 0, notWinner: 0 };
462
+ // (b) Seed the deleted-keys high-water FIRST (§6.5) so a put record in the same
463
+ // snapshot that is below a delete high-water is dropped as a resurrection by the
464
+ // applier's guard during step (a).
465
+ for (const [recordKey, hlc] of Object.entries(snapshot.deleteWatermarks)) {
466
+ seams.seedDeleteWatermark(snapshot.store, snapshot.origin, recordKey, hlc);
467
+ }
468
+ // (a) Apply each materialized record via HLC-max merge, with the §6.4 secondary
469
+ // HLC-identity dedup as a belt-and-suspenders net.
470
+ for (const rec of snapshot.records) {
471
+ const isNew = seams.recordHlcIdentity(snapshot.store, rec.recordKey, rec.origin, rec.hlc);
472
+ if (!isNew) {
473
+ tally.dedupSkipped++;
474
+ continue;
475
+ }
476
+ const landed = seams.applySnapshotRecord(snapshot.store, snapshot.origin, rec);
477
+ if (landed)
478
+ tally.applied++;
479
+ else
480
+ tally.notWinner++;
481
+ }
482
+ // (c) Seed lastHeldSeq = snapshotSeq per contributing (origin, kind) stream —
483
+ // the load-bearing cursor placement (§6.3 step 3) so the next tail is contiguous.
484
+ for (const [kind, wm] of Object.entries(snapshot.watermark.kinds)) {
485
+ seams.seedLastHeldSeq(snapshot.origin, kind, wm.snapshotSeq);
486
+ }
487
+ return tally;
488
+ }
489
+ /**
490
+ * Compute the tail request cursor for a contributing (origin, kind) AFTER a
491
+ * cutover (§6.3 step 4). The tail rides the UNCHANGED transport:
492
+ * `buildServeBatch(kind, snapshotSeq, origin)`. This helper is the single place
493
+ * that names the contract — the cursor is the snapshot's per-kind `snapshotSeq`,
494
+ * NOTHING HLC-derived (HLC is demoted to secondary dedup, §6.4).
495
+ */
496
+ export function tailCursorAfterCutover(snapshot, kind) {
497
+ return snapshot.watermark.kinds[kind]?.snapshotSeq ?? 0;
498
+ }
499
+ // ───────────────────────────────────────────────────────────────────────────
500
+ // Wire serialization (the mesh verb carries these) — bounded, validated
501
+ // ───────────────────────────────────────────────────────────────────────────
502
+ /** Validate + narrow an untrusted wire snapshot (the receiver side of the mesh
503
+ * verb). Returns null to REJECT the whole snapshot (single-origin violated, a
504
+ * malformed record, etc.) — the §6.1 anti-forgery + §6.2 format gate at the door.
505
+ * `expectedOrigin` is the AUTHENTICATED sender (the mesh layer proved it); a
506
+ * snapshot whose `origin` or any record `origin` disagrees is rejected wholesale
507
+ * (the cross-origin-snapshot attack, §6.1). */
508
+ export function validateWireSnapshot(raw, expectedOrigin) {
509
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
510
+ return null;
511
+ const o = raw;
512
+ if (typeof o.store !== 'string' || o.store.length === 0)
513
+ return null;
514
+ if (typeof o.origin !== 'string' || o.origin.length === 0)
515
+ return null;
516
+ // §6.1: single-origin — origin MUST equal the authenticated sender.
517
+ if (o.origin !== expectedOrigin)
518
+ return null;
519
+ if (!Array.isArray(o.records))
520
+ return null;
521
+ const records = [];
522
+ for (const r of o.records) {
523
+ if (!r || typeof r !== 'object')
524
+ return null;
525
+ const rr = r;
526
+ if (typeof rr.recordKey !== 'string' || rr.recordKey.length === 0)
527
+ return null;
528
+ if (rr.op !== 'put' && rr.op !== 'delete')
529
+ return null;
530
+ // §6.1 anti-forgery: EVERY record's origin MUST equal the serving machine. A
531
+ // single foreign-origin record rejects the WHOLE snapshot (quarantined as
532
+ // untrusted-origin by the caller) — never landed.
533
+ if (rr.origin !== expectedOrigin)
534
+ return null;
535
+ let hlc;
536
+ try {
537
+ hlc = coerceHlc(rr.hlc);
538
+ }
539
+ catch { /* @silent-fallback-ok: a malformed hlc on an UNTRUSTED wire record REJECTS the whole snapshot (return null → the caller quarantines it as untrusted-origin/malformed, §6.1) — the safe direction; nothing is silently defaulted or applied. */
540
+ return null;
541
+ }
542
+ const data = rr.data && typeof rr.data === 'object' && !Array.isArray(rr.data)
543
+ ? rr.data
544
+ : {};
545
+ records.push({ recordKey: rr.recordKey, hlc, op: rr.op, origin: expectedOrigin, data: stripEnvelopeFields(data) });
546
+ }
547
+ // Watermark.
548
+ const wmRaw = o.watermark;
549
+ if (!wmRaw || typeof wmRaw !== 'object')
550
+ return null;
551
+ const wm = wmRaw;
552
+ if (wm.origin !== expectedOrigin)
553
+ return null;
554
+ const kindsRaw = wm.kinds;
555
+ if (!kindsRaw || typeof kindsRaw !== 'object')
556
+ return null;
557
+ const kinds = {};
558
+ for (const [k, v] of Object.entries(kindsRaw)) {
559
+ const vv = v;
560
+ if (!vv || typeof vv.snapshotSeq !== 'number' || !Number.isFinite(vv.snapshotSeq) || vv.snapshotSeq < 0)
561
+ return null;
562
+ kinds[k] = { snapshotSeq: vv.snapshotSeq };
563
+ }
564
+ let maxHlc;
565
+ try {
566
+ maxHlc = coerceHlc(wm.maxHlc);
567
+ }
568
+ catch { /* @silent-fallback-ok: a malformed watermark maxHlc on an UNTRUSTED wire snapshot REJECTS the whole snapshot (return null) — the safe direction; never a silent default. */
569
+ return null;
570
+ }
571
+ // Delete watermarks.
572
+ const dwRaw = o.deleteWatermarks;
573
+ const deleteWatermarks = {};
574
+ if (dwRaw && typeof dwRaw === 'object' && !Array.isArray(dwRaw)) {
575
+ for (const [k, v] of Object.entries(dwRaw)) {
576
+ try {
577
+ deleteWatermarks[k] = coerceHlc(v);
578
+ }
579
+ catch { /* @silent-fallback-ok: a malformed delete-watermark hlc on an UNTRUSTED wire snapshot REJECTS the whole snapshot (return null) — the safe direction; never a silent default. */
580
+ return null;
581
+ }
582
+ }
583
+ }
584
+ const sizeBytes = Buffer.byteLength(JSON.stringify(records), 'utf-8');
585
+ // Preserve the truncated flag off the wire (a holder that built a truncated
586
+ // snapshot must NOT have served it — serveSnapshot refuses build-truncated — but
587
+ // we carry the flag honestly so applySnapshotCutover's structural refusal is the
588
+ // backstop even against a buggy/old holder that serves one anyway).
589
+ const truncated = o.truncated === true;
590
+ return { store: o.store, origin: expectedOrigin, records, watermark: { origin: expectedOrigin, kinds, maxHlc }, deleteWatermarks, sizeBytes, truncated };
591
+ }
592
+ /**
593
+ * StoreSnapshotEngine — the §6.3 serve orchestrator. For a (peer, origin, store)
594
+ * request it:
595
+ * 1. asks the rebuild breaker whether a fresh build is allowed (a flapping peer
596
+ * is served the cached snapshot; a tripped breaker refuses — §6.3),
597
+ * 2. on a build: loads the own-stream entries (single-origin), runs the
598
+ * materializer OFF THE EVENT LOOP in a worker thread (instar#1069), bounded
599
+ * by `buildTimeoutMs`, caches the result (LRU + cacheLossCounter, §8.2), and
600
+ * records the rebuild against the breaker,
601
+ * 3. on a within-window / breaker-open decision: serves the cached snapshot if
602
+ * present, else refuses (the caller falls back / retries later).
603
+ *
604
+ * The HEAVY work (whole-store materialization) is the worker's; the engine itself
605
+ * does only bounded orchestration on the main thread.
606
+ */
607
+ export class StoreSnapshotEngine {
608
+ cache;
609
+ breaker;
610
+ seams;
611
+ buildTimeoutMs;
612
+ workerHeapMb;
613
+ maxSnapshotBytes;
614
+ log;
615
+ runInline;
616
+ constructor(opts) {
617
+ this.cache = opts.cache;
618
+ this.breaker = opts.breaker;
619
+ this.seams = opts.seams;
620
+ this.buildTimeoutMs = opts.buildTimeoutMs ?? 120_000;
621
+ this.workerHeapMb = opts.workerHeapMb ?? 1536;
622
+ this.maxSnapshotBytes = opts.maxSnapshotBytes ?? 0;
623
+ this.log = opts.log ?? (() => { });
624
+ this.runInline = opts.runInline === true;
625
+ }
626
+ /** The snapshot cache (for observability / the rollback-unmerge dropOrigin hook). */
627
+ getCache() {
628
+ return this.cache;
629
+ }
630
+ /**
631
+ * Serve a single-origin snapshot of (origin, store) to `peer` (§6.3). Builds off
632
+ * the event loop, reuses the cache within the rebuild window, breaker-gated.
633
+ */
634
+ async serveSnapshot(peer, origin, store) {
635
+ const started = this.seams.now();
636
+ const decision = this.breaker.shouldRebuild(peer, origin, store);
637
+ if (!decision.allow) {
638
+ // Serve the cached snapshot if we have one (§6.3 flapping-peer reuse).
639
+ if (decision.serveCache) {
640
+ const cached = this.mostRecentCached(origin, store);
641
+ if (cached) {
642
+ return { ok: true, snapshot: cached, source: 'cache', durationMs: this.seams.now() - started, truncated: false };
643
+ }
644
+ }
645
+ // No cache to serve + a tripped breaker ⇒ refuse (bounded; the caller retries).
646
+ if (decision.reason === 'breaker-open') {
647
+ return { ok: false, reason: 'breaker-open', durationMs: this.seams.now() - started };
648
+ }
649
+ // within-min-interval but no cache yet (first request in the window) — fall
650
+ // through to a build (we have nothing to serve and the breaker is not open).
651
+ }
652
+ const entriesByKind = this.seams.loadOwnEntries(store, origin);
653
+ const total = Object.values(entriesByKind).reduce((n, arr) => n + arr.length, 0);
654
+ if (total === 0) {
655
+ return { ok: false, reason: 'no-entries', durationMs: this.seams.now() - started };
656
+ }
657
+ const input = {
658
+ store,
659
+ origin,
660
+ entriesByKind,
661
+ ...(this.maxSnapshotBytes > 0 ? { maxSnapshotBytes: this.maxSnapshotBytes } : {}),
662
+ };
663
+ const built = await this.runBuild(input);
664
+ if (!built.ok) {
665
+ this.log('snapshot-build-failed', { store, origin, peer, reason: built.reason });
666
+ return { ok: false, reason: built.reason, durationMs: this.seams.now() - started };
667
+ }
668
+ // STRUCTURAL REFUSAL of a truncated build (the sub-watermark gap trap, §6.3):
669
+ // a truncated snapshot would seed lastHeldSeq past dropped records — a silent
670
+ // gap. We do NOT cache it and do NOT serve it; the caller falls back to a
671
+ // from-genesis tail (the complete path). We DO record the rebuild against the
672
+ // breaker (a build happened — its cost is real, and an immediate retry would
673
+ // re-truncate), and surface the condition loudly. The structural answer is the
674
+ // store's per-kind retention bound (§8): a store whose materialized state
675
+ // exceeds maxSnapshotBytes is mis-bounded and must shrink its window, not be
676
+ // served a silent partial.
677
+ if (built.result.truncated) {
678
+ this.breaker.recordRebuild(peer, origin, store);
679
+ this.log('snapshot-build-truncated', {
680
+ store,
681
+ origin,
682
+ peer,
683
+ recordCount: built.result.snapshot.records.length,
684
+ sizeBytes: built.result.snapshot.sizeBytes,
685
+ });
686
+ return { ok: false, reason: 'build-truncated', durationMs: this.seams.now() - started };
687
+ }
688
+ this.cache.put(built.result.snapshot);
689
+ this.breaker.recordRebuild(peer, origin, store);
690
+ if (built.result.crossOriginDropped > 0) {
691
+ this.log('snapshot-build-anomaly', {
692
+ store,
693
+ origin,
694
+ crossOriginDropped: built.result.crossOriginDropped,
695
+ malformedDropped: built.result.malformedDropped,
696
+ });
697
+ }
698
+ return {
699
+ ok: true,
700
+ snapshot: built.result.snapshot,
701
+ source: 'built',
702
+ durationMs: this.seams.now() - started,
703
+ truncated: false,
704
+ };
705
+ }
706
+ /** The most-recent cached snapshot for (origin, store), if any. */
707
+ mostRecentCached(origin, store) {
708
+ // The cache key embeds maxHlc; the cache.invalidateOlder already keeps only the
709
+ // freshest per (origin, store). Scan for a match (bounded by maxCachedSnapshots).
710
+ // We re-build the key from a probe is impossible (we don't know maxHlc), so we
711
+ // scan — the cache holds at most maxCachedSnapshots entries.
712
+ let best;
713
+ // SnapshotCache does not expose iteration; we add a helper there. Use it:
714
+ for (const snap of this.cache.entriesFor(origin, store)) {
715
+ if (!best || HybridLogicalClock.compare(snap.watermark.maxHlc, best.watermark.maxHlc) > 0) {
716
+ best = snap;
717
+ }
718
+ }
719
+ if (best) {
720
+ // Touch its LRU position via a get on the canonical key.
721
+ this.cache.get(snapshotCacheKey(best.origin, best.store, best.watermark.maxHlc));
722
+ }
723
+ return best;
724
+ }
725
+ /** Run the materializer — off the event loop (worker), or inline for tests. */
726
+ async runBuild(input) {
727
+ if (this.runInline) {
728
+ try {
729
+ return { ok: true, result: materializeSnapshot(input) };
730
+ }
731
+ catch {
732
+ return { ok: false, reason: 'build-failed' };
733
+ }
734
+ }
735
+ return this.runWorker(input);
736
+ }
737
+ /** Minimal env allowlist for a spawned worker — NEVER the parent process.env. */
738
+ workerEnv() {
739
+ const env = {};
740
+ for (const k of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'SystemRoot', 'TEMP', 'TMP']) {
741
+ if (process.env[k])
742
+ env[k] = process.env[k];
743
+ }
744
+ return env;
745
+ }
746
+ /** Spawn the build worker, await its single message, bound by buildTimeoutMs. */
747
+ runWorker(input) {
748
+ const heapMb = this.workerHeapMb;
749
+ const timeoutMs = this.buildTimeoutMs;
750
+ return new Promise((resolve) => {
751
+ let worker;
752
+ try {
753
+ const workerUrl = new URL('./storeSnapshotBuild.worker.js', import.meta.url);
754
+ worker = new Worker(workerUrl, {
755
+ workerData: input,
756
+ resourceLimits: { maxOldGenerationSizeMb: heapMb },
757
+ env: this.workerEnv(),
758
+ });
759
+ }
760
+ catch {
761
+ resolve({ ok: false, reason: 'build-failed' });
762
+ return;
763
+ }
764
+ let settled = false;
765
+ const done = (r) => {
766
+ if (settled)
767
+ return;
768
+ settled = true;
769
+ clearTimeout(timer);
770
+ worker.terminate().catch(() => { });
771
+ resolve(r);
772
+ };
773
+ const timer = setTimeout(() => done({ ok: false, reason: 'build-timeout' }), timeoutMs);
774
+ worker.once('message', (msg) => {
775
+ if (msg.ok && msg.result)
776
+ done({ ok: true, result: msg.result });
777
+ else
778
+ done({ ok: false, reason: 'build-failed' });
779
+ });
780
+ worker.once('error', () => done({ ok: false, reason: 'build-failed' }));
781
+ worker.once('exit', () => { if (!settled)
782
+ done({ ok: false, reason: 'build-failed' }); });
783
+ });
784
+ }
785
+ }
786
+ //# sourceMappingURL=StoreSnapshot.js.map