@mmstack/primitives 20.14.3 → 20.15.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
@@ -4244,7 +4244,7 @@ function generateOrigin$1() {
4244
4244
  return globalThis.crypto.randomUUID();
4245
4245
  return Math.random().toString(36).substring(2);
4246
4246
  }
4247
- const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4247
+ const isPlainArray$2 = (v) => Array.isArray(v) && !isOpaque(v);
4248
4248
  /**
4249
4249
  * Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
4250
4250
  * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
@@ -4269,7 +4269,7 @@ function diffNode(prev, next, path, ops) {
4269
4269
  }
4270
4270
  return;
4271
4271
  }
4272
- if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4272
+ if (isPlainArray$2(prev) && isPlainArray$2(next)) {
4273
4273
  // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4274
4274
  // change is a whole unit — index attribution lies under insert/remove/reorder
4275
4275
  if (prev.length === next.length) {
@@ -4288,7 +4288,7 @@ function applyAt(container, path, idx, op) {
4288
4288
  const seg = path[idx];
4289
4289
  if (seg === '__proto__')
4290
4290
  return container;
4291
- const base = isPlainArray$1(container)
4291
+ const base = isPlainArray$2(container)
4292
4292
  ? container.slice()
4293
4293
  : isRecord$1(container)
4294
4294
  ? { ...container }
@@ -5218,12 +5218,15 @@ function evenPositions(n) {
5218
5218
  }
5219
5219
 
5220
5220
  /**
5221
- * Wire protocol version. Version 2 ops carry `cites` + `epoch` (the dot-citation register);
5222
- * envelopes from other versions are dropped loudly: an op without citations cannot be merged
5223
- * soundly (it would supersede nothing and its siblings would accumulate forever), so versions
5224
- * are never silently mixed.
5221
+ * Wire protocol version. Version 2 ops carry `cites` + `epoch` (the dot-citation register).
5222
+ * Version 3 changes MATERIALIZATION semantics, not shape: grafts descend through plain arrays
5223
+ * (per-index ops materialize instead of silently dropping) and the documented drop rule is
5224
+ * enforced for non-plain ancestors — so a v2 replica and a v3 replica CANNOT converge on the
5225
+ * same op set, which is exactly what the version fence exists to make loud. Envelopes from
5226
+ * other versions are dropped loudly, and the tab-sync join protocol refuses to pair mismatched
5227
+ * peers: versions are never silently mixed, in shape OR in meaning.
5225
5228
  */
5226
- const OP_PROTO_VERSION = 2;
5229
+ const OP_PROTO_VERSION = 3;
5227
5230
  const CONFLICT_BRAND = '~mmstackConflict';
5228
5231
  function isConflicted(value) {
5229
5232
  return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
@@ -5480,7 +5483,14 @@ const mergeFold = (merge) => {
5480
5483
  return { kind: 'set', value: acc };
5481
5484
  };
5482
5485
  };
5483
- const isContainer = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
5486
+ const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
5487
+ /**
5488
+ * The graft-side container admission. MUST equal `diffNode`'s descent set (plain records and
5489
+ * plain non-opaque arrays) and `applyAt`'s copy set: emission, incremental apply and
5490
+ * checkpoint materialization decide "container or leaf" identically, or a checkpoint-seeded
5491
+ * replica and an incrementally-applied one disagree about the same op set.
5492
+ */
5493
+ const isContainer = (v) => isPlainArray$1(v) || isRecord$1(v);
5484
5494
  /**
5485
5495
  * The unsequenced-topology convergence core: a dot-citation multi-value register per path.
5486
5496
  * An op supersedes exactly the sibling dots it cites; uncited concurrent writes stay live; a
@@ -5539,8 +5549,6 @@ function createConvergingApply(opt) {
5539
5549
  }
5540
5550
  return out.sort((a, b) => a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0);
5541
5551
  };
5542
- // The live siblings a frontier had observed: those that arrived at or below its captured seq.
5543
- // Used by a fork commit so it supersedes only what it saw when it forked, not later writes.
5544
5552
  const liveObserved = (reg, frontier) => {
5545
5553
  const live = liveOf(reg);
5546
5554
  if (!frontier)
@@ -5548,11 +5556,6 @@ function createConvergingApply(opt) {
5548
5556
  const sm = seqs.get(keyOf$1(reg.path));
5549
5557
  return live.filter((s) => (sm?.get(s.origin) ?? 0) <= frontier.seq);
5550
5558
  };
5551
- // JSON of a tuple array, not a separator-joined string: `origin` is a caller-supplied value on a
5552
- // P2P peer, so a naive `origin@p.l#epoch` join lets a crafted origin collide the signatures of two
5553
- // distinct live sets. A collision makes refresh() skip a fold update, and since that skip is
5554
- // arrival-order-sensitive it breaks convergence. JSON.stringify escapes the strings and the array
5555
- // structure is unambiguous, so the signature is injective in the live set.
5556
5559
  const sigOf = (live) => JSON.stringify(live.map((s) => [s.origin, s.hlc.p, s.hlc.l, s.epoch, s.kind]));
5557
5560
  /** Recompute the fold cache; true iff the materialized result meaningfully changed. */
