@mmstack/primitives 22.9.4 → 22.10.1

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.
@@ -3589,7 +3589,7 @@ function createPointerDrag(opt) {
3589
3589
  return base;
3590
3590
  }
3591
3591
  const hostRef = inject((ElementRef), { optional: true });
3592
- const { target = hostRef?.nativeElement, coordinateSpace = 'client', activationThreshold = 3, throttle = 16, handleSelector, buttons = [0], stopPropagation = false, debugName = 'pointerDrag', } = opt ?? {};
3592
+ const { target = hostRef?.nativeElement, coordinateSpace = 'client', activationThreshold = 3, throttle = 16, handleSelector, buttons = [0], stopPropagation = false, capture = false, debugName = 'pointerDrag', } = opt ?? {};
3593
3593
  const resolve = (t) => {
3594
3594
  if (!t)
3595
3595
  return null;
@@ -3721,6 +3721,7 @@ function createPointerDrag(opt) {
3721
3721
  const controller = new AbortController();
3722
3722
  el.addEventListener('pointerdown', onDown(el), {
3723
3723
  signal: controller.signal,
3724
+ capture,
3724
3725
  });
3725
3726
  return () => {
3726
3727
  controller.abort();
@@ -4323,7 +4324,7 @@ function generateOrigin$1() {
4323
4324
  return globalThis.crypto.randomUUID();
4324
4325
  return Math.random().toString(36).substring(2);
4325
4326
  }
4326
- const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4327
+ const isPlainArray$2 = (v) => Array.isArray(v) && !isOpaque(v);
4327
4328
  /**
4328
4329
  * Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
4329
4330
  * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
@@ -4348,7 +4349,7 @@ function diffNode(prev, next, path, ops) {
4348
4349
  }
4349
4350
  return;
4350
4351
  }
4351
- if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4352
+ if (isPlainArray$2(prev) && isPlainArray$2(next)) {
4352
4353
  // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4353
4354
  // change is a whole unit — index attribution lies under insert/remove/reorder
4354
4355
  if (prev.length === next.length) {
@@ -4367,7 +4368,7 @@ function applyAt(container, path, idx, op) {
4367
4368
  const seg = path[idx];
4368
4369
  if (seg === '__proto__')
4369
4370
  return container;
4370
- const base = isPlainArray$1(container)
4371
+ const base = isPlainArray$2(container)
4371
4372
  ? container.slice()
4372
4373
  : isRecord$1(container)
4373
4374
  ? { ...container }
@@ -5064,12 +5065,15 @@ function createHlcClock(now = Date.now) {
5064
5065
  }
5065
5066
 
5066
5067
  /**
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.
5068
+ * Wire protocol version. Version 2 ops carry `cites` + `epoch` (the dot-citation register).
5069
+ * Version 3 changes MATERIALIZATION semantics, not shape: grafts descend through plain arrays
5070
+ * (per-index ops materialize instead of silently dropping) and the documented drop rule is
5071
+ * enforced for non-plain ancestors — so a v2 replica and a v3 replica CANNOT converge on the
5072
+ * same op set, which is exactly what the version fence exists to make loud. Envelopes from
5073
+ * other versions are dropped loudly, and the tab-sync join protocol refuses to pair mismatched
5074
+ * peers: versions are never silently mixed, in shape OR in meaning.
5071
5075
  */
5072
- const OP_PROTO_VERSION = 2;
5076
+ const OP_PROTO_VERSION = 3;
5073
5077
  const CONFLICT_BRAND = '~mmstackConflict';
5074
5078
  function isConflicted(value) {
5075
5079
  return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
@@ -5326,7 +5330,14 @@ const mergeFold = (merge) => {
5326
5330
  return { kind: 'set', value: acc };
5327
5331
  };
5328
5332
  };
5329
- const isContainer = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
5333
+ const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
5334
+ /**
5335
+ * The graft-side container admission. MUST equal `diffNode`'s descent set (plain records and
5336
+ * plain non-opaque arrays) and `applyAt`'s copy set: emission, incremental apply and
5337
+ * checkpoint materialization decide "container or leaf" identically, or a checkpoint-seeded
5338
+ * replica and an incrementally-applied one disagree about the same op set.
5339
+ */
5340
+ const isContainer = (v) => isPlainArray$1(v) || isRecord$1(v);
5330
5341
  /**
5331
5342
  * The unsequenced-topology convergence core: a dot-citation multi-value register per path.
5332
5343
  * An op supersedes exactly the sibling dots it cites; uncited concurrent writes stay live; a
@@ -5385,8 +5396,6 @@ function createConvergingApply(opt) {
5385
5396
  }
5386
5397
  return out.sort((a, b) => a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0);
5387
5398
  };
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
5399
  const liveObserved = (reg, frontier) => {
5391
5400
  const live = liveOf(reg);
5392
5401
  if (!frontier)
@@ -5394,11 +5403,6 @@ function createConvergingApply(opt) {
5394
5403
  const sm = seqs.get(keyOf$1(reg.path));
5395
5404
  return live.filter((s) => (sm?.get(s.origin) ?? 0) <= frontier.seq);
5396
5405
  };
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
5406
  const sigOf = (live) => JSON.stringify(live.map((s) => [s.origin, s.hlc.p, s.hlc.l, s.epoch, s.kind]));
5403
5407
  /** Recompute the fold cache; true iff the materialized result meaningfully changed. */
5404
5408
  const refresh = (reg) => {
@@ -5448,9 +5452,6 @@ function createConvergingApply(opt) {
5448
5452
  }
5449
5453
  return true;
5450
5454
  };
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
5455
  const tombstoneDroppable = (key, reg) => {
5455
5456
  for (const [k, other] of registers) {
5456
5457
  if (k === key)
@@ -5478,31 +5479,37 @@ function createConvergingApply(opt) {
5478
5479
  }
5479
5480
  return undefined;
5480
5481
  };
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
5482
  const graft = (tree, rel, res) => {
5484
5483
  if (!isContainer(tree))
5485
5484
  return tree;
5486
5485
  const head = String(rel[0]);
5486
+ if (head === '__proto__')
5487
+ return tree;
5488
+ const copyOf = () => (isPlainArray$1(tree) ? tree.slice() : { ...tree });
5487
5489
  if (rel.length === 1) {
5488
5490
  if (res.kind === 'delete') {
5489
5491
  if (!Object.hasOwn(tree, head))
5490
5492
  return tree;
5491
- const copy = { ...tree };
5493
+ const copy = copyOf();
5492
5494
  delete copy[head];
5493
5495
  return copy;
5494
5496
  }
5495
- return { ...tree, [head]: res.value };
5497
+ const copy = copyOf();
5498
+ copy[head] = res.value;
5499
+ return copy;
5496
5500
  }
5497
5501
  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
5502
  const vivified = typeof rel[1] === 'number' ? [] : {};
5502
- return { ...tree, [head]: graft(vivified, rel.slice(1), res) };
5503
+ const copy = copyOf();
5504
+ copy[head] = graft(vivified, rel.slice(1), res);
5505
+ return copy;
5503
5506
  }
5504
5507
  const child = graft(tree[head], rel.slice(1), res);
5505
- return child === tree[head] ? tree : { ...tree, [head]: child };
5508
+ if (child === tree[head])
5509
+ return tree;
5510
+ const copy = copyOf();
5511
+ copy[head] = child;
5512
+ return copy;
5506
5513
  };
5507
5514
  /** Would a value at `rel` under `value` materialize, per the graft rules? */
5508
5515
  const graftable = (value, rel) => {
@@ -5604,9 +5611,7 @@ function createConvergingApply(opt) {
5604
5611
  const seq = ++ingestSeq;
5605
5612
  for (const op of env.ops) {
5606
5613
  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
5614
+ continue;
5610
5615
  if (!op.path.length && op.kind !== 'set')
5611
5616
  continue;
5612
5617
  const reg = regAt(op.path);
@@ -5871,15 +5876,8 @@ function opSync(source, opt) {
5871
5876
  origin,
5872
5877
  });
5873
5878
  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
5879
  const versions = new Map();
5878
5880
  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
5881
  let prunedFrontier;
5884
5882
  const resolvedInjector = opt.driver
5885
5883
  ? null
@@ -5887,17 +5885,6 @@ function opSync(source, opt) {
5887
5885
  const log = opLog(source, opt.driver
5888
5886
  ? { origin, driver: opt.driver }
5889
5887
  : { 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
5888
  const canDefer = !opt.driver;
5902
5889
  const outbox = [];
5903
5890
  let receiving = false;
@@ -5983,11 +5970,6 @@ function opSync(source, opt) {
5983
5970
  opt.onReject?.(env, reason);
5984
5971
  return;
5985
5972
  }
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
5973
  if (prunedFrontier && compareHlc(env.hlc, prunedFrontier) <= 0)
5992
5974
  return;
5993
5975
  const known = versions.get(env.origin);
@@ -6060,10 +6042,6 @@ function opSync(source, opt) {
6060
6042
  },
6061
6043
  hydrate: (state, pending) => {
6062
6044
  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
6045
  const source = pending ?? recentLocal;
6068
6046
  const toReplay = source.filter((e) => e.version > (state.wm?.[e.origin] ?? 0));
6069
6047
  conv.reset();
@@ -6926,6 +6904,9 @@ function storeTabSync(sig, opt, bus, injector) {
6926
6904
  const { unsub, post } = bus.subscribe(opt.id, (msg) => {
6927
6905
  if (!msg || typeof msg !== 'object')
6928
6906
  return;
6907
+ // the envelope lane has its own loud guard in `receive`; join messages are fenced here
6908
+ if (msg.t !== 'env' && msg.proto !== OP_PROTO_VERSION)
6909
+ return;
6929
6910
  switch (msg.t) {
6930
6911
  case 'env':
6931
6912
  if (phase === 'joining')
@@ -6942,8 +6923,8 @@ function storeTabSync(sig, opt, bus, injector) {
6942
6923
  const snap = sync.snapshot();
6943
6924
  const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
6944
6925
  post(covered
6945
- ? { t: 'uptodate', to: msg.from }
6946
- : { t: 'state', to: msg.from, state: snap });
6926
+ ? { t: 'uptodate', proto: OP_PROTO_VERSION, to: msg.from }
6927
+ : { t: 'state', proto: OP_PROTO_VERSION, to: msg.from, state: snap });
6947
6928
  }, Math.random() * jitterMs);
6948
6929
  responseTimers.set(msg.from, timer);
6949
6930
  return;
@@ -6965,7 +6946,7 @@ function storeTabSync(sig, opt, bus, injector) {
6965
6946
  }
6966
6947
  });
6967
6948
  const unsubEnv = sync.subscribe((env) => post({ t: 'env', env }));
6968
- post({ t: 'hello', from: sync.origin, wm: sync.watermark() });
6949
+ post({ t: 'hello', proto: OP_PROTO_VERSION, from: sync.origin, wm: sync.watermark() });
6969
6950
  helloTimer = setTimeout(goLive, helloTimeoutMs);
6970
6951
  injector.get(DestroyRef).onDestroy(() => {
6971
6952
  if (helloTimer !== undefined)