@mmstack/primitives 22.9.3 → 22.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
@@ -4323,7 +4323,7 @@ function generateOrigin$1() {
4323
4323
  return globalThis.crypto.randomUUID();
4324
4324
  return Math.random().toString(36).substring(2);
4325
4325
  }
4326
- const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4326
+ const isPlainArray$2 = (v) => Array.isArray(v) && !isOpaque(v);
4327
4327
  /**
4328
4328
  * Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
4329
4329
  * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
@@ -4348,7 +4348,7 @@ function diffNode(prev, next, path, ops) {
4348
4348
  }
4349
4349
  return;
4350
4350
  }
4351
- if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4351
+ if (isPlainArray$2(prev) && isPlainArray$2(next)) {
4352
4352
  // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4353
4353
  // change is a whole unit — index attribution lies under insert/remove/reorder
4354
4354
  if (prev.length === next.length) {
@@ -4367,7 +4367,7 @@ function applyAt(container, path, idx, op) {
4367
4367
  const seg = path[idx];
4368
4368
  if (seg === '__proto__')
4369
4369
  return container;
4370
- const base = isPlainArray$1(container)
4370
+ const base = isPlainArray$2(container)
4371
4371
  ? container.slice()
4372
4372
  : isRecord$1(container)
4373
4373
  ? { ...container }
@@ -5064,12 +5064,15 @@ function createHlcClock(now = Date.now) {
5064
5064
  }
5065
5065
 
5066
5066
  /**
5067
- * Wire protocol version. Version 2 ops carry `cites` + `epoch` (the dot-citation register);
5068
- * envelopes from other versions are dropped loudly: an op without citations cannot be merged
5069
- * soundly (it would supersede nothing and its siblings would accumulate forever), so versions
5070
- * are never silently mixed.
5067
+ * Wire protocol version. Version 2 ops carry `cites` + `epoch` (the dot-citation register).
5068
+ * Version 3 changes MATERIALIZATION semantics, not shape: grafts descend through plain arrays
5069
+ * (per-index ops materialize instead of silently dropping) and the documented drop rule is
5070
+ * enforced for non-plain ancestors — so a v2 replica and a v3 replica CANNOT converge on the
5071
+ * same op set, which is exactly what the version fence exists to make loud. Envelopes from
5072
+ * other versions are dropped loudly, and the tab-sync join protocol refuses to pair mismatched
5073
+ * peers: versions are never silently mixed, in shape OR in meaning.
5071
5074
  */
5072
- const OP_PROTO_VERSION = 2;
5075
+ const OP_PROTO_VERSION = 3;
5073
5076
  const CONFLICT_BRAND = '~mmstackConflict';
5074
5077
  function isConflicted(value) {
5075
5078
  return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
@@ -5326,7 +5329,14 @@ const mergeFold = (merge) => {
5326
5329
  return { kind: 'set', value: acc };
5327
5330
  };
5328
5331
  };
5329
- const isContainer = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
5332
+ const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
5333
+ /**
5334
+ * The graft-side container admission. MUST equal `diffNode`'s descent set (plain records and
5335
+ * plain non-opaque arrays) and `applyAt`'s copy set: emission, incremental apply and
5336
+ * checkpoint materialization decide "container or leaf" identically, or a checkpoint-seeded
5337
+ * replica and an incrementally-applied one disagree about the same op set.
5338
+ */
5339
+ const isContainer = (v) => isPlainArray$1(v) || isRecord$1(v);
5330
5340
  /**
5331
5341
  * The unsequenced-topology convergence core: a dot-citation multi-value register per path.
5332
5342
  * An op supersedes exactly the sibling dots it cites; uncited concurrent writes stay live; a
@@ -5385,8 +5395,6 @@ function createConvergingApply(opt) {
5385
5395
  }
5386
5396
  return out.sort((a, b) => a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0);
5387
5397
  };
5388
- // The live siblings a frontier had observed: those that arrived at or below its captured seq.
5389
- // Used by a fork commit so it supersedes only what it saw when it forked, not later writes.
5390
5398
  const liveObserved = (reg, frontier) => {
5391
5399
  const live = liveOf(reg);
5392
5400
  if (!frontier)
@@ -5394,11 +5402,6 @@ function createConvergingApply(opt) {
5394
5402
  const sm = seqs.get(keyOf$1(reg.path));
5395
5403
  return live.filter((s) => (sm?.get(s.origin) ?? 0) <= frontier.seq);
5396
5404
  };
5397
- // JSON of a tuple array, not a separator-joined string: `origin` is a caller-supplied value on a
5398
- // P2P peer, so a naive `origin@p.l#epoch` join lets a crafted origin collide the signatures of two
5399
- // distinct live sets. A collision makes refresh() skip a fold update, and since that skip is
5400
- // arrival-order-sensitive it breaks convergence. JSON.stringify escapes the strings and the array
5401
- // structure is unambiguous, so the signature is injective in the live set.
5402
5405
  const sigOf = (live) => JSON.stringify(live.map((s) => [s.origin, s.hlc.p, s.hlc.l, s.epoch, s.kind]));
5403
5406
  /** Recompute the fold cache; true iff the materialized result meaningfully changed. */