5558
5561
  const refresh = (reg) => {
@@ -5602,9 +5605,6 @@ function createConvergingApply(opt) {
5602
5605
  }
5603
5606
  return true;
5604
5607
  };
5605
- // A lone tombstone is droppable only if nothing else still materializes its key: no live
5606
- // descendant register would resurface, and no live ancestor `set` value still holds it. Mirrors
5607
- // the relay's retention twin so a client that prunes converges with a joiner seeded from the relay.
5608
5608
  const tombstoneDroppable = (key, reg) => {
5609
5609
  for (const [k, other] of registers) {
5610
5610
  if (k === key)
@@ -5632,31 +5632,37 @@ function createConvergingApply(opt) {
5632
5632
  }
5633
5633
  return undefined;
5634
5634
  };
5635
- // graft with the deterministic type-change rule: a graft whose parent location is not a plain
5636
- // record is DROPPED (the register stays intact and resurfaces if the container is restored)
5637
5635
  const graft = (tree, rel, res) => {
5638
5636
  if (!isContainer(tree))
5639
5637
  return tree;
5640
5638
  const head = String(rel[0]);
5639
+ if (head === '__proto__')
5640
+ return tree;
5641
+ const copyOf = () => (isPlainArray$1(tree) ? tree.slice() : { ...tree });
5641
5642
  if (rel.length === 1) {
5642
5643
  if (res.kind === 'delete') {
5643
5644
  if (!Object.hasOwn(tree, head))
5644
5645
  return tree;
5645
- const copy = { ...tree };
5646
+ const copy = copyOf();
5646
5647
  delete copy[head];
5647
5648
  return copy;
5648
5649
  }
5649
- return { ...tree, [head]: res.value };
5650
+ const copy = copyOf();
5651
+ copy[head] = res.value;
5652
+ return copy;
5650
5653
  }
5651
5654
  if (!Object.hasOwn(tree, head)) {
5652
- // vivify an absent middle container so a checkpoint-seeded materialization matches a peer that
5653
- // applied the ops incrementally (incremental apply creates missing parents). A numeric next
5654
- // segment vivifies an array, else an object, mirroring the incremental apply path.
5655
5655
  const vivified = typeof rel[1] === 'number' ? [] : {};
5656
- return { ...tree, [head]: graft(vivified, rel.slice(1), res) };
5656
+ const copy = copyOf();
5657
+ copy[head] = graft(vivified, rel.slice(1), res);
5658
+ return copy;
5657
5659
  }
5658
5660
  const child = graft(tree[head], rel.slice(1), res);
5659
- return child === tree[head] ? tree : { ...tree, [head]: child };
5661
+ if (child === tree[head])
5662
+ return tree;
5663
+ const copy = copyOf();
5664
+ copy[head] = child;
5665
+ return copy;
5660
5666
  };
5661
5667
  /** Would a value at `rel` under `value` materialize, per the graft rules? */
5662
5668
  const graftable = (value, rel) => {
@@ -5758,9 +5764,7 @@ function createConvergingApply(opt) {
5758
5764
  const seq = ++ingestSeq;
5759
5765
  for (const op of env.ops) {
5760
5766
  if (o?.frontier && compareHlc(env.hlc, o.frontier) <= 0)
5761
- continue; // below the pruned horizon
5762
- // a delete or clear at the root has no parent register to abstain to; it can only blank the
5763
- // whole document, and materialize would then disagree with the delta path, so drop it
5767
+ continue;
5764
5768
  if (!op.path.length && op.kind !== 'set')
5765
5769
  continue;
5766
5770
  const reg = regAt(op.path);
@@ -6025,15 +6029,8 @@ function opSync(source, opt) {
6025
6029
  origin,
6026
6030
  });
6027
6031
  const subscribers = new Set();
6028
- // per-origin high-watermark; `versions.get(origin)` IS the local emit counter, so a hydrate/restore
6029
- // that raises our own watermark also advances the next mint — no separate counter to drift out of
6030
- // sync and collide with a version acked before a reboot but dropped from a debounced outbox.
6031
6032
  const versions = new Map();
6032
6033
  const recentLocal = [];
6033
- // highest stability frontier this peer has pruned to. A remote envelope at or below it is a settled
6034
- // straggler (its state is compacted away); re-admitting one could resurrect a value below the
6035
- // frontier, and per-origin version dedup cannot catch a FIRST-CONTACT straggler (no prior entry),
6036
- // so the frontier is the admission gate that closes that hole on the receive path.
6037
6034
  let prunedFrontier;
6038
6035
  const resolvedInjector = opt.driver
6039
6036
  ? null
@@ -6041,17 +6038,6 @@ function opSync(source, opt) {
6041
6038
  const log = opLog(source, opt.driver
6042
6039
  ? { origin, driver: opt.driver }
6043
6040
  : { origin, injector: resolvedInjector });
6044
- // Local envelopes stamped + registered but not yet handed to the transport. A `receive` freezes
6045
- // this peer's pending writes here so it can ingest the remote WITHOUT emitting mid-receive — the
6046
- // synchronous re-entrant emission that used to scramble the relay's commit order. The outbox
6047
- // drains on a LATER tick, so emission always lands outside any receive callstack. Writes made
6048
- // while a drain is still owed queue here too, keeping wire order == version order (else a receiver
6049
- // would dedup the older, still-frozen envelope).
6050
- //
6051
- // Deferral rides an Angular effect, so it arms only on the injector path — the transport
6052
- // topologies (`tabSync`, `meshSync`) that actually re-enter through a relay. A custom `driver`
6053
- // (worker mirror, pure sim, multi-reader) owns its own scheduling and has no such re-entrancy, so
6054
- // it emits synchronously; the freeze-before-observe STAMPING fix below is identical either way.
6055
6041
  const canDefer = !opt.driver;
6056
6042
  const outbox = [];
6057
6043
  let receiving = false;
@@ -6136,11 +6122,6 @@ function opSync(source, opt) {
6136
6122
  opt.onReject?.(env, reason);
6137
6123
  return;
6138
6124
  }
6139
- // a settled straggler at or below the pruned stability frontier: reject it (its state is
6140
- // compacted, re-admitting could resurrect a below-frontier value). All ops in an envelope share
6141
- // its stamp, so the envelope hlc is the dot for every op. The live relay path never delivers a
6142
- // below-frontier op (a lagging client gets a snapshot, not a delta), so this only fires on a
6143
- // stray re-broadcast, e.g. over a P2P/multi-path topology.
6144
6125
  if (prunedFrontier && compareHlc(env.hlc, prunedFrontier) <= 0)
6145
6126
  return;
6146
6127
  const known = versions.get(env.origin);
@@ -6213,10 +6194,6 @@ function opSync(source, opt) {
6213
6194
  },
6214
6195
  hydrate: (state, pending) => {
6215
6196
  log.flush();
6216
- // rebase this origin's uncovered local writes on top. A caller that keeps a durable outbox
6217
- // (meshSync, the worker replica) passes its full unacked set, so a long offline burst larger
6218
- // than the in-memory `recentLocal` cap is never dropped from the rebase; without it, fall back
6219
- // to the recent-local ring.
6220
6197
  const source = pending ?? recentLocal;
6221
6198
  const toReplay = source.filter((e) => e.version > (state.wm?.[e.origin] ?? 0));
6222
6199
  conv.reset();
@@ -6931,6 +6908,9 @@ function storeTabSync(sig, opt, bus, injector) {
6931
6908
  const { unsub, post } = bus.subscribe(opt.id, (msg) => {
6932
6909
  if (!msg || typeof msg !== 'object')
6933
6910
  return;
6911
+ // the envelope lane has its own loud guard in `receive`; join messages are fenced here
6912
+ if (msg.t !== 'env' && msg.proto !== OP_PROTO_VERSION)
6913
+ return;
6934
6914
  switch (msg.t) {
6935
6915
  case 'env':
6936
6916
  if (phase === 'joining')
@@ -6947,8 +6927,8 @@ function storeTabSync(sig, opt, bus, injector) {
6947
6927
  const snap = sync.snapshot();
6948
6928
  const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
6949
6929
  post(covered
6950
- ? { t: 'uptodate', to: msg.from }
6951
- : { t: 'state', to: msg.from, state: snap });
6930
+ ? { t: 'uptodate', proto: OP_PROTO_VERSION, to: msg.from }
6931
+ : { t: 'state', proto: OP_PROTO_VERSION, to: msg.from, state: snap });
6952
6932
  }, Math.random() * jitterMs);
6953
6933
  responseTimers.set(msg.from, timer);
6954
6934
  return;
@@ -6970,7 +6950,7 @@ function storeTabSync(sig, opt, bus, injector) {
6970
6950
  }
6971
6951
  });
6972
6952
  const unsubEnv = sync.subscribe((env) => post({ t: 'env', env }));
6973
- post({ t: 'hello', from: sync.origin, wm: sync.watermark() });
6953
+ post({ t: 'hello', proto: OP_PROTO_VERSION, from: sync.origin, wm: sync.watermark() });
6974
6954
  helloTimer = setTimeout(goLive, helloTimeoutMs);
6975
6955
  injector.get(DestroyRef).onDestroy(() => {
6976
6956
  if (helloTimer !== undefined)