@mmstack/primitives 21.5.1 → 21.6.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.
@@ -208,8 +208,7 @@ class MmActivity {
208
208
  if (this.onServer)
209
209
  return;
210
210
  for (const node of this.view.rootNodes) {
211
- // covers HTML and SVG roots; text/comment roots can't be styled their CD is still
212
- // detached, but prefer an element root for true visual hiding
211
+ // covers HTML and SVG roots; text/comment roots can't be styled, their CD is still detached
213
212
  if (node instanceof HTMLElement || node instanceof SVGElement)
214
213
  node.style.display = visible ? '' : 'none';
215
214
  }
@@ -227,8 +226,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
227
226
  selector: '[mmActivity]',
228
227
  }]
229
228
  }], ctorParameters: () => [], propDecorators: { visible: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmActivity", required: true }] }] } });
230
- // Shared never-paused signal returned outside a boundary / on the server (SSR renders the full tree,
231
- // nothing is paused). Readonly so a consumer can't cast-and-`.set()` the shared default for everyone.
232
229
  const NEVER_PAUSED = signal(false).asReadonly();
233
230
  /**
234
231
  * Inject the nearest paused-state signal — `true` while the surrounding subtree is paused (hidden by
@@ -481,7 +478,7 @@ function deferredValue(source, opt) {
481
478
  let cancel = null;
482
479
  const watch = effect(() => {
483
480
  const v = source();
484
- cancel?.(); // latest wins: rapid changes coalesce into one catch-up
481
+ cancel?.();
485
482
  cancel = schedule(() => {
486
483
  cancel = null;
487
484
  out.set(v);
@@ -493,8 +490,6 @@ function deferredValue(source, opt) {
493
490
  cancel = null;
494
491
  });
495
492
  const result = computed(() => out());
496
- // "behind" is a value comparison, not a schedule flag: an equal-valued catch-up
497
- // (e.g. type a char, delete it before the deferred view caught up) is not pending
498
493
  result.pending = computed(() => !equal(out(), source()), ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
499
494
  return result;
500
495
  }
@@ -518,6 +513,46 @@ function resolveScheduler(strategy, injector) {
518
513
  };
519
514
  }
520
515
 
516
+ const CONCURRENCY_INSTRUMENTATION = new InjectionToken('@mmstack/primitives:concurrency-instrumentation');
517
+ function provideConcurrencyInstrumentation(listener) {
518
+ return { provide: CONCURRENCY_INSTRUMENTATION, useValue: listener };
519
+ }
520
+ const now = () => typeof globalThis.performance !== 'undefined'
521
+ ? globalThis.performance.now()
522
+ : Date.now();
523
+ /**
524
+ * Chrome DevTools "Performance" custom-tracks preset (idea/concurrency-devtools.md): writes a
525
+ * `performance.measure` for each pending/transaction window onto an "mmstack" extension track,
526
+ * so reactive coordination shows up on the Performance panel timeline. Dev-only, zero backend,
527
+ * no dependencies. Give each measure the scope name for readability.
528
+ */
529
+ function perfCustomTracks(track = 'mmstack concurrency') {
530
+ const canMeasure = typeof globalThis.performance !== 'undefined' &&
531
+ typeof globalThis.performance.measure === 'function';
532
+ const span = (name, start) => {
533
+ if (!canMeasure)
534
+ return;
535
+ try {
536
+ globalThis.performance.measure(name, {
537
+ start,
538
+ end: now(),
539
+ detail: {
540
+ devtools: { dataType: 'track-entry', track, color: 'primary' },
541
+ },
542
+ });
543
+ }
544
+ catch {
545
+ // measure options with detail are unsupported on this engine — skip silently
546
+ }
547
+ };
548
+ return {
549
+ pendingStart: (e) => e.at,
550
+ pendingEnd: (handle, e) => span(`pending`, handle ?? e.at),
551
+ transactionStart: (e) => e.at,
552
+ transactionEnd: (handle, e) => span(`transaction`, handle ?? e.at),
553
+ };
554
+ }
555
+
521
556
  /**
522
557
  * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
523
558
  * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
@@ -592,8 +627,13 @@ function isMutable(value) {
592
627
  return 'mutate' in value && typeof value.mutate === 'function';
593
628
  }
594
629
 
595
- function createTransitionScope() {
630
+ function createTransitionScope(opt) {
596
631
  const list = mutable([]);
632
+ const inst = opt?.instrumentation;
633
+ const name = opt?.name ?? 'scope';
634
+ const at = () => typeof globalThis.performance !== 'undefined'
635
+ ? globalThis.performance.now()
636
+ : Date.now();
597
637
  const pending = computed(() => list().some(({ ref }) => {
598
638
  const s = ref.status();
599
639
  return s === 'loading' || s === 'reloading';
@@ -604,11 +644,17 @@ function createTransitionScope() {
604
644
  resources: computed(() => list().map((e) => e.ref)),
605
645
  pending,
606
646
  suspended: (type) => list().some(({ ref, suspends }) => suspends && (type === 'loading' ? ref.isLoading() : !ref.hasValue())),
607
- add: (ref, opt) => untracked(() => list.inline((c) => c.push({ ref, suspends: opt?.suspends ?? true }))),
647
+ add: (ref, o) => untracked(() => {
648
+ const suspends = o?.suspends ?? true;
649
+ list.inline((c) => c.push({ ref, suspends }));
650
+ inst?.resourceRegistered?.({ scope: name, suspends });
651
+ }),
608
652
  remove: (ref) => untracked(() => list.inline((c) => {
609
653
  const i = c.findIndex((e) => e.ref === ref);
610
- if (i !== -1)
654
+ if (i !== -1) {
611
655
  c.splice(i, 1);
656
+ inst?.resourceRemoved?.({ scope: name });
657
+ }
612
658
  })),
613
659
  commit: (value) => linkedSignal({
614
660
  source: () => ({ v: value(), settled: !pending() }),
@@ -623,6 +669,8 @@ function createTransitionScope() {
623
669
  aborted++;
624
670
  }
625
671
  }
672
+ if (aborted > 0)
673
+ inst?.abortPending?.({ scope: name, aborted, at: at() });
626
674
  return aborted;
627
675
  }),
628
676
  holding,
@@ -691,13 +739,59 @@ function bridgeScopeToPendingTasks(scope, injector) {
691
739
  });
692
740
  });
693
741
  }
742
+ /**
743
+ * While a listener is installed, bracket each pending window of `scope` with a
744
+ * `pendingStart`/`pendingEnd` span (the reactive tap that needs an injection context). No-op
745
+ * when no listener is provided, so it stays zero-cost by default.
746
+ */
747
+ function bridgeScopeToInstrumentation(scope, name, injector) {
748
+ const run = (fn) => injector ? runInInjectionContext(injector, fn) : fn();
749
+ run(() => {
750
+ const inst = inject(CONCURRENCY_INSTRUMENTATION, { optional: true });
751
+ if (!inst?.pendingStart && !inst?.pendingEnd)
752
+ return;
753
+ const at = () => typeof globalThis.performance !== 'undefined'
754
+ ? globalThis.performance.now()
755
+ : Date.now();
756
+ let handle;
757
+ let open = false;
758
+ effect(() => {
759
+ const pending = scope.pending();
760
+ untracked(() => {
761
+ if (pending && !open) {
762
+ open = true;
763
+ handle = inst.pendingStart?.({
764
+ scope: name,
765
+ resources: scope.resources().length,
766
+ at: at(),
767
+ });
768
+ }
769
+ else if (!pending && open) {
770
+ open = false;
771
+ inst.pendingEnd?.(handle, { at: at() });
772
+ }
773
+ });
774
+ });
775
+ inject(DestroyRef).onDestroy(() => {
776
+ if (open)
777
+ inst.pendingEnd?.(handle, { at: at() });
778
+ });
779
+ });
780
+ }
694
781
  /** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
695
- function provideTransitionScope() {
782
+ function provideTransitionScope(opt) {
696
783
  return {
697
784
  provide: TRANSITION_SCOPE,
698
785
  useFactory: () => {
699
- const scope = createTransitionScope();
786
+ const listener = opt?.instrumentation ??
787
+ inject(CONCURRENCY_INSTRUMENTATION, { optional: true }) ??
788
+ undefined;
789
+ const scope = createTransitionScope({
790
+ name: opt?.name,
791
+ instrumentation: listener,
792
+ });
700
793
  bridgeScopeToPendingTasks(scope);
794
+ bridgeScopeToInstrumentation(scope, opt?.name ?? 'scope');
701
795
  return scope;
702
796
  },
703
797
  };
@@ -845,8 +939,6 @@ function use(res) {
845
939
  frame.seen.add(res);
846
940
  frame.deps.push(res);
847
941
  }
848
- // status() is read tracked even on the short-circuit paths, so the owning computed
849
- // re-evaluates when the load settles / the error clears.
850
942
  if (res.status() === 'error') {
851
943
  frame.errors.push(res.error?.());
852
944
  throw BLOCKED;
@@ -894,9 +986,6 @@ function latest(fn, opt) {
894
986
  }
895
987
  }, opt?.debugName ? { debugName: `${opt.debugName}:evaluation` } : undefined);
896
988
  const equal = opt?.equal ?? Object.is;
897
- // The stale-while-revalidate atom: holds the last successful result through blocked /
898
- // errored rounds. `equal` gates notification, so an in-flight cycle that lands on an
899
- // equal value never ripples to consumers — while `pending` (independent) still cycles.
900
989
  const held = linkedSignal({ ...(ngDevMode ? { debugName: "held" } : /* istanbul ignore next */ {}), source: evaluation,
901
990
  computation: (ev, prev) => ev.kind === 'value'
902
991
  ? { has: true, v: ev.value }