5404
5407
  const refresh = (reg) => {
@@ -5448,9 +5451,6 @@ function createConvergingApply(opt) {
5448
5451
  }
5449
5452
  return true;
5450
5453
  };
5451
- // A lone tombstone is droppable only if nothing else still materializes its key: no live
5452
- // descendant register would resurface, and no live ancestor `set` value still holds it. Mirrors
5453
- // the relay's retention twin so a client that prunes converges with a joiner seeded from the relay.
5454
5454
  const tombstoneDroppable = (key, reg) => {
5455
5455
  for (const [k, other] of registers) {
5456
5456
  if (k === key)
@@ -5478,31 +5478,37 @@ function createConvergingApply(opt) {
5478
5478
  }
5479
5479
  return undefined;
5480
5480
  };
5481
- // graft with the deterministic type-change rule: a graft whose parent location is not a plain
5482
- // record is DROPPED (the register stays intact and resurfaces if the container is restored)
5483
5481
  const graft = (tree, rel, res) => {
5484
5482
  if (!isContainer(tree))
5485
5483
  return tree;
5486
5484
  const head = String(rel[0]);
5485
+ if (head === '__proto__')
5486
+ return tree;
5487
+ const copyOf = () => (isPlainArray$1(tree) ? tree.slice() : { ...tree });
5487
5488
  if (rel.length === 1) {
5488
5489
  if (res.kind === 'delete') {
5489
5490
  if (!Object.hasOwn(tree, head))
5490
5491
  return tree;
5491
- const copy = { ...tree };
5492
+ const copy = copyOf();
5492
5493
  delete copy[head];
5493
5494
  return copy;
5494
5495
  }
5495
- return { ...tree, [head]: res.value };
5496
+ const copy = copyOf();
5497
+ copy[head] = res.value;
5498
+ return copy;
5496
5499
  }
5497
5500
  if (!Object.hasOwn(tree, head)) {
5498
- // vivify an absent middle container so a checkpoint-seeded materialization matches a peer that
5499
- // applied the ops incrementally (incremental apply creates missing parents). A numeric next
5500
- // segment vivifies an array, else an object, mirroring the incremental apply path.
5501
5501
  const vivified = typeof rel[1] === 'number' ? [] : {};
5502
- return { ...tree, [head]: graft(vivified, rel.slice(1), res) };
5502
+ const copy = copyOf();
5503
+ copy[head] = graft(vivified, rel.slice(1), res);
5504
+ return copy;
5503
5505
  }
5504
5506
  const child = graft(tree[head], rel.slice(1), res);
5505
- return child === tree[head] ? tree : { ...tree, [head]: child };
5507
+ if (child === tree[head])
5508
+ return tree;
5509
+ const copy = copyOf();
5510
+ copy[head] = child;
5511
+ return copy;
5506
5512
  };
5507
5513
  /** Would a value at `rel` under `value` materialize, per the graft rules? */
5508
5514
  const graftable = (value, rel) => {
@@ -5604,9 +5610,7 @@ function createConvergingApply(opt) {
5604
5610
  const seq = ++ingestSeq;
5605
5611
  for (const op of env.ops) {
5606
5612
  if (o?.frontier && compareHlc(env.hlc, o.frontier) <= 0)
5607
- continue; // below the pruned horizon
5608
- // a delete or clear at the root has no parent register to abstain to; it can only blank the
5609
- // whole document, and materialize would then disagree with the delta path, so drop it
5613
+ continue;
5610
5614
  if (!op.path.length && op.kind !== 'set')
5611
5615
  continue;
5612
5616
  const reg = regAt(op.path);
@@ -5871,15 +5875,8 @@ function opSync(source, opt) {
5871
5875
  origin,
5872
5876
  });
5873
5877
  const subscribers = new Set();
5874
- // per-origin high-watermark; `versions.get(origin)` IS the local emit counter, so a hydrate/restore
5875
- // that raises our own watermark also advances the next mint — no separate counter to drift out of
5876
- // sync and collide with a version acked before a reboot but dropped from a debounced outbox.
5877
5878
  const versions = new Map();
5878
5879
  const recentLocal = [];
5879
- // highest stability frontier this peer has pruned to. A remote envelope at or below it is a settled
5880
- // straggler (its state is compacted away); re-admitting one could resurrect a value below the
5881
- // frontier, and per-origin version dedup cannot catch a FIRST-CONTACT straggler (no prior entry),
5882
- // so the frontier is the admission gate that closes that hole on the receive path.
5883
5880
  let prunedFrontier;
5884
5881
  const resolvedInjector = opt.driver
5885
5882
  ? null
@@ -5887,17 +5884,6 @@ function opSync(source, opt) {
5887
5884
  const log = opLog(source, opt.driver
5888
5885
  ? { origin, driver: opt.driver }
5889
5886
  : { origin, injector: resolvedInjector });
5890
- // Local envelopes stamped + registered but not yet handed to the transport. A `receive` freezes
5891
- // this peer's pending writes here so it can ingest the remote WITHOUT emitting mid-receive — the
5892
- // synchronous re-entrant emission that used to scramble the relay's commit order. The outbox
5893
- // drains on a LATER tick, so emission always lands outside any receive callstack. Writes made
5894
- // while a drain is still owed queue here too, keeping wire order == version order (else a receiver
5895
- // would dedup the older, still-frozen envelope).
5896
- //
5897
- // Deferral rides an Angular effect, so it arms only on the injector path — the transport
5898
- // topologies (`tabSync`, `meshSync`) that actually re-enter through a relay. A custom `driver`
5899
- // (worker mirror, pure sim, multi-reader) owns its own scheduling and has no such re-entrancy, so
5900
- // it emits synchronously; the freeze-before-observe STAMPING fix below is identical either way.
5901
5887
  const canDefer = !opt.driver;
5902
5888
  const outbox = [];
5903
5889
  let receiving = false;
@@ -5983,11 +5969,6 @@ function opSync(source, opt) {
5983
5969
  opt.onReject?.(env, reason);
5984
5970
  return;
5985
5971
  }
5986
- // a settled straggler at or below the pruned stability frontier: reject it (its state is
5987
- // compacted, re-admitting could resurrect a below-frontier value). All ops in an envelope share
5988
- // its stamp, so the envelope hlc is the dot for every op. The live relay path never delivers a
5989
- // below-frontier op (a lagging client gets a snapshot, not a delta), so this only fires on a
5990
- // stray re-broadcast, e.g. over a P2P/multi-path topology.
5991
5972
  if (prunedFrontier && compareHlc(env.hlc, prunedFrontier) <= 0)
5992
5973
  return;
5993
5974
  const known = versions.get(env.origin);
@@ -6060,10 +6041,6 @@ function opSync(source, opt) {
6060
6041
  },
6061
6042
  hydrate: (state, pending) => {
6062
6043
  log.flush();
6063
- // rebase this origin's uncovered local writes on top. A caller that keeps a durable outbox
6064
- // (meshSync, the worker replica) passes its full unacked set, so a long offline burst larger
6065
- // than the in-memory `recentLocal` cap is never dropped from the rebase; without it, fall back
6066
- // to the recent-local ring.
6067
6044
  const source = pending ?? recentLocal;
6068
6045
  const toReplay = source.filter((e) => e.version > (state.wm?.[e.origin] ?? 0));
6069
6046
  conv.reset();
@@ -6926,6 +6903,9 @@ function storeTabSync(sig, opt, bus, injector) {
6926
6903
  const { unsub, post } = bus.subscribe(opt.id, (msg) => {
6927
6904
  if (!msg || typeof msg !== 'object')
6928
6905
  return;
6906
+ // the envelope lane has its own loud guard in `receive`; join messages are fenced here
6907
+ if (msg.t !== 'env' && msg.proto !== OP_PROTO_VERSION)
6908
+ return;
6929
6909
  switch (msg.t) {
6930
6910
  case 'env':
6931
6911
  if (phase === 'joining')
@@ -6942,8 +6922,8 @@ function storeTabSync(sig, opt, bus, injector) {
6942
6922
  const snap = sync.snapshot();
6943
6923
  const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
6944
6924
  post(covered
6945
- ? { t: 'uptodate', to: msg.from }
6946
- : { t: 'state', to: msg.from, state: snap });
6925
+ ? { t: 'uptodate', proto: OP_PROTO_VERSION, to: msg.from }
6926
+ : { t: 'state', proto: OP_PROTO_VERSION, to: msg.from, state: snap });
6947
6927
  }, Math.random() * jitterMs);
6948
6928
  responseTimers.set(msg.from, timer);
6949
6929
  return;
@@ -6965,7 +6945,7 @@ function storeTabSync(sig, opt, bus, injector) {
6965
6945
  }
6966
6946
  });
6967
6947
  const unsubEnv = sync.subscribe((env) => post({ t: 'env', env }));
6968
- post({ t: 'hello', from: sync.origin, wm: sync.watermark() });
6948
+ post({ t: 'hello', proto: OP_PROTO_VERSION, from: sync.origin, wm: sync.watermark() });
6969
6949
  helloTimer = setTimeout(goLive, helloTimeoutMs);
6970
6950
  injector.get(DestroyRef).onDestroy(() => {
6971
6951
  if (helloTimer !== undefined)