@mmstack/primitives 21.9.3 → 21.10.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/README.md CHANGED
@@ -766,7 +766,8 @@ const profile = persistedStore({ first: '', last: '' }, {
766
766
  `persistedStore` is `store()` + `persist()`. Reach for `persist(store, opt)` directly to add durability to a store you already have — one you also `meshSync`, or a worker-owned store's replica. Persistence is a reader over the op-log, so it composes with the other readers on the same store.
767
767
 
768
768
  ```typescript
769
- import { store, persist, meshSync } from '@mmstack/primitives';
769
+ import { store, persist } from '@mmstack/primitives';
770
+ import { meshSync } from '@mmstack/mesh';
770
771
 
771
772
  const doc = store({ title: '', body: '' });
772
773
  persist(doc, { key: 'draft', store: idbKeyval }); // durable to IndexedDB
@@ -4286,7 +4286,7 @@ function generateOrigin$1() {
4286
4286
  return globalThis.crypto.randomUUID();
4287
4287
  return Math.random().toString(36).substring(2);
4288
4288
  }
4289
- const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4289
+ const isPlainArray$2 = (v) => Array.isArray(v) && !isOpaque(v);
4290
4290
  /**
4291
4291
  * Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
4292
4292
  * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
@@ -4311,7 +4311,7 @@ function diffNode(prev, next, path, ops) {
4311
4311
  }
4312
4312
  return;
4313
4313
  }
4314
- if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4314
+ if (isPlainArray$2(prev) && isPlainArray$2(next)) {
4315
4315
  // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4316
4316
  // change is a whole unit — index attribution lies under insert/remove/reorder
4317
4317
  if (prev.length === next.length) {
@@ -4330,7 +4330,7 @@ function applyAt(container, path, idx, op) {
4330
4330
  const seg = path[idx];
4331
4331
  if (seg === '__proto__')
4332
4332
  return container;
4333
- const base = isPlainArray$1(container)
4333
+ const base = isPlainArray$2(container)
4334
4334
  ? container.slice()
4335
4335
  : isRecord$1(container)
4336
4336
  ? { ...container }
@@ -5024,12 +5024,15 @@ function createHlcClock(now = Date.now) {
5024
5024
  }
5025
5025
 
5026
5026
  /**
5027
- * Wire protocol version. Version 2 ops carry `cites` + `epoch` (the dot-citation register);
5028
- * envelopes from other versions are dropped loudly: an op without citations cannot be merged
5029
- * soundly (it would supersede nothing and its siblings would accumulate forever), so versions
5030
- * are never silently mixed.
5027
+ * Wire protocol version. Version 2 ops carry `cites` + `epoch` (the dot-citation register).
5028
+ * Version 3 changes MATERIALIZATION semantics, not shape: grafts descend through plain arrays
5029
+ * (per-index ops materialize instead of silently dropping) and the documented drop rule is
5030
+ * enforced for non-plain ancestors — so a v2 replica and a v3 replica CANNOT converge on the
5031
+ * same op set, which is exactly what the version fence exists to make loud. Envelopes from
5032
+ * other versions are dropped loudly, and the tab-sync join protocol refuses to pair mismatched
5033
+ * peers: versions are never silently mixed, in shape OR in meaning.
5031
5034
  */
5032
- const OP_PROTO_VERSION = 2;
5035
+ const OP_PROTO_VERSION = 3;
5033
5036
  const CONFLICT_BRAND = '~mmstackConflict';
5034
5037
  function isConflicted(value) {
5035
5038
  return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
@@ -5286,7 +5289,14 @@ const mergeFold = (merge) => {
5286
5289
  return { kind: 'set', value: acc };
5287
5290
  };
5288
5291
  };
5289
- const isContainer = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
5292
+ const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
5293
+ /**
5294
+ * The graft-side container admission. MUST equal `diffNode`'s descent set (plain records and
5295
+ * plain non-opaque arrays) and `applyAt`'s copy set: emission, incremental apply and
5296
+ * checkpoint materialization decide "container or leaf" identically, or a checkpoint-seeded
5297
+ * replica and an incrementally-applied one disagree about the same op set.
5298
+ */
5299
+ const isContainer = (v) => isPlainArray$1(v) || isRecord$1(v);
5290
5300
  /**
5291
5301
  * The unsequenced-topology convergence core: a dot-citation multi-value register per path.
5292
5302
  * An op supersedes exactly the sibling dots it cites; uncited concurrent writes stay live; a
@@ -5345,8 +5355,6 @@ function createConvergingApply(opt) {
5345
5355
  }
5346
5356
  return out.sort((a, b) => a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0);
5347
5357
  };
5348
- // The live siblings a frontier had observed: those that arrived at or below its captured seq.
5349
- // Used by a fork commit so it supersedes only what it saw when it forked, not later writes.
5350
5358
  const liveObserved = (reg, frontier) => {
5351
5359
  const live = liveOf(reg);
5352
5360
  if (!frontier)
@@ -5354,11 +5362,6 @@ function createConvergingApply(opt) {
5354
5362
  const sm = seqs.get(keyOf$1(reg.path));
5355
5363
  return live.filter((s) => (sm?.get(s.origin) ?? 0) <= frontier.seq);
5356
5364
  };
5357
- // JSON of a tuple array, not a separator-joined string: `origin` is a caller-supplied value on a
5358
- // P2P peer, so a naive `origin@p.l#epoch` join lets a crafted origin collide the signatures of two
5359
- // distinct live sets. A collision makes refresh() skip a fold update, and since that skip is
5360
- // arrival-order-sensitive it breaks convergence. JSON.stringify escapes the strings and the array
5361
- // structure is unambiguous, so the signature is injective in the live set.
5362
5365
  const sigOf = (live) => JSON.stringify(live.map((s) => [s.origin, s.hlc.p, s.hlc.l, s.epoch, s.kind]));
5363
5366
  /** Recompute the fold cache; true iff the materialized result meaningfully changed. */
5364
5367
  const refresh = (reg) => {
@@ -5408,9 +5411,6 @@ function createConvergingApply(opt) {
5408
5411
  }
5409
5412
  return true;
5410
5413
  };
5411
- // A lone tombstone is droppable only if nothing else still materializes its key: no live
5412
- // descendant register would resurface, and no live ancestor `set` value still holds it. Mirrors
5413
- // the relay's retention twin so a client that prunes converges with a joiner seeded from the relay.
5414
5414
  const tombstoneDroppable = (key, reg) => {
5415
5415
  for (const [k, other] of registers) {
5416
5416
  if (k === key)
@@ -5438,31 +5438,37 @@ function createConvergingApply(opt) {
5438
5438
  }
5439
5439
  return undefined;
5440
5440
  };
5441
- // graft with the deterministic type-change rule: a graft whose parent location is not a plain
5442
- // record is DROPPED (the register stays intact and resurfaces if the container is restored)
5443
5441
  const graft = (tree, rel, res) => {
5444
5442
  if (!isContainer(tree))
5445
5443
  return tree;
5446
5444
  const head = String(rel[0]);
5445
+ if (head === '__proto__')
5446
+ return tree;
5447
+ const copyOf = () => (isPlainArray$1(tree) ? tree.slice() : { ...tree });
5447
5448
  if (rel.length === 1) {
5448
5449
  if (res.kind === 'delete') {
5449
5450
  if (!Object.hasOwn(tree, head))
5450
5451
  return tree;
5451
- const copy = { ...tree };
5452
+ const copy = copyOf();
5452
5453
  delete copy[head];
5453
5454
  return copy;
5454
5455
  }
5455
- return { ...tree, [head]: res.value };
5456
+ const copy = copyOf();
5457
+ copy[head] = res.value;
5458
+ return copy;
5456
5459
  }
5457
5460
  if (!Object.hasOwn(tree, head)) {
5458
- // vivify an absent middle container so a checkpoint-seeded materialization matches a peer that
5459
- // applied the ops incrementally (incremental apply creates missing parents). A numeric next
5460
- // segment vivifies an array, else an object, mirroring the incremental apply path.
5461
5461
  const vivified = typeof rel[1] === 'number' ? [] : {};
5462
- return { ...tree, [head]: graft(vivified, rel.slice(1), res) };
5462
+ const copy = copyOf();
5463
+ copy[head] = graft(vivified, rel.slice(1), res);
5464
+ return copy;
5463
5465
  }
5464
5466
  const child = graft(tree[head], rel.slice(1), res);
5465
- return child === tree[head] ? tree : { ...tree, [head]: child };
5467
+ if (child === tree[head])
5468
+ return tree;
5469
+ const copy = copyOf();
5470
+ copy[head] = child;
5471
+ return copy;
5466
5472
  };
5467
5473
  /** Would a value at `rel` under `value` materialize, per the graft rules? */
5468
5474
  const graftable = (value, rel) => {
@@ -5564,9 +5570,7 @@ function createConvergingApply(opt) {
5564
5570
  const seq = ++ingestSeq;
5565
5571
  for (const op of env.ops) {
5566
5572
  if (o?.frontier && compareHlc(env.hlc, o.frontier) <= 0)
5567
- continue; // below the pruned horizon
5568
- // a delete or clear at the root has no parent register to abstain to; it can only blank the
5569
- // whole document, and materialize would then disagree with the delta path, so drop it
5573
+ continue;
5570
5574
  if (!op.path.length && op.kind !== 'set')
5571
5575
  continue;
5572
5576
  const reg = regAt(op.path);
@@ -5831,15 +5835,8 @@ function opSync(source, opt) {
5831
5835
  origin,
5832
5836
  });
5833
5837
  const subscribers = new Set();
5834
- // per-origin high-watermark; `versions.get(origin)` IS the local emit counter, so a hydrate/restore
5835
- // that raises our own watermark also advances the next mint — no separate counter to drift out of
5836
- // sync and collide with a version acked before a reboot but dropped from a debounced outbox.
5837
5838
  const versions = new Map();
5838
5839
  const recentLocal = [];
5839
- // highest stability frontier this peer has pruned to. A remote envelope at or below it is a settled
5840
- // straggler (its state is compacted away); re-admitting one could resurrect a value below the
5841
- // frontier, and per-origin version dedup cannot catch a FIRST-CONTACT straggler (no prior entry),
5842
- // so the frontier is the admission gate that closes that hole on the receive path.
5843
5840
  let prunedFrontier;
5844
5841
  const resolvedInjector = opt.driver
5845
5842
  ? null
@@ -5847,17 +5844,6 @@ function opSync(source, opt) {
5847
5844
  const log = opLog(source, opt.driver
5848
5845
  ? { origin, driver: opt.driver }
5849
5846
  : { origin, injector: resolvedInjector });
5850
- // Local envelopes stamped + registered but not yet handed to the transport. A `receive` freezes
5851
- // this peer's pending writes here so it can ingest the remote WITHOUT emitting mid-receive — the
5852
- // synchronous re-entrant emission that used to scramble the relay's commit order. The outbox
5853
- // drains on a LATER tick, so emission always lands outside any receive callstack. Writes made
5854
- // while a drain is still owed queue here too, keeping wire order == version order (else a receiver
5855
- // would dedup the older, still-frozen envelope).
5856
- //
5857
- // Deferral rides an Angular effect, so it arms only on the injector path — the transport
5858
- // topologies (`tabSync`, `meshSync`) that actually re-enter through a relay. A custom `driver`
5859
- // (worker mirror, pure sim, multi-reader) owns its own scheduling and has no such re-entrancy, so
5860
- // it emits synchronously; the freeze-before-observe STAMPING fix below is identical either way.
5861
5847
  const canDefer = !opt.driver;
5862
5848
  const outbox = [];
5863
5849
  let receiving = false;
@@ -5942,11 +5928,6 @@ function opSync(source, opt) {
5942
5928
  opt.onReject?.(env, reason);
5943
5929
  return;
5944
5930
  }
5945
- // a settled straggler at or below the pruned stability frontier: reject it (its state is
5946
- // compacted, re-admitting could resurrect a below-frontier value). All ops in an envelope share
5947
- // its stamp, so the envelope hlc is the dot for every op. The live relay path never delivers a
5948
- // below-frontier op (a lagging client gets a snapshot, not a delta), so this only fires on a
5949
- // stray re-broadcast, e.g. over a P2P/multi-path topology.
5950
5931
  if (prunedFrontier && compareHlc(env.hlc, prunedFrontier) <= 0)
5951
5932
  return;
5952
5933
  const known = versions.get(env.origin);
@@ -6019,10 +6000,6 @@ function opSync(source, opt) {
6019
6000
  },
6020
6001
  hydrate: (state, pending) => {
6021
6002
  log.flush();
6022
- // rebase this origin's uncovered local writes on top. A caller that keeps a durable outbox
6023
- // (meshSync, the worker replica) passes its full unacked set, so a long offline burst larger
6024
- // than the in-memory `recentLocal` cap is never dropped from the rebase; without it, fall back
6025
- // to the recent-local ring.
6026
6003
  const source = pending ?? recentLocal;
6027
6004
  const toReplay = source.filter((e) => e.version > (state.wm?.[e.origin] ?? 0));
6028
6005
  conv.reset();
@@ -6883,6 +6860,9 @@ function storeTabSync(sig, opt, bus, injector) {
6883
6860
  const { unsub, post } = bus.subscribe(opt.id, (msg) => {
6884
6861
  if (!msg || typeof msg !== 'object')
6885
6862
  return;
6863
+ // the envelope lane has its own loud guard in `receive`; join messages are fenced here
6864
+ if (msg.t !== 'env' && msg.proto !== OP_PROTO_VERSION)
6865
+ return;
6886
6866
  switch (msg.t) {
6887
6867
  case 'env':
6888
6868
  if (phase === 'joining')
@@ -6899,8 +6879,8 @@ function storeTabSync(sig, opt, bus, injector) {
6899
6879
  const snap = sync.snapshot();
6900
6880
  const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
6901
6881
  post(covered
6902
- ? { t: 'uptodate', to: msg.from }
6903
- : { t: 'state', to: msg.from, state: snap });
6882
+ ? { t: 'uptodate', proto: OP_PROTO_VERSION, to: msg.from }
6883
+ : { t: 'state', proto: OP_PROTO_VERSION, to: msg.from, state: snap });
6904
6884
  }, Math.random() * jitterMs);
6905
6885
  responseTimers.set(msg.from, timer);
6906
6886
  return;
@@ -6922,7 +6902,7 @@ function storeTabSync(sig, opt, bus, injector) {
6922
6902
  }
6923
6903
  });
6924
6904
  const unsubEnv = sync.subscribe((env) => post({ t: 'env', env }));
6925
- post({ t: 'hello', from: sync.origin, wm: sync.watermark() });
6905
+ post({ t: 'hello', proto: OP_PROTO_VERSION, from: sync.origin, wm: sync.watermark() });
6926
6906
  helloTimer = setTimeout(goLive, helloTimeoutMs);
6927
6907
  injector.get(DestroyRef).onDestroy(() => {
6928
6908
  if (helloTimer !== undefined)