@@ -962,8 +1051,7 @@ function injectStartTransition() {
962
1051
  const destroyRef = inject(DestroyRef);
963
1052
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
964
1053
  return (fn) => {
965
- // attributed: loads already in flight when the transition starts are not ours
966
- // they can neither settle this transition early nor block it forever
1054
+ // attributed: loads already in flight when the transition starts are not ours
967
1055
  const pending = createAttributedPending(scope);
968
1056
  untracked(fn);
969
1057
  let sawPending = false;
@@ -988,8 +1076,7 @@ function injectStartTransition() {
988
1076
  settle();
989
1077
  return;
990
1078
  }
991
- // no-async fallback: once the reactive system has processed the writes (afterNextRender),
992
- // if nothing ever went in flight, the transition is already complete.
1079
+ // no-async fallback: once the reactive system has processed the writes,
993
1080
  afterNextRender(() => {
994
1081
  if (!sawPending && !untracked(pending))
995
1082
  settle();
@@ -1094,9 +1181,6 @@ function createTransaction() {
1094
1181
  clear: () => log.clear(),
1095
1182
  };
1096
1183
  }
1097
- // The currently-active transaction, set only for the synchronous duration of a `startTransaction`
1098
- // body (so stateful actions running inside it can record their writes). Module-level + sync
1099
- // set/reset is the honest shape: a transaction is call-scoped, not structural-per-injector.
1100
1184
  let active = null;
1101
1185
  /** The transaction in effect right now, or `null`. Stateful actions consult this to record undo. */
1102
1186
  function activeTransaction() {
@@ -1135,10 +1219,7 @@ function injectStartTransaction() {
1135
1219
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
1136
1220
  return (fn) => {
1137
1221
  const txn = createTransaction();
1138
- // attributed: loads already in flight when the transaction starts are not ours —
1139
- // they can neither commit this transaction early nor block its settle forever
1140
1222
  const pending = createAttributedPending(scope);
1141
- // Hold BEFORE the writes, so the display freezes at pre-transaction values.
1142
1223
  scope.beginHold();
1143
1224
  let finished = false;
1144
1225
  // eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
@@ -1147,9 +1228,6 @@ function injectStartTransaction() {
1147
1228
  const done = new Promise((resolve) => {
1148
1229
  resolveDone = resolve;
1149
1230
  });
1150
- // Every exit path funnels through here, so `done` always settles — including `abort()`
1151
- // and a throwing transaction body (which would otherwise leak the hold forever and
1152
- // freeze the boundary with no recovery).
1153
1231
  const finish = (restore) => {
1154
1232
  if (finished)
1155
1233
  return;
@@ -1163,9 +1241,6 @@ function injectStartTransaction() {
1163
1241
  scope.endHold();
1164
1242
  resolveDone();
1165
1243
  };
1166
- // The scope may outlive the calling context (a component transacting on an ancestor
1167
- // boundary): a destroy mid-flight kills the settle watcher, so without this the hold
1168
- // would leak and freeze the surviving scope forever. Keep the writes — they landed live.
1169
1244
  const releaseDestroy = destroyRef.onDestroy(() => finish(false));
1170
1245
  try {
1171
1246
  runInTransaction(txn, fn);
@@ -1187,7 +1262,7 @@ function injectStartTransaction() {
1187
1262
  finish(false);
1188
1263
  }
1189
1264
  else {
1190
- // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
1265
+ // no-async fallback
1191
1266
  afterNextRender(() => {
1192
1267
  if (!sawPending && !untracked(pending))
1193
1268
  finish(false);
@@ -1263,7 +1338,6 @@ class MmTransition {
1263
1338
  }
1264
1339
  onValue(v) {
1265
1340
  if (!this.current) {
1266
- // first render: nothing to hold yet — show immediately (also what SSR serializes)
1267
1341
  this.current = this.createView(v).view;
1268
1342
  return;
1269
1343
  }
@@ -1277,8 +1351,7 @@ class MmTransition {
1277
1351
  const { view, scope } = this.createView(v);
1278
1352
  this.setHidden(view, true);
1279
1353
  this.holding.set(true);
1280
- // Registration happens synchronously during view creation, so a resource already in
1281
- // flight counts from the start; later kickoffs are caught by the watcher.
1354
+ // Registration happens synchronously during view creation, so a resource already incl. later kickoffs are caught by the watcher.
1282
1355
  let sawPending = untracked(scope.pending);
1283
1356
  const watcher = effect(() => {
1284
1357
  const pending = scope.pending();
@@ -1335,8 +1408,6 @@ class MmTransition {
1335
1408
  this.holding.set(false);
1336
1409
  }
1337
1410
  createView(v) {
1338
- // Each view gets its own scope, so its subtree's resources register here by existing —
1339
- // and the outgoing view's background work can't block the swap (per-view isolation).
1340
1411
  const injector = Injector.create({
1341
1412
  parent: this.parent,
1342
1413
  providers: [provideTransitionScope()],
@@ -1422,7 +1493,7 @@ function getSignalEquality(sig) {
1422
1493
  if (internal && typeof internal.equal === 'function') {
1423
1494
  return internal.equal;
1424
1495
  }
1425
- return Object.is; // Default equality check
1496
+ return Object.is;
1426
1497
  }
1427
1498
 
1428
1499
  /**
@@ -1570,8 +1641,6 @@ function isIndexProp(prop) {
1570
1641
  return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
1571
1642
  }
1572
1643
 
1573
- // Container resolvers used by createVivify: each returns the current value when present and
1574
- // only creates a new container when it is null/undefined.
1575
1644
  function identity(x) {
1576
1645
  return x;
1577
1646
  }
@@ -1729,11 +1798,6 @@ function derived(source, optOrKey, opt) {
1729
1798
  cnt++;
1730
1799
  try {
1731
1800
  sig.update(updater);
1732
- // The wrapped computed evaluates its `equal` lazily — at the next read, which would
1733
- // normally happen after `cnt` has already dropped back to 0. For a reference-stable
1734
- // mutation that read compares the same object to itself and the version never bumps,
1735
- // so dependents are never notified. Reading here, while equality is still suppressed,
1736
- // forces the recompute (and version bump) inside the mutate window.
1737
1801
  untracked(sig);
1738
1802
  }
1739
1803
  finally {
@@ -1795,8 +1859,6 @@ function isDerivation(sig) {
1795
1859
 
1796
1860
  function keepPrevious(src, opt) {
1797
1861
  const mutableSrc = isWritableSignal$2(src) && isMutable(src);
1798
- // For a mutable source the linkedSignal's equality must be suppressible: a forwarded
1799
- // `mutate` keeps the same reference, which default equality would otherwise swallow.
1800
1862
  let cnt = 0;
1801
1863
  const baseEqual = opt?.equal;
1802
1864
  const equal = mutableSrc
@@ -1809,16 +1871,11 @@ function keepPrevious(src, opt) {
1809
1871
  if (isWritableSignal$2(src)) {
1810
1872
  persisted.set = src.set;
1811
1873
  persisted.update = src.update;
1812
- // NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
1813
- // source's readonly view would reintroduce the `undefined` flashes this wrapper exists
1814
- // to prevent.
1815
1874
  if (mutableSrc) {
1816
1875
  persisted.mutate = (updater) => {
1817
1876
  cnt++;
1818
1877
  try {
1819
1878
  src.mutate(updater);
1820
- // force the recompute while equality is suppressed, so the reference-stable
1821
- // mutation bumps the wrapper's version (see derived.ts for the same pattern)
1822
1879
  untracked(persisted);
1823
1880
  }
1824
1881
  finally {
@@ -1887,8 +1944,7 @@ function indexArray(source, map, opt = {}) {
1887
1944
  : toWritable(data, () => {
1888
1945
  // noop
1889
1946
  });
1890
- // copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
1891
- // (possibly shared/reused) options object
1947
+ // copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned (possibly shared/reused) options object
1892
1948
  if (isWritableSignal$1(data) && isMutable(data) && !opt.equal) {
1893
1949
  opt = {
1894
1950
  ...opt,
@@ -2583,8 +2639,7 @@ function observerSupported$1() {
2583
2639
  */
2584
2640
  function elementSize(target, opt) {
2585
2641
  return runInSensorContext(opt?.injector, () =>
2586
- // the host-element default must resolve INSIDE the sensor context, not as a
2587
- // parameter default (which would run before the injector wrapper)
2642
+ // the host-element default must resolve INSIDE the sensor context
2588
2643
  createElementSize(target ?? inject(ElementRef), opt));
2589
2644
  }
2590
2645
  function createElementSize(target, opt) {
@@ -2721,10 +2776,7 @@ function observerSupported() {
2721
2776
  * ```
2722
2777
  */
2723
2778
  function elementVisibility(target, opt) {
2724
- return runInSensorContext(opt?.injector, () =>
2725
- // the host-element default must resolve INSIDE the sensor context, not as a
2726
- // parameter default (which would run before the injector wrapper)
2727
- createElementVisibility(target ?? inject(ElementRef), opt));
2779
+ return runInSensorContext(opt?.injector, () => createElementVisibility(target ?? inject(ElementRef), opt));
2728
2780
  }
2729
2781
  function createElementVisibility(target, opt) {
2730
2782
  if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
@@ -2795,10 +2847,7 @@ function unwrap$1(target) {
2795
2847
  * ```
2796
2848
  */
2797
2849
  function focusWithin(target, opt) {
2798
- return runInSensorContext(opt?.injector, () =>
2799
- // the host-element default must resolve INSIDE the sensor context, not as a
2800
- // parameter default (which would run before the injector wrapper)
2801
- createFocusWithin(target ?? inject(ElementRef), opt));
2850
+ return runInSensorContext(opt?.injector, () => createFocusWithin(target ?? inject(ElementRef), opt));
2802
2851
  }
2803
2852
  function createFocusWithin(target, opt) {
2804
2853
  const debugName = opt?.debugName ?? 'focusWithin';
@@ -3275,8 +3324,6 @@ function createMousePosition(opt) {
3275
3324
  }
3276
3325
  pos.set({ x, y });
3277
3326
  };
3278
- // passive: the handler never calls preventDefault, and a non-passive touchmove on
3279
- // window forces the browser to wait on JS before scrolling (scroll jank on touch)
3280
3327
  const attach = (el) => {
3281
3328
  const controller = new AbortController();
3282
3329
  el.addEventListener('mousemove', updatePosition, {
@@ -3292,7 +3339,7 @@ function createMousePosition(opt) {
3292
3339
  return () => controller.abort();
3293
3340
  };
3294
3341
  if (isSignal(target)) {
3295
- // re-attach whenever the signal resolves to a (new) element — covers viewChild
3342
+ // covers viewChild case
3296
3343
  effect((cleanup) => {
3297
3344
  const el = resolve(target());
3298
3345
  if (!el)
@@ -3550,7 +3597,7 @@ function createPointerDrag(opt) {
3550
3597
  return;
3551
3598
  const matched = handleSelector
3552
3599
  ? e.target?.closest?.(handleSelector)
3553
- : el;
3600
+ : (e.target ?? el); // no selector: the pressed element itself
3554
3601
  if (!matched)
3555
3602
  return; // handleSelector set but pointerdown landed outside a handle
3556
3603
  if (stopPropagation)
@@ -3740,7 +3787,6 @@ function createScrollPosition(opt) {
3740
3787
  ms: throttle,
3741
3788
  });
3742
3789
  if (isSignal(target)) {
3743
- // re-attach whenever the signal resolves to a (new) element — covers viewChild
3744
3790
  effect((cleanup) => {
3745
3791
  const el = resolve(target());
3746
3792
  if (!el)
@@ -3934,7 +3980,6 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
3934
3980
  state.set(event);
3935
3981
  };
3936
3982
  const { destroyRef: providedDestroyRef,
3937
- // strip non-listener keys so they don't leak into addEventListener options
3938
3983
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
3939
3984
  injector: _injector,
3940
3985
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -3948,8 +3993,7 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
3948
3993
  resolved.addEventListener(eventName, handler, listenerOpts);
3949
3994
  cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
3950
3995
  }, { ...(ngDevMode ? { debugName: "effectRef" } : /* istanbul ignore next */ {}), injector });
3951
- // honor an explicit destroyRef for signal targets too — the effect would otherwise
3952
- // only follow the injector's lifetime, contradicting the documented option
3996
+ // honor an explicit destroyRef for signal targets
3953
3997
  providedDestroyRef?.onDestroy(() => effectRef.destroy());
3954
3998
  }
3955
3999
  else {
@@ -4185,6 +4229,228 @@ function isStore(value) {
4185
4229
  value[IS_STORE] === true);
4186
4230
  }
4187
4231
 
4232
+ function generateOrigin$1() {
4233
+ if (globalThis.crypto?.randomUUID)
4234
+ return globalThis.crypto.randomUUID();
4235
+ return Math.random().toString(36).substring(2);
4236
+ }
4237
+ const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4238
+ /**
4239
+ * Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
4240
+ * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4241
+ * descends only where refs differ. O(changed paths), not O(tree).
4242
+ */
4243
+ function diffNode(prev, next, path, ops) {
4244
+ if (Object.is(prev, next))
4245
+ return;
4246
+ if (isRecord(prev) && isRecord(next)) {
4247
+ for (const key of Object.keys(prev)) {
4248
+ if (!Object.hasOwn(next, key))
4249
+ ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
4250
+ }
4251
+ for (const key of Object.keys(next)) {
4252
+ if (!Object.hasOwn(prev, key)) {
4253
+ // added key: deliberately NO `prev` property (absent ≠ undefined)
4254
+ ops.push({ kind: 'set', path: [...path, key], next: next[key] });
4255
+ }
4256
+ else {
4257
+ diffNode(prev[key], next[key], [...path, key], ops);
4258
+ }
4259
+ }
4260
+ return;
4261
+ }
4262
+ if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4263
+ // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4264
+ // change is a whole unit — index attribution lies under insert/remove/reorder
4265
+ if (prev.length === next.length) {
4266
+ for (let i = 0; i < next.length; i++)
4267
+ diffNode(prev[i], next[i], [...path, i], ops);
4268
+ return;
4269
+ }
4270
+ ops.push({ kind: 'set', path, prev, next });
4271
+ return;
4272
+ }
4273
+ // leaf / type change / opaque — one unit, prev present (the slot existed)
4274
+ ops.push({ kind: 'set', path, prev, next });
4275
+ }
4276
+ /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4277
+ function applyAt(container, path, idx, op) {
4278
+ const seg = path[idx];
4279
+ const base = isPlainArray$1(container)
4280
+ ? container.slice()
4281
+ : isRecord(container)
4282
+ ? { ...container }
4283
+ : typeof seg === 'number'
4284
+ ? []
4285
+ : {};
4286
+ if (idx === path.length - 1) {
4287
+ if (op.kind === 'delete') {
4288
+ // arrays never receive deletes (length changes travel as whole-array sets)
4289
+ delete base[seg];
4290
+ }
4291
+ else {
4292
+ base[seg] = op.next;
4293
+ }
4294
+ return base;
4295
+ }
4296
+ base[seg] = applyAt(base[seg], path, idx + 1, op);
4297
+ return base;
4298
+ }
4299
+ /**
4300
+ * Pure, store-free application of ops onto a plain root value, returning the next immutable root
4301
+ * (structural-sharing along op paths, missing containers vivified `'auto'`-style). This is the
4302
+ * same transform {@link OpLog.apply} runs, extracted so a replica can fold a received batch into
4303
+ * a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
4304
+ * Accepts a batch or a bare op list.
4305
+ */
4306
+ function applyOps(root, ops) {
4307
+ const list = Array.isArray(ops) ? ops : ops.ops;
4308
+ let next = root;
4309
+ for (const op of list) {
4310
+ if (op.path.length === 0) {
4311
+ if (op.kind === 'set')
4312
+ next = op.next;
4313
+ continue; // a root delete is meaningless — ignore (mirrors OpLog.apply)
4314
+ }
4315
+ next = applyAt(next, op.path, 0, op);
4316
+ }
4317
+ return next;
4318
+ }
4319
+ /**
4320
+ * Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
4321
+ * {@link opLog}, exported so code outside a log can produce a batch — e.g. diffing a scratch
4322
+ * draft against a replica's current value to route a write to its owner). Trusts the
4323
+ * copy-on-write contract: an untouched subtree that kept its reference is skipped.
4324
+ */
4325
+ function diffOps(prev, next) {
4326
+ const ops = [];
4327
+ diffNode(prev, next, [], ops);
4328
+ return ops;
4329
+ }
4330
+ /**
4331
+ * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
4332
+ * `prev` — inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4333
+ * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4334
+ * carry — a wire-serialized batch that stripped them is not invertible.
4335
+ */
4336
+ function invertBatch(batch) {
4337
+ const ops = Array.isArray(batch) ? batch : batch.ops;
4338
+ const inverted = [];
4339
+ for (let i = ops.length - 1; i >= 0; i--) {
4340
+ const op = ops[i];
4341
+ if (op.kind === 'delete') {
4342
+ inverted.push({
4343
+ kind: 'set',
4344
+ path: op.path,
4345
+ next: op.prev,
4346
+ prev: undefined,
4347
+ });
4348
+ continue;
4349
+ }
4350
+ if (!Object.hasOwn(op, 'prev')) {
4351
+ inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4352
+ }
4353
+ else {
4354
+ inverted.push({
4355
+ kind: 'set',
4356
+ path: op.path,
4357
+ next: op.prev,
4358
+ prev: op.next,
4359
+ });
4360
+ }
4361
+ }
4362
+ return inverted;
4363
+ }
4364
+ /**
4365
+ * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4366
+ * immutably-updated objects) and emits its changes as minimal structural op batches — the
4367
+ * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4368
+ * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4369
+ *
4370
+ * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4371
+ * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4372
+ * effect. A batch therefore coalesces everything written in one tick — for coarser,
4373
+ * intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
4374
+ *
4375
+ * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4376
+ * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4377
+ * warning fires and nothing emits.
4378
+ *
4379
+ * ```ts
4380
+ * const s = store({ todos: [{ done: false }] });
4381
+ * const log = opLog(s, { origin: 'tab-a' });
4382
+ * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4383
+ * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4384
+ * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4385
+ * ```
4386
+ */
4387
+ function opLog(source, opt) {
4388
+ const origin = opt?.origin ?? generateOrigin$1();
4389
+ const storeKind = source[STORE_KIND];
4390
+ const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4391
+ if (isDevMode() && mutableSource) {
4392
+ console.warn('[@mmstack/primitives] opLog observes copy-on-write updates via reference identity — a MUTABLE store/signal mutates in place, so changes are invisible to it. Use an immutable store, or set whole values.');
4393
+ }
4394
+ let prevRoot = untracked(source);
4395
+ let version = 0;
4396
+ let destroyed = false;
4397
+ const subscribers = new Set();
4398
+ const latest = signal(null, ...(ngDevMode ? [{ debugName: "latest" }] : /* istanbul ignore next */ []));
4399
+ /** Diff now, emit if there's a delta, advance the baseline. */
4400
+ const flush = () => {
4401
+ if (destroyed)
4402
+ return;
4403
+ const next = untracked(source);
4404
+ if (Object.is(prevRoot, next))
4405
+ return;
4406
+ const ops = [];
4407
+ diffNode(prevRoot, next, [], ops);
4408
+ prevRoot = next;
4409
+ if (!ops.length)
4410
+ return; // fresh refs, equal values — spurious-write tolerance
4411
+ const batch = { origin, version: ++version, ops };
4412
+ latest.set(batch);
4413
+ for (const cb of [...subscribers])
4414
+ cb(batch);
4415
+ };
4416
+ const run = () => {
4417
+ source(); // track every commit…
4418
+ untracked(flush); // …and emit the delta since the last flush
4419
+ };
4420
+ // default driver is an Angular effect (needs an injector); a supplied driver runs injector-free
4421
+ // (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
4422
+ const ref = opt?.driver
4423
+ ? opt.driver(run)
4424
+ : effect(run, { injector: opt?.injector ?? inject(Injector) });
4425
+ return {
4426
+ latest: latest.asReadonly(),
4427
+ subscribe: (cb) => {
4428
+ subscribers.add(cb);
4429
+ return () => subscribers.delete(cb);
4430
+ },
4431
+ // the emission core, callable on demand — reads the source untracked, so it never disturbs the
4432
+ // driver's subscription; a subsequent scheduled run just finds the baseline already advanced
4433
+ flush: () => flush(),
4434
+ apply: (batchOrOps) => {
4435
+ const ops = Array.isArray(batchOrOps)
4436
+ ? batchOrOps
4437
+ : batchOrOps.ops;
4438
+ if (!ops.length)
4439
+ return;
4440
+ // pending local writes must emit BEFORE the baseline advances past them
4441
+ flush();
4442
+ const root = applyOps(untracked(source), ops); // one atomic root, structural-shared
4443
+ source.set(root);
4444
+ prevRoot = root; // baseline advance: an applied batch never echoes
4445
+ },
4446
+ destroy: () => {
4447
+ destroyed = true;
4448
+ subscribers.clear();
4449
+ ref.destroy();
4450
+ },
4451
+ };
4452
+ }
4453
+
4188
4454
  /**
4189
4455
  * @internal Reads (or lazily builds + caches) the child node proxy for `prop` on `target`,
4190
4456
  * holding it via a `WeakRef` and registering it for finalizer-driven cache pruning. The cache
@@ -4291,8 +4557,7 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4291
4557
  vivify,
4292
4558
  noUnionLeaves,
4293
4559
  [STORE_SHARED_GLOBALS]: {
4294
- // the `injector!` reads run only when a global is absent, which (per hasSharedGlobals) means
4295
- // an injector was resolved above
4560
+ // the `injector!` reads run only when a global is absent
4296
4561
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4297
4562
  cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
4298
4563
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -4372,8 +4637,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4372
4637
  injector,
4373
4638
  vivify,
4374
4639
  noUnionLeaves,
4375
- // forward the resolved globals — re-resolving from the injector both re-injects
4376
- // needlessly and breaks in DI-less (worker) mode where injector is undefined
4377
4640
  [STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
4378
4641
  }));
4379
4642
  };
@@ -4389,8 +4652,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4389
4652
  return arrayLength();
4390
4653
  if (prop === Symbol.iterator)
4391
4654
  return function* () {
4392
- // read length reactively: a spread/for-of inside a computed/effect must re-run
4393
- // when items are added or removed, not only when already-read elements change
4394
4655
  const len = arrayLength();
4395
4656
  for (let i = 0; i < len(); i++)
4396
4657
  yield receiver[i];
@@ -4634,9 +4895,6 @@ function forkStore(base, opt) {
4634
4895
  const merge = reconcile;
4635
4896
  const staged = linkedSignal({ ...(ngDevMode ? { debugName: "staged" } : /* istanbul ignore next */ {}), source: () => base(),
4636
4897
  computation: (theirs, prev) => prev === undefined ? theirs : merge(prev.source, prev.value, theirs) });
4637
- // Inherit the base's shared options (injector, vivify, noUnionLeaves + the
4638
- // proxy cache/registry), same as extendStore — a fork should vivify like its
4639
- // base and share its injector-scoped cache. `opt` overrides (advanced use).
4640
4898
  const store = toStore(staged, {
4641
4899
  ...base[STORE_SHARED_OPTIONS],
4642
4900
  ...opt,
@@ -4645,233 +4903,678 @@ function forkStore(base, opt) {
4645
4903
  store,
4646
4904
  commit: () => base.set(untracked(staged)),
4647
4905
  discard: () => staged.set(untracked(base)),
4906
+ ops: () => diffOps(untracked(base), untracked(staged)),
4648
4907
  };
4649
4908
  }
4650
4909
 
4651
- function generateOrigin() {
4652
- if (globalThis.crypto?.randomUUID)
4653
- return globalThis.crypto.randomUUID();
4654
- return Math.random().toString(36).substring(2);
4910
+ /** Total order over stamps alone; ties break on `writer` via {@link compareTotal}. */
4911
+ function compareHlc(a, b) {
4912
+ return a.p !== b.p ? a.p - b.p : a.l - b.l;
4913
+ }
4914
+ /** The protocol's total order: (hlc.p, hlc.l, writer). Never returns 0 for distinct writers. */
4915
+ function compareTotal(a, writerA, b, writerB) {
4916
+ const byClock = compareHlc(a, b);
4917
+ if (byClock !== 0)
4918
+ return byClock;
4919
+ return writerA < writerB ? -1 : writerA > writerB ? 1 : 0;
4920
+ }
4921
+ const SKEW_WARN_MS = 5 * 60_000;
4922
+ /**
4923
+ * HLC per Kulkarni et al.: convergence never depends on wall clocks, but LWW fairness
4924
+ * degrades under large skew, so observing a remote clock far ahead warns in dev mode.
4925
+ */
4926
+ function createHlcClock(now = Date.now) {
4927
+ let p = 0;
4928
+ let l = 0;
4929
+ const advance = (wall, observed) => {
4930
+ const nextP = Math.max(p, wall, observed?.p ?? 0);
4931
+ if (nextP === p) {
4932
+ l = Math.max(l, observed && observed.p === nextP ? observed.l : 0) + 1;
4933
+ }
4934
+ else {
4935
+ p = nextP;
4936
+ l = observed && observed.p === nextP ? observed.l + 1 : 0;
4937
+ }
4938
+ };
4939
+ return {
4940
+ next: () => {
4941
+ advance(now());
4942
+ return { p, l };
4943
+ },
4944
+ observe: (remote) => {
4945
+ const wall = now();
4946
+ if (isDevMode() && remote.p - wall > SKEW_WARN_MS) {
4947
+ console.warn(`[@mmstack/primitives] observed remote clock ${Math.round((remote.p - wall) / 1000)}s ahead — convergence holds, but last-writer-wins fairness degrades under clock skew`);
4948
+ }
4949
+ advance(wall, remote);
4950
+ },
4951
+ };
4655
4952
  }
4656
- const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4953
+
4954
+ const OP_PROTO_VERSION = 1;
4955
+ const CONFLICT_BRAND = '~mmstackConflict';
4956
+ function isConflicted(value) {
4957
+ return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
4958
+ }
4959
+ const lww = (_ancestor, mine) => mine;
4960
+ const mergeThree = (ancestor, mine, theirs) => merge3(ancestor, mine, theirs);
4961
+ const preserve = (ancestor, mine, theirs) => ({ [CONFLICT_BRAND]: true, mine, theirs, ancestor });
4657
4962
  /**
4658
- * Reference-identity-pruned structural diff the same short-circuit discipline as `merge3`:
4659
- * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4660
- * descends only where refs differ. O(changed paths), not O(tree).
4963
+ * Identity-aware array merge (op-protocol RFC §12 v0): reconciles two concurrent versions of
4964
+ * an array item-wise by a user-provided identity, instead of last-writer-wins on the whole
4965
+ * array. Items are matched by key; per-item fields merge via `merge3` against the ancestor
4966
+ * item; items added on either side survive; an item removed on either side and unedited on
4967
+ * the other stays removed. Item ORDER follows `mine` (the total-order winner), with `theirs`-
4968
+ * only additions appended — positional merging is out of scope (fractional indexing is the
4969
+ * known upgrade if dogfooding demands it). Arrays still TRAVEL as whole-value sets; identity
4970
+ * only shapes conflict resolution, so the wire format is untouched.
4661
4971
  */
4662
- function diffNode(prev, next, path, ops) {
4663
- if (Object.is(prev, next))
4664
- return;
4665
- if (isRecord(prev) && isRecord(next)) {
4666
- for (const key of Object.keys(prev)) {
4667
- if (!Object.hasOwn(next, key))
4668
- ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
4972
+ function keyedArray(identity, opt) {
4973
+ const mergeItem = opt?.item ?? mergeThree;
4974
+ return (ancestor, mine, theirs, ctx) => {
4975
+ if (!Array.isArray(mine) || !Array.isArray(theirs)) {
4976
+ return mine; // type conflict → total-order winner, like lww
4669
4977
  }
4670
- for (const key of Object.keys(next)) {
4671
- if (!Object.hasOwn(prev, key)) {
4672
- // added key: deliberately NO `prev` property (absent ≠ undefined)
4673
- ops.push({ kind: 'set', path: [...path, key], next: next[key] });
4978
+ const anc = Array.isArray(ancestor) ? ancestor : [];
4979
+ const byKey = (arr) => {
4980
+ const map = new Map();
4981
+ for (const item of arr)
4982
+ map.set(identity(item), item);
4983
+ return map;
4984
+ };
4985
+ const ancMap = byKey(anc);
4986
+ const mineMap = byKey(mine);
4987
+ const theirsMap = byKey(theirs);
4988
+ const out = [];
4989
+ for (const item of mine) {
4990
+ const key = identity(item);
4991
+ const other = theirsMap.get(key);
4992
+ const base = ancMap.get(key);
4993
+ if (theirsMap.has(key)) {
4994
+ out.push(structuralEq(item, other)
4995
+ ? item
4996
+ : mergeItem(base, item, other, ctx));
4674
4997
  }
4675
- else {
4676
- diffNode(prev[key], next[key], [...path, key], ops);
4998
+ else if (!ancMap.has(key) || !structuralEq(item, base)) {
4999
+ out.push(item); // added by mine, or edited by mine while theirs removed it → keep
4677
5000
  }
5001
+ // else: theirs removed it and mine left it untouched → stays removed
4678
5002
  }
4679
- return;
4680
- }
4681
- if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4682
- // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4683
- // change is a whole unit — index attribution lies under insert/remove/reorder
4684
- if (prev.length === next.length) {
4685
- for (let i = 0; i < next.length; i++)
4686
- diffNode(prev[i], next[i], [...path, i], ops);
4687
- return;
5003
+ for (const item of theirs) {
5004
+ const key = identity(item);
5005
+ if (mineMap.has(key))
5006
+ continue;
5007
+ if (!ancMap.has(key) || !structuralEq(item, ancMap.get(key))) {
5008
+ out.push(item); // added by theirs, or edited by theirs while mine removed it → keep
5009
+ }
4688
5010
  }
4689
- ops.push({ kind: 'set', path, prev, next });
4690
- return;
4691
- }
4692
- // leaf / type change / opaque — one unit, prev present (the slot existed)
4693
- ops.push({ kind: 'set', path, prev, next });
5011
+ return out;
5012
+ };
4694
5013
  }
4695
- /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4696
- function applyAt(container, path, idx, op) {
4697
- const seg = path[idx];
4698
- const base = isPlainArray$1(container)
4699
- ? container.slice()
4700
- : isRecord(container)
4701
- ? { ...container }
4702
- : typeof seg === 'number'
4703
- ? []
4704
- : {};
4705
- if (idx === path.length - 1) {
4706
- if (op.kind === 'delete') {
4707
- // arrays never receive deletes (length changes travel as whole-array sets)
4708
- delete base[seg];
5014
+ function compilePolicies(entries) {
5015
+ return entries.map((e) => ({
5016
+ segments: typeof e.path === 'string' ? e.path.split('.') : e.path.map(String),
5017
+ merge: e.merge,
5018
+ }));
5019
+ }
5020
+ function policyFor(policies, path) {
5021
+ outer: for (const p of policies) {
5022
+ if (p.segments.length !== path.length)
5023
+ continue;
5024
+ for (let i = 0; i < path.length; i++) {
5025
+ if (p.segments[i] !== '*' && p.segments[i] !== String(path[i]))
5026
+ continue outer;
4709
5027
  }
4710
- else {
4711
- base[seg] = op.next;
5028
+ return p.merge;
5029
+ }
5030
+ return lww;
5031
+ }
5032
+ const SEP = '';
5033
+ const keyOf$1 = (path) => path.map(String).join(SEP);
5034
+ function structuralEq(a, b) {
5035
+ if (Object.is(a, b))
5036
+ return true;
5037
+ if (typeof a !== 'object' ||
5038
+ typeof b !== 'object' ||
5039
+ a === null ||
5040
+ b === null ||
5041
+ Array.isArray(a) !== Array.isArray(b)) {
5042
+ return false;
5043
+ }
5044
+ const ka = Object.keys(a);
5045
+ const kb = Object.keys(b);
5046
+ if (ka.length !== kb.length)
5047
+ return false;
5048
+ for (const k of ka) {
5049
+ if (!Object.hasOwn(b, k))
5050
+ return false;
5051
+ if (!structuralEq(a[k], b[k])) {
5052
+ return false;
4712
5053
  }
4713
- return base;
4714
5054
  }
4715
- base[seg] = applyAt(base[seg], path, idx + 1, op);
4716
- return base;
5055
+ return true;
4717
5056
  }
5057
+ // total order (hlc, writer, origin): two origins can share a writer AND a stamp
5058
+ // (independent clocks, same ms), so only origin makes the order strict
5059
+ const compareStamp = (a, b) => {
5060
+ const byTotal = compareTotal(a.hlc, a.writer, b.hlc, b.writer);
5061
+ if (byTotal !== 0)
5062
+ return byTotal;
5063
+ return a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0;
5064
+ };
5065
+ const beats = (a, b) => compareStamp(a, b) > 0;
4718
5066
  /**
4719
- * Pure, store-free application of ops onto a plain root value, returning the next immutable root
4720
- * (structural-sharing along op paths, missing containers vivified `'auto'`-style). This is the
4721
- * same transform {@link OpLog.apply} runs, extracted so a replica can fold a received batch into
4722
- * a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
4723
- * Accepts a batch or a bare op list.
5067
+ * The unsequenced-topology convergence core (op-protocol RFC §4): a per-path last-writer-wins
5068
+ * register map over the total order (hlc, writer), with subtree dominance. Order-independent:
5069
+ * any arrival order of the same envelope set yields the same state.
4724
5070
  */
4725
- function applyOps(root, ops) {
4726
- const list = Array.isArray(ops) ? ops : ops.ops;
4727
- let next = root;
4728
- for (const op of list) {
4729
- if (op.path.length === 0) {
4730
- if (op.kind === 'set')
4731
- next = op.next;
4732
- continue; // a root delete is meaningless — ignore (mirrors OpLog.apply)
5071
+ function createConvergingApply(opt) {
5072
+ const registers = new Map();
5073
+ const policies = compilePolicies(opt?.policies ?? []);
5074
+ const resolveConcurrent = (winner, loser, path) => {
5075
+ const merge = policyFor(policies, path);
5076
+ if (merge === lww || winner.kind === 'delete' || loser.kind === 'delete') {
5077
+ return winner;
4733
5078
  }
4734
- next = applyAt(next, op.path, 0, op);
5079
+ const resolved = merge(loser.prev, winner.next, loser.next, { path });
5080
+ if (Object.is(resolved, winner.next))
5081
+ return winner;
5082
+ return { kind: 'set', path, next: resolved, prev: winner.next };
5083
+ };
5084
+ // a sequential edit carries the value it overwrote; a mismatch means neither saw the other.
5085
+ // Structural, not referential: identity never survives the wire, so a peer that built on
5086
+ // the replicated copy of a value must still count as sequential.
5087
+ const concurrentWith = (incoming, registered) => {
5088
+ if (incoming.kind === 'delete' || registered.kind === 'delete')
5089
+ return false;
5090
+ if (!Object.hasOwn(incoming, 'prev'))
5091
+ return true;
5092
+ return !structuralEq(incoming.prev, registered.next);
5093
+ };
5094
+ return {
5095
+ ingest: (env, o) => {
5096
+ const stamp = { hlc: env.hlc, writer: env.writer, origin: env.origin };
5097
+ const out = [];
5098
+ for (const op of env.ops) {
5099
+ const key = keyOf$1(op.path);
5100
+ let dominated = false;
5101
+ let exact;
5102
+ for (let len = 0; len <= op.path.length; len++) {
5103
+ const reg = registers.get(keyOf$1(op.path.slice(0, len)));
5104
+ if (!reg)
5105
+ continue;
5106
+ if (len === op.path.length)
5107
+ exact = reg;
5108
+ else if (beats(reg, stamp)) {
5109
+ dominated = true;
5110
+ break;
5111
+ }
5112
+ }
5113
+ if (dominated)
5114
+ continue;
5115
+ if (exact && beats(exact, stamp)) {
5116
+ if (concurrentWith(op, exact.op)) {
5117
+ const resolved = resolveConcurrent(exact.op, op, op.path);
5118
+ if (resolved !== exact.op) {
5119
+ exact.op = resolved;
5120
+ if (!o?.local)
5121
+ out.push(resolved);
5122
+ }
5123
+ }
5124
+ continue;
5125
+ }
5126
+ let accepted = op;
5127
+ if (exact && concurrentWith(op, exact.op)) {
5128
+ accepted = resolveConcurrent(op, exact.op, op.path);
5129
+ }
5130
+ const isDescendant = key === ''
5131
+ ? (k) => k !== ''
5132
+ : (k) => k.startsWith(key + SEP);
5133
+ const replays = [];
5134
+ for (const [k, reg] of registers) {
5135
+ if (!isDescendant(k))
5136
+ continue;
5137
+ if (beats(stamp, reg))
5138
+ registers.delete(k);
5139
+ else
5140
+ replays.push(reg);
5141
+ }
5142
+ replays.sort(compareStamp);
5143
+ registers.set(key, { hlc: env.hlc, writer: env.writer, origin: env.origin, op: accepted });
5144
+ if (!o?.local) {
5145
+ out.push(accepted);
5146
+ for (const r of replays)
5147
+ out.push(r.op);
5148
+ }
5149
+ }
5150
+ return out;
5151
+ },
5152
+ reset: () => registers.clear(),
5153
+ };
5154
+ }
5155
+ function getAtPath(root, path) {
5156
+ let cur = root;
5157
+ for (const seg of path) {
5158
+ if (cur === null || typeof cur !== 'object')
5159
+ return undefined;
5160
+ cur = cur[seg];
4735
5161
  }
4736
- return next;
5162
+ return cur;
4737
5163
  }
4738
5164
  /**
4739
- * Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
4740
- * {@link opLog}, exported so code outside a log can produce a batch — e.g. diffing a scratch
4741
- * draft against a replica's current value to route a write to its owner). Trusts the
4742
- * copy-on-write contract: an untouched subtree that kept its reference is skipped.
5165
+ * The shared rebase routine (op-protocol RFC §5): invert pending, apply remote, re-apply
5166
+ * pending through the merge policies. Pure branching's `rebase()` and the sequenced relay
5167
+ * client both call this.
4743
5168
  */
4744
- function diffOps(prev, next) {
4745
- const ops = [];
4746
- diffNode(prev, next, [], ops);
4747
- return ops;
5169
+ function rebaseOps(root, pending, remote, policies) {
5170
+ const compiled = compilePolicies(policies ?? []);
5171
+ let base = root;
5172
+ for (let i = pending.length - 1; i >= 0; i--) {
5173
+ base = applyOps(base, invertBatch(pending[i]));
5174
+ }
5175
+ base = applyOps(base, remote);
5176
+ const rebased = [];
5177
+ for (const batch of pending) {
5178
+ const next = [];
5179
+ for (const op of batch) {
5180
+ const cur = getAtPath(base, op.path);
5181
+ if (op.kind === 'delete') {
5182
+ next.push({ kind: 'delete', path: op.path, prev: cur });
5183
+ }
5184
+ else if (cur === undefined) {
5185
+ next.push({ kind: 'set', path: op.path, next: op.next });
5186
+ }
5187
+ else if (Object.hasOwn(op, 'prev') && !structuralEq(op.prev, cur)) {
5188
+ const merge = policyFor(compiled, op.path);
5189
+ const resolved = merge(op.prev, op.next, cur, { path: op.path });
5190
+ next.push({ kind: 'set', path: op.path, next: resolved, prev: cur });
5191
+ }
5192
+ else {
5193
+ next.push({ kind: 'set', path: op.path, next: op.next, prev: cur });
5194
+ }
5195
+ }
5196
+ base = applyOps(base, next);
5197
+ rebased.push(next);
5198
+ }
5199
+ return { root: base, pending: rebased };
4748
5200
  }
4749
5201
  /**
4750
- * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add a `set` with no
4751
- * `prev` inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4752
- * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4753
- * carry a wire-serialized batch that stripped them is not invertible.
5202
+ * A per-path-policy `ForkStrategy` for `forkStore`: a three-way reconcile built from the
5203
+ * shared rebase (invert mine apply theirs' delta re-apply mine through the policies).
5204
+ * Paths only one side touched resolve like `merge3`; paths BOTH touched go through the
5205
+ * matching {@link MergePolicyEntry} (`lww` default fork wins, matching `'fine'`; or
5206
+ * `mergeThree` / `preserve` / custom). Same copy-on-write contract as `'fine'`.
4754
5207
  */
4755
- function invertBatch(batch) {
4756
- const ops = Array.isArray(batch) ? batch : batch.ops;
4757
- const inverted = [];
4758
- for (let i = ops.length - 1; i >= 0; i--) {
4759
- const op = ops[i];
4760
- if (op.kind === 'delete') {
4761
- inverted.push({
4762
- kind: 'set',
4763
- path: op.path,
4764
- next: op.prev,
4765
- prev: undefined,
4766
- });
4767
- continue;
4768
- }
4769
- if (!Object.hasOwn(op, 'prev')) {
4770
- inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4771
- }
4772
- else {
4773
- inverted.push({
4774
- kind: 'set',
4775
- path: op.path,
4776
- next: op.prev,
4777
- prev: op.next,
4778
- });
4779
- }
4780
- }
4781
- return inverted;
5208
+ function policyStrategy(policies) {
5209
+ return (ancestor, mine, theirs) => rebaseOps(mine, [diffOps(ancestor, mine)], diffOps(ancestor, theirs), policies).root;
5210
+ }
5211
+ function generateOrigin() {
5212
+ if (globalThis.crypto?.randomUUID)
5213
+ return globalThis.crypto.randomUUID();
5214
+ return Math.random().toString(36).substring(2);
4782
5215
  }
4783
5216
  /**
4784
- * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4785
- * immutably-updated objects) and emits its changes as minimal structural op batches — the
4786
- * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4787
- * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4788
- *
4789
- * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4790
- * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4791
- * effect. A batch therefore coalesces everything written in one tick — for coarser,
4792
- * intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
4793
- *
4794
- * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4795
- * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4796
- * warning fires and nothing emits.
4797
- *
4798
- * ```ts
4799
- * const s = store({ todos: [{ done: false }] });
4800
- * const log = opLog(s, { origin: 'tab-a' });
4801
- * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4802
- * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4803
- * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4804
- * ```
5217
+ * Wires a copy-on-write signal (a `store` root) to the op protocol: local writes emit
5218
+ * stamped envelopes, received envelopes fold in through the converging apply. The
5219
+ * unsequenced-topology client core that `tabSync(store)` and P2P transports build on.
4805
5220
  */
4806
- function opLog(source, opt) {
4807
- const origin = opt?.origin ?? generateOrigin();
4808
- // a store proxy's `has` trap answers for the VALUE's keys, so `isMutable`'s `'mutate' in`
4809
- // probe can't see the brand — ask the store's own kind symbol first
4810
- const storeKind = source[STORE_KIND];
4811
- const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4812
- if (isDevMode() && mutableSource) {
4813
- console.warn('[@mmstack/primitives] opLog observes copy-on-write updates via reference identity — a MUTABLE store/signal mutates in place, so changes are invisible to it. Use an immutable store, or set whole values.');
4814
- }
4815
- let prevRoot = untracked(source);
4816
- let version = 0;
4817
- let destroyed = false;
5221
+ const RECENT_LOCAL_CAP = 64;
5222
+ function opSync(source, opt) {
5223
+ const origin = opt.origin ?? generateOrigin();
5224
+ const clock = opt.clock ?? createHlcClock();
5225
+ const conv = createConvergingApply({ policies: opt.policies });
4818
5226
  const subscribers = new Set();
4819
- const latest = signal(null, ...(ngDevMode ? [{ debugName: "latest" }] : /* istanbul ignore next */ []));
4820
- /** Diff now, emit if there's a delta, advance the baseline. */
4821
- const flush = () => {
4822
- if (destroyed)
4823
- return;
4824
- const next = untracked(source);
4825
- if (Object.is(prevRoot, next))
4826
- return;
4827
- const ops = [];
4828
- diffNode(prevRoot, next, [], ops);
4829
- prevRoot = next;
4830
- if (!ops.length)
4831
- return; // fresh refs, equal values — spurious-write tolerance
4832
- const batch = { origin, version: ++version, ops };
4833
- latest.set(batch);
5227
+ const versions = new Map();
5228
+ const recentLocal = [];
5229
+ let version = 0;
5230
+ const log = opLog(source, opt.driver
5231
+ ? { origin, driver: opt.driver }
5232
+ : { origin, injector: opt.injector ?? inject(Injector) });
5233
+ const emitLocal = (ops) => {
5234
+ const env = {
5235
+ proto: OP_PROTO_VERSION,
5236
+ origin,
5237
+ writer: opt.writer,
5238
+ version: ++version,
5239
+ hlc: clock.next(),
5240
+ policyVersion: opt.policyVersion ?? 0,
5241
+ ops,
5242
+ };
5243
+ versions.set(origin, env.version);
5244
+ conv.ingest(env, { local: true });
5245
+ recentLocal.push(env);
5246
+ if (recentLocal.length > RECENT_LOCAL_CAP)
5247
+ recentLocal.shift();
4834
5248
  for (const cb of [...subscribers])
4835
- cb(batch);
5249
+ cb(env);
4836
5250
  };
4837
- const run = () => {
4838
- source(); // track every commit…
4839
- untracked(flush); // …and emit the delta since the last flush
4840
- };
4841
- // default driver is an Angular effect (needs an injector); a supplied driver runs injector-free
4842
- // (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
4843
- const ref = opt?.driver
4844
- ? opt.driver(run)
4845
- : effect(run, { injector: opt?.injector ?? inject(Injector) });
5251
+ const unsub = log.subscribe((batch) => emitLocal(batch.ops));
4846
5252
  return {
4847
- latest: latest.asReadonly(),
5253
+ origin,
4848
5254
  subscribe: (cb) => {
4849
5255
  subscribers.add(cb);
4850
5256
  return () => subscribers.delete(cb);
4851
5257
  },
4852
- // the emission core, callable on demand — reads the source untracked, so it never disturbs the
4853
- // driver's subscription; a subsequent scheduled run just finds the baseline already advanced
4854
- flush: () => flush(),
4855
- apply: (batchOrOps) => {
4856
- const ops = Array.isArray(batchOrOps)
4857
- ? batchOrOps
4858
- : batchOrOps.ops;
4859
- if (!ops.length)
5258
+ receive: (env) => {
5259
+ if (env.origin === origin)
4860
5260
  return;
4861
- // pending local writes must emit BEFORE the baseline advances past them
4862
- flush();
4863
- const root = applyOps(untracked(source), ops); // one atomic root, structural-shared
4864
- source.set(root);
4865
- prevRoot = root; // baseline advance: an applied batch never echoes
5261
+ if (env.proto !== OP_PROTO_VERSION) {
5262
+ if (isDevMode()) {
5263
+ console.warn(`[@mmstack/primitives] dropped envelope with proto ${env.proto} (expected ${OP_PROTO_VERSION})`);
5264
+ }
5265
+ return;
5266
+ }
5267
+ clock.observe(env.hlc);
5268
+ const known = versions.get(env.origin);
5269
+ if (known !== undefined && env.version <= known)
5270
+ return; // duplicate/covered — idempotent
5271
+ if (known !== undefined && env.version !== known + 1) {
5272
+ opt.onGap?.(env.origin, known + 1, env.version);
5273
+ }
5274
+ versions.set(env.origin, env.version);
5275
+ log.flush();
5276
+ const ops = conv.ingest(env);
5277
+ if (ops.length)
5278
+ log.apply(ops);
5279
+ },
5280
+ flush: () => log.flush(),
5281
+ watermark: () => Object.fromEntries(versions),
5282
+ snapshot: () => {
5283
+ log.flush();
5284
+ return { root: untracked(source), wm: Object.fromEntries(versions) };
5285
+ },
5286
+ seed: () => {
5287
+ log.flush();
5288
+ emitLocal([{ kind: 'set', path: [], next: untracked(source) }]);
5289
+ },
5290
+ hydrate: (root, wm) => {
5291
+ log.flush();
5292
+ const covered = wm?.[origin] ?? 0;
5293
+ const pending = recentLocal.filter((e) => e.version > covered);
5294
+ conv.reset();
5295
+ let next = root;
5296
+ for (const e of pending)
5297
+ next = applyOps(next, e.ops);
5298
+ log.apply([{ kind: 'set', path: [], next }]);
5299
+ for (const [o, v] of Object.entries(wm ?? {})) {
5300
+ versions.set(o, Math.max(versions.get(o) ?? 0, v));
5301
+ }
5302
+ for (const e of pending)
5303
+ conv.ingest(e, { local: true });
4866
5304
  },
4867
5305
  destroy: () => {
4868
- destroyed = true;
5306
+ unsub();
4869
5307
  subscribers.clear();
4870
- ref.destroy();
5308
+ log.destroy();
5309
+ },
5310
+ };
5311
+ }
5312
+
5313
+ /**
5314
+ * Undo/redo for a copy-on-write store, built on the op-log: each tracked change is stored as
5315
+ * its inverse batch, so `undo()` is one `apply` and history costs only the diffs, not full
5316
+ * snapshots. Redoing is invert-of-the-inverse. A new edit made after an undo clears the redo
5317
+ * stack (linear history). Applying a redo/undo does not itself re-enter history.
5318
+ *
5319
+ * Composes with sync for collaborative undo: pass `track: syncClient` so only YOUR writes are
5320
+ * undoable, while `undo()` emits a normal op that propagates to peers (it writes through the
5321
+ * store, which the sync client picks up).
5322
+ */
5323
+ function storeHistory(source, opt) {
5324
+ const limit = opt?.limit ?? 100;
5325
+ const logOpt = { origin: opt?.origin };
5326
+ if (opt?.driver)
5327
+ logOpt.driver = opt.driver;
5328
+ else
5329
+ logOpt.injector = opt?.injector ?? inject(Injector);
5330
+ const log = opLog(source, logOpt);
5331
+ const undoStack = [];
5332
+ const redoStack = [];
5333
+ const version = signal(0, ...(ngDevMode ? [{ debugName: "version" }] : /* istanbul ignore next */ [])); // monotonic: bumps on every mutation so the computeds recompute
5334
+ let applying = false;
5335
+ const push = (stack, inverse) => {
5336
+ stack.push(inverse);
5337
+ if (stack.length > limit)
5338
+ stack.shift();
5339
+ };
5340
+ const record = (batch) => {
5341
+ if (applying)
5342
+ return; // an undo/redo's own emission must not re-enter history
5343
+ if (!batch.ops.length)
5344
+ return;
5345
+ push(undoStack, invertBatch(batch));
5346
+ redoStack.length = 0; // a fresh edit forks the timeline
5347
+ version.update((v) => v + 1);
5348
+ };
5349
+ // track the sync client's local stream when given, else self-diff every store change
5350
+ const unsub = (opt?.track ?? log).subscribe(record);
5351
+ const run = (from, to) => {
5352
+ const inverse = from.pop();
5353
+ if (!inverse)
5354
+ return;
5355
+ log.flush(); // settle pending local writes before applying
5356
+ applying = true;
5357
+ try {
5358
+ log.apply(inverse);
5359
+ }
5360
+ finally {
5361
+ applying = false;
5362
+ }
5363
+ push(to, invertBatch(inverse)); // the inverse of what we applied restores the other direction
5364
+ version.update((v) => v + 1);
5365
+ };
5366
+ return {
5367
+ canUndo: computed(() => (version(), undoStack.length > 0)),
5368
+ canRedo: computed(() => (version(), redoStack.length > 0)),
5369
+ undo: () => run(undoStack, redoStack),
5370
+ redo: () => run(redoStack, undoStack),
5371
+ clear: () => {
5372
+ undoStack.length = 0;
5373
+ redoStack.length = 0;
5374
+ version.update((v) => v + 1);
5375
+ },
5376
+ destroy: () => {
5377
+ unsub();
5378
+ log.destroy();
4871
5379
  },
4872
5380
  };
4873
5381
  }
4874
5382
 
5383
+ const PERSISTED_STORE_OPTIONS = new InjectionToken('@mmstack/primitives:persisted-store-options');
5384
+ /**
5385
+ * Wire the {@link AsyncStore} backend (and any shared debounce) once, override per call. The
5386
+ * typical use is to install idb-keyval at bootstrap so every `persist`/`persistedStore` persists
5387
+ * without re-passing the backend.
5388
+ *
5389
+ * @example
5390
+ * import * as idbKeyval from 'idb-keyval';
5391
+ * providePersistedStoreOptions({ store: idbKeyval });
5392
+ */
5393
+ function providePersistedStoreOptions(opt) {
5394
+ return { provide: PERSISTED_STORE_OPTIONS, useValue: opt };
5395
+ }
5396
+ /**
5397
+ * Attach durable local persistence to an EXISTING store: its whole-value snapshot is written to an
5398
+ * async backend (IndexedDB via idb-keyval or Dexie) and restored on boot. A reader over the store,
5399
+ * so it composes with the other op-log readers (`tabSync`, `@mmstack/mesh`) on the same store — a
5400
+ * persisted, synced graph is just two readers. Local durability, not sync.
5401
+ *
5402
+ * Because the backend is async, hydration cannot precede the first read: the store keeps its current
5403
+ * value, then adopts the persisted snapshot once the backend answers, UNLESS a write happened first
5404
+ * (an explicit boot-time write wins over stale disk). Writes are coalesced and flushed on teardown
5405
+ * and on page hide, so the last change is never lost. On the server it is a no-op.
5406
+ *
5407
+ * When the persisted shape evolves, pass `version` and a `migrate` hook: an older snapshot is
5408
+ * brought forward on boot before it is adopted, then re-persisted in the new shape. Because boot is
5409
+ * already async, `migrate` may be async, so the migration ladder can be lazy-imported.
5410
+ */
5411
+ function persist(source, opt) {
5412
+ const injector = opt.injector ?? inject(Injector);
5413
+ const defaults = injector.get(PERSISTED_STORE_OPTIONS, null);
5414
+ const key = opt.key;
5415
+ const backend = opt.store ?? defaults?.store;
5416
+ const serialize = opt.serialize ?? ((v) => v);
5417
+ const deserialize = opt.deserialize ?? ((r) => r);
5418
+ const version = opt.version;
5419
+ const debounceMs = opt.writeDebounceMs ?? defaults?.writeDebounceMs ?? 300;
5420
+ const read = source;
5421
+ const setRoot = (value) => source.set(value);
5422
+ const VERSION_KEY = '__mmstack_pv';
5423
+ const encode = (value) => version === undefined
5424
+ ? serialize(value)
5425
+ : { [VERSION_KEY]: version, data: serialize(value) };
5426
+ const isServer = isPlatformServer(injector.get(PLATFORM_ID));
5427
+ const initialRef = untracked(read); // copy-on-write: an untouched store keeps this reference
5428
+ const hydrated = signal(false, ...(ngDevMode ? [{ debugName: "hydrated" }] : /* istanbul ignore next */ []));
5429
+ if (isServer || !backend) {
5430
+ if (!backend && !isServer && isDevMode()) {
5431
+ console.warn(`[@mmstack/primitives] persist("${key}"): no AsyncStore backend (pass { store } or providePersistedStoreOptions). Running in-memory, not persisted.`);
5432
+ }
5433
+ hydrated.set(true);
5434
+ return {
5435
+ hydrated: hydrated.asReadonly(),
5436
+ flush: () => Promise.resolve(),
5437
+ clear: () => {
5438
+ setRoot(initialRef);
5439
+ return Promise.resolve();
5440
+ },
5441
+ };
5442
+ }
5443
+ let persistedRef = initialRef;
5444
+ void (async () => {
5445
+ try {
5446
+ const raw = await backend.get(key);
5447
+ // apply the snapshot only if nothing wrote in the boot window (explicit write wins)
5448
+ if (raw !== undefined && raw !== null && untracked(read) === initialRef) {
5449
+ let fromVersion = 0;
5450
+ let payload = raw;
5451
+ if (typeof raw === 'object' &&
5452
+ raw !== null &&
5453
+ VERSION_KEY in raw) {
5454
+ const env = raw;
5455
+ fromVersion =
5456
+ typeof env[VERSION_KEY] === 'number'
5457
+ ? env[VERSION_KEY]
5458
+ : 0;
5459
+ payload = env['data'];
5460
+ }
5461
+ const target = version ?? 0;
5462
+ if (fromVersion > target) {
5463
+ if (isDevMode()) {
5464
+ console.warn(`[@mmstack/primitives] persist("${key}"): stored snapshot is version ${fromVersion} but this build is ${target}; leaving it untouched (a newer build wrote it).`);
5465
+ }
5466
+ }
5467
+ else {
5468
+ const migrated = !!(opt.migrate && fromVersion < target);
5469
+ let value = deserialize(payload);
5470
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
5471
+ if (migrated)
5472
+ value = await opt.migrate(value, fromVersion);
5473
+ if (untracked(read) === initialRef) {
5474
+ setRoot(value);
5475
+ if (!migrated)
5476
+ persistedRef = value;
5477
+ }
5478
+ }
5479
+ }
5480
+ }
5481
+ catch (err) {
5482
+ if (isDevMode()) {
5483
+ console.warn(`[@mmstack/primitives] persist("${key}") hydrate failed`, err);
5484
+ }
5485
+ }
5486
+ finally {
5487
+ hydrated.set(true);
5488
+ }
5489
+ })();
5490
+ let timer;
5491
+ const write = async (value) => {
5492
+ try {
5493
+ await backend.set(key, encode(value));
5494
+ persistedRef = value;
5495
+ }
5496
+ catch (err) {
5497
+ if (isDevMode()) {
5498
+ console.warn(`[@mmstack/primitives] persist("${key}") write failed`, err);
5499
+ }
5500
+ }
5501
+ };
5502
+ const cancelTimer = () => {
5503
+ if (timer !== undefined) {
5504
+ clearTimeout(timer);
5505
+ timer = undefined;
5506
+ }
5507
+ };
5508
+ const flush = async () => {
5509
+ cancelTimer();
5510
+ const current = untracked(read);
5511
+ if (!untracked(hydrated) ||
5512
+ current === initialRef ||
5513
+ current === persistedRef)
5514
+ return;
5515
+ await write(current);
5516
+ };
5517
+ effect(() => {
5518
+ if (!hydrated())
5519
+ return;
5520
+ const value = read();
5521
+ untracked(() => {
5522
+ cancelTimer();
5523
+ // untouched / reset-to-initial, or already the value on disk (e.g. just hydrated): skip
5524
+ if (value === initialRef || value === persistedRef)
5525
+ return;
5526
+ timer = setTimeout(() => {
5527
+ timer = undefined;
5528
+ void write(value);
5529
+ }, debounceMs);
5530
+ });
5531
+ }, { injector });
5532
+ const onHide = () => {
5533
+ void flush();
5534
+ };
5535
+ if (typeof document !== 'undefined') {
5536
+ document.addEventListener('visibilitychange', onHide);
5537
+ window.addEventListener('pagehide', onHide);
5538
+ }
5539
+ injector.get(DestroyRef).onDestroy(() => {
5540
+ void flush();
5541
+ if (typeof document !== 'undefined') {
5542
+ document.removeEventListener('visibilitychange', onHide);
5543
+ window.removeEventListener('pagehide', onHide);
5544
+ }
5545
+ });
5546
+ return {
5547
+ hydrated: hydrated.asReadonly(),
5548
+ flush,
5549
+ clear: async () => {
5550
+ cancelTimer();
5551
+ setRoot(initialRef); // back to initialRef, so the persist effect skips (no re-write over the delete)
5552
+ persistedRef = initialRef; // disk is now empty
5553
+ try {
5554
+ await backend.del(key);
5555
+ }
5556
+ catch (err) {
5557
+ if (isDevMode()) {
5558
+ console.warn(`[@mmstack/primitives] persist("${key}") clear failed`, err);
5559
+ }
5560
+ }
5561
+ },
5562
+ };
5563
+ }
5564
+ /**
5565
+ * A `store` with {@link persist} already attached: a whole-value snapshot persisted to an async
5566
+ * backend and restored on boot. Equivalent to `const s = store(initial); persist(s, opt)` — reach
5567
+ * for `persist` directly when you want persistence on a store you already have (e.g. to also
5568
+ * `meshSync` it).
5569
+ */
5570
+ function persistedStore(initial, opt) {
5571
+ const injector = opt.injector ?? inject(Injector);
5572
+ // store() reads only the signal/store opts it knows; the persistence keys ride along harmlessly
5573
+ const s = store(initial, { ...opt, injector });
5574
+ const handle = persist(s, { ...opt, injector });
5575
+ return { store: s, ...handle };
5576
+ }
5577
+
4875
5578
  const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
4876
5579
  function keyOf(item, key) {
4877
5580
  if (typeof key === 'function')
@@ -4949,13 +5652,9 @@ function reconcileValue(prev, next, key) {
4949
5652
  */
4950
5653
  function projection(fn, seed, opt) {
4951
5654
  const { key = 'id', ...storeOpt } = opt ?? {};
4952
- // linkedSignal rather than an effect-driven signal: the computation runs in the tracked
4953
- // context (fn's reads are dependencies) and `previous` hands back the last emitted value for
4954
- // the reconcile, so the projection is glitch-free, lazy, and needs no effect scheduler.
4955
5655
  const root = linkedSignal({ ...(ngDevMode ? { debugName: "root" } : /* istanbul ignore next */ {}), source: () => undefined,
4956
5656
  computation: (_, previous) => {
4957
5657
  const base = previous ? previous.value : seed;
4958
- // a plain mutable scratch seeded with the current value; fn mutates it or returns new data
4959
5658
  const draft = structuredClone(base);
4960
5659
  const returned = fn(draft);
4961
5660
  const next = (returned === undefined ? draft : returned);
@@ -5151,6 +5850,85 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
5151
5850
  return writable;
5152
5851
  }
5153
5852
 
5853
+ /** Op-mode sync for a writable store: hello exchange, then live envelopes (RFC §6 tab flavor). */
5854
+ function storeTabSync(sig, opt, bus, injector) {
5855
+ const sync = opSync(sig, {
5856
+ writer: opt.writer ?? 'local',
5857
+ policies: opt.policies,
5858
+ injector,
5859
+ });
5860
+ const helloTimeoutMs = opt.helloTimeoutMs ?? 250;
5861
+ const jitterMs = opt.jitterMs ?? 25;
5862
+ let phase = 'joining';
5863
+ const joinBuffer = [];
5864
+ const responseTimers = new Map();
5865
+ let helloTimer;
5866
+ function goLive() {
5867
+ if (phase === 'live')
5868
+ return;
5869
+ phase = 'live';
5870
+ if (helloTimer !== undefined) {
5871
+ clearTimeout(helloTimer);
5872
+ helloTimer = undefined;
5873
+ }
5874
+ for (const env of joinBuffer.splice(0))
5875
+ sync.receive(env);
5876
+ }
5877
+ const { unsub, post } = bus.subscribe(opt.id, (msg) => {
5878
+ if (!msg || typeof msg !== 'object')
5879
+ return;
5880
+ switch (msg.t) {
5881
+ case 'env':
5882
+ if (phase === 'joining')
5883
+ joinBuffer.push(msg.env);
5884
+ else
5885
+ sync.receive(msg.env);
5886
+ return;
5887
+ case 'hello': {
5888
+ if (phase !== 'live' || msg.from === sync.origin)
5889
+ return;
5890
+ // first responder wins: jittered answer, cancelled when someone else answers first
5891
+ const timer = setTimeout(() => {
5892
+ responseTimers.delete(msg.from);
5893
+ const snap = sync.snapshot();
5894
+ const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
5895
+ post(covered
5896
+ ? { t: 'uptodate', to: msg.from }
5897
+ : { t: 'state', to: msg.from, root: snap.root, wm: snap.wm });
5898
+ }, Math.random() * jitterMs);
5899
+ responseTimers.set(msg.from, timer);
5900
+ return;
5901
+ }
5902
+ case 'state':
5903
+ case 'uptodate': {
5904
+ const scheduled = responseTimers.get(msg.to);
5905
+ if (scheduled !== undefined) {
5906
+ clearTimeout(scheduled);
5907
+ responseTimers.delete(msg.to);
5908
+ }
5909
+ if (msg.to !== sync.origin || phase !== 'joining')
5910
+ return;
5911
+ if (msg.t === 'state')
5912
+ sync.hydrate(msg.root, msg.wm);
5913
+ goLive();
5914
+ return;
5915
+ }
5916
+ }
5917
+ });
5918
+ const unsubEnv = sync.subscribe((env) => post({ t: 'env', env }));
5919
+ post({ t: 'hello', from: sync.origin, wm: sync.watermark() });
5920
+ helloTimer = setTimeout(goLive, helloTimeoutMs);
5921
+ injector.get(DestroyRef).onDestroy(() => {
5922
+ if (helloTimer !== undefined)
5923
+ clearTimeout(helloTimer);
5924
+ for (const timer of responseTimers.values())
5925
+ clearTimeout(timer);
5926
+ responseTimers.clear();
5927
+ unsubEnv();
5928
+ unsub();
5929
+ sync.destroy();
5930
+ });
5931
+ }
5154
5932
  class MessageBus {
5155
5933
  channel = new BroadcastChannel('mmstack-tab-sync-bus');
5156
5934
  listeners = new Map();
@@ -5262,8 +6040,23 @@ function tabSync(sig, opt) {
5262
6040
  return sig;
5263
6041
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
5264
6042
  const bus = injector.get(MessageBus);
6043
+ const storeKind = sig[STORE_KIND];
6044
+ if (storeKind === 'writable') {
6045
+ storeTabSync(sig, { ...optObj, id }, bus, injector);
6046
+ return sig;
6047
+ }
6048
+ if (storeKind === 'readonly') {
6049
+ if (isDevMode()) {
6050
+ console.warn('[@mmstack/primitives] tabSync: a readonly store cannot receive remote ops — not synced.');
6051
+ }
6052
+ return sig;
6053
+ }
6054
+ if (storeKind === 'mutable' && isDevMode()) {
6055
+ console.warn('[@mmstack/primitives] tabSync: mutable stores fall back to whole-value sync (op diffing needs copy-on-write).');
6056
+ }
5265
6057
  const NONE = Symbol();
5266
6058
  let received = NONE;
6059
+ let last = untracked(sig);
5267
6060
  const { unsub, post } = bus.subscribe(id, (next) => {
5268
6061
  const before = untracked(sig);
5269
6062
  received = next;
@@ -5271,13 +6064,12 @@ function tabSync(sig, opt) {
5271
6064
  if (untracked(sig) === before)
5272
6065
  received = NONE;
5273
6066
  });
5274
- let firstDone = false;
5275
6067
  const effectRef = effect(() => {
5276
6068
  const val = sig();
5277
- if (!firstDone) {
5278
- firstDone = true;
5279
- return;
5280
- }
6069
+ if (val === last)
6070
+ return; // unchanged since last seen → nothing to post
6071
+ last = val;
6072
+ // came from bus → don't echo
5281
6073
  if (val === received) {
5282
6074
  received = NONE;
5283
6075
  return;
@@ -5489,5 +6281,5 @@ function withHistory(sourceOrValue, opt) {
5489
6281
  * Generated bundle index. Do not edit.
5490
6282
  */
5491
6283
 
5492
- export { MmActivity, MmTransition, MmViewTransitionName, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, applyOps, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, createAttributedPending, createForwardingScope, createStoreContext, createTransaction, createTransitionScope, debounce, debounced, deferredValue, derived, diffOps, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, invertBatch, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, latest, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opLog, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pointerDrag, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, projection, provideForwardingTransitionScope, providePausableOptions, providePaused, provideTransitionScope, reconcile, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, use, windowSize, withHistory };
6284
+ export { CONCURRENCY_INSTRUMENTATION, MmActivity, MmTransition, MmViewTransitionName, OP_PROTO_VERSION, PAUSABLE_OPTIONS, PERSISTED_STORE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, applyOps, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, compareHlc, compareTotal, createAttributedPending, createConvergingApply, createForwardingScope, createHlcClock, createStoreContext, createTransaction, createTransitionScope, debounce, debounced, deferredValue, derived, diffOps, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, invertBatch, isConflicted, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, keyedArray, latest, lww, map, mapArray, mapObject, mediaQuery, merge3, mergeThree, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opLog, opSync, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, perfCustomTracks, persist, persistedStore, pipeable, piped, pointerDrag, policyStrategy, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, preserve, projection, provideConcurrencyInstrumentation, provideForwardingTransitionScope, providePausableOptions, providePaused, providePersistedStoreOptions, provideTransitionScope, rebaseOps, reconcile, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, storeHistory, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, use, windowSize, withHistory };
5493
6285
  //# sourceMappingURL=mmstack-primitives.mjs.map