@mmstack/primitives 22.5.1 → 22.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -209,8 +209,7 @@ class MmActivity {
209
209
  if (this.onServer)
210
210
  return;
211
211
  for (const node of this.view.rootNodes) {
212
- // covers HTML and SVG roots; text/comment roots can't be styled their CD is still
213
- // detached, but prefer an element root for true visual hiding
212
+ // covers HTML and SVG roots; text/comment roots can't be styled, their CD is still detached
214
213
  if (node instanceof HTMLElement || node instanceof SVGElement)
215
214
  node.style.display = visible ? '' : 'none';
216
215
  }
@@ -228,8 +227,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
228
227
  selector: '[mmActivity]',
229
228
  }]
230
229
  }], ctorParameters: () => [], propDecorators: { visible: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmActivity", required: true }] }] } });
231
- // Shared never-paused signal returned outside a boundary / on the server (SSR renders the full tree,
232
- // nothing is paused). Readonly so a consumer can't cast-and-`.set()` the shared default for everyone.
233
230
  const NEVER_PAUSED = signal(false).asReadonly();
234
231
  /**
235
232
  * Inject the nearest paused-state signal — `true` while the surrounding subtree is paused (hidden by
@@ -483,7 +480,7 @@ function deferredValue(source, opt) {
483
480
  let cancel = null;
484
481
  const watch = effect(() => {
485
482
  const v = source();
486
- cancel?.(); // latest wins: rapid changes coalesce into one catch-up
483
+ cancel?.();
487
484
  cancel = schedule(() => {
488
485
  cancel = null;
489
486
  out.set(v);
@@ -495,8 +492,6 @@ function deferredValue(source, opt) {
495
492
  cancel = null;
496
493
  });
497
494
  const result = computed(() => out());
498
- // "behind" is a value comparison, not a schedule flag: an equal-valued catch-up
499
- // (e.g. type a char, delete it before the deferred view caught up) is not pending
500
495
  result.pending = computed(() => !equal(out(), source()), /* @ts-ignore */
501
496
  ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
502
497
  return result;
@@ -521,6 +516,46 @@ function resolveScheduler(strategy, injector) {
521
516
  };
522
517
  }
523
518
 
519
+ const CONCURRENCY_INSTRUMENTATION = new InjectionToken('@mmstack/primitives:concurrency-instrumentation');
520
+ function provideConcurrencyInstrumentation(listener) {
521
+ return { provide: CONCURRENCY_INSTRUMENTATION, useValue: listener };
522
+ }
523
+ const now = () => typeof globalThis.performance !== 'undefined'
524
+ ? globalThis.performance.now()
525
+ : Date.now();
526
+ /**
527
+ * Chrome DevTools "Performance" custom-tracks preset (idea/concurrency-devtools.md): writes a
528
+ * `performance.measure` for each pending/transaction window onto an "mmstack" extension track,
529
+ * so reactive coordination shows up on the Performance panel timeline. Dev-only, zero backend,
530
+ * no dependencies. Give each measure the scope name for readability.
531
+ */
532
+ function perfCustomTracks(track = 'mmstack concurrency') {
533
+ const canMeasure = typeof globalThis.performance !== 'undefined' &&
534
+ typeof globalThis.performance.measure === 'function';
535
+ const span = (name, start) => {
536
+ if (!canMeasure)
537
+ return;
538
+ try {
539
+ globalThis.performance.measure(name, {
540
+ start,
541
+ end: now(),
542
+ detail: {
543
+ devtools: { dataType: 'track-entry', track, color: 'primary' },
544
+ },
545
+ });
546
+ }
547
+ catch {
548
+ // measure options with detail are unsupported on this engine — skip silently
549
+ }
550
+ };
551
+ return {
552
+ pendingStart: (e) => e.at,
553
+ pendingEnd: (handle, e) => span(`pending`, handle ?? e.at),
554
+ transactionStart: (e) => e.at,
555
+ transactionEnd: (handle, e) => span(`transaction`, handle ?? e.at),
556
+ };
557
+ }
558
+
524
559
  /**
525
560
  * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
526
561
  * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
@@ -595,8 +630,13 @@ function isMutable(value) {
595
630
  return 'mutate' in value && typeof value.mutate === 'function';
596
631
  }
597
632
 
598
- function createTransitionScope() {
633
+ function createTransitionScope(opt) {
599
634
  const list = mutable([]);
635
+ const inst = opt?.instrumentation;
636
+ const name = opt?.name ?? 'scope';
637
+ const at = () => typeof globalThis.performance !== 'undefined'
638
+ ? globalThis.performance.now()
639
+ : Date.now();
600
640
  const pending = computed(() => list().some(({ ref }) => {
601
641
  const s = ref.status();
602
642
  return s === 'loading' || s === 'reloading';
@@ -610,11 +650,17 @@ function createTransitionScope() {
610
650
  resources: computed(() => list().map((e) => e.ref)),
611
651
  pending,
612
652
  suspended: (type) => list().some(({ ref, suspends }) => suspends && (type === 'loading' ? ref.isLoading() : !ref.hasValue())),
613
- add: (ref, opt) => untracked(() => list.inline((c) => c.push({ ref, suspends: opt?.suspends ?? true }))),
653
+ add: (ref, o) => untracked(() => {
654
+ const suspends = o?.suspends ?? true;
655
+ list.inline((c) => c.push({ ref, suspends }));
656
+ inst?.resourceRegistered?.({ scope: name, suspends });
657
+ }),
614
658
  remove: (ref) => untracked(() => list.inline((c) => {
615
659
  const i = c.findIndex((e) => e.ref === ref);
616
- if (i !== -1)
660
+ if (i !== -1) {
617
661
  c.splice(i, 1);
662
+ inst?.resourceRemoved?.({ scope: name });
663
+ }
618
664
  })),
619
665
  commit: (value) => linkedSignal({
620
666
  source: () => ({ v: value(), settled: !pending() }),
@@ -629,6 +675,8 @@ function createTransitionScope() {
629
675
  aborted++;
630
676
  }
631
677
  }
678
+ if (aborted > 0)
679
+ inst?.abortPending?.({ scope: name, aborted, at: at() });
632
680
  return aborted;
633
681
  }),
634
682
  holding,
@@ -697,13 +745,59 @@ function bridgeScopeToPendingTasks(scope, injector) {
697
745
  });
698
746
  });
699
747
  }
748
+ /**
749
+ * While a listener is installed, bracket each pending window of `scope` with a
750
+ * `pendingStart`/`pendingEnd` span (the reactive tap that needs an injection context). No-op
751
+ * when no listener is provided, so it stays zero-cost by default.
752
+ */
753
+ function bridgeScopeToInstrumentation(scope, name, injector) {
754
+ const run = (fn) => injector ? runInInjectionContext(injector, fn) : fn();
755
+ run(() => {
756
+ const inst = inject(CONCURRENCY_INSTRUMENTATION, { optional: true });
757
+ if (!inst?.pendingStart && !inst?.pendingEnd)
758
+ return;
759
+ const at = () => typeof globalThis.performance !== 'undefined'
760
+ ? globalThis.performance.now()
761
+ : Date.now();
762
+ let handle;
763
+ let open = false;
764
+ effect(() => {
765
+ const pending = scope.pending();
766
+ untracked(() => {
767
+ if (pending && !open) {
768
+ open = true;
769
+ handle = inst.pendingStart?.({
770
+ scope: name,
771
+ resources: scope.resources().length,
772
+ at: at(),
773
+ });
774
+ }
775
+ else if (!pending && open) {
776
+ open = false;
777
+ inst.pendingEnd?.(handle, { at: at() });
778
+ }
779
+ });
780
+ });
781
+ inject(DestroyRef).onDestroy(() => {
782
+ if (open)
783
+ inst.pendingEnd?.(handle, { at: at() });
784
+ });
785
+ });
786
+ }
700
787
  /** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
701
- function provideTransitionScope() {
788
+ function provideTransitionScope(opt) {
702
789
  return {
703
790
  provide: TRANSITION_SCOPE,
704
791
  useFactory: () => {
705
- const scope = createTransitionScope();
792
+ const listener = opt?.instrumentation ??
793
+ inject(CONCURRENCY_INSTRUMENTATION, { optional: true }) ??
794
+ undefined;
795
+ const scope = createTransitionScope({
796
+ name: opt?.name,
797
+ instrumentation: listener,
798
+ });
706
799
  bridgeScopeToPendingTasks(scope);
800
+ bridgeScopeToInstrumentation(scope, opt?.name ?? 'scope');
707
801
  return scope;
708
802
  },
709
803
  };
@@ -723,7 +817,11 @@ function createForwardingScope() {
723
817
  const target = signal(null, /* @ts-ignore */
724
818
  ...(ngDevMode ? [{ debugName: "target" }] : /* istanbul ignore next */ []));
725
819
  const eff = () => target() ?? own;
726
- const owners = new Map();
820
+ // WeakMap, deliberately: the forwarder usually outlives its targets (an outlet
821
+ // re-pointing at per-route scopes). If a registrant ever misses its `remove`,
822
+ // ephemeron semantics let the ref↔dead-target cycle collect once the registrant
823
+ // drops the ref, instead of this map pinning every stranded pair forever.
824
+ const owners = new WeakMap();
727
825
  return {
728
826
  setTarget: (t) => target.set(t),
729
827
  resources: computed(() => eff().resources()),
@@ -852,8 +950,6 @@ function use(res) {
852
950
  frame.seen.add(res);
853
951
  frame.deps.push(res);
854
952
  }
855
- // status() is read tracked even on the short-circuit paths, so the owning computed
856
- // re-evaluates when the load settles / the error clears.
857
953
  if (res.status() === 'error') {
858
954
  frame.errors.push(res.error?.());
859
955
  throw BLOCKED;
@@ -901,9 +997,6 @@ function latest(fn, opt) {
901
997
  }
902
998
  }, opt?.debugName ? { debugName: `${opt.debugName}:evaluation` } : undefined);
903
999
  const equal = opt?.equal ?? Object.is;
904
- // The stale-while-revalidate atom: holds the last successful result through blocked /
905
- // errored rounds. `equal` gates notification, so an in-flight cycle that lands on an
906
- // equal value never ripples to consumers — while `pending` (independent) still cycles.
907
1000
  const held = linkedSignal({ ...(ngDevMode ? { debugName: "held" } : /* istanbul ignore next */ {}), source: evaluation,
908
1001
  computation: (ev, prev) => ev.kind === 'value'
909
1002
  ? { has: true, v: ev.value }
@@ -972,8 +1065,7 @@ function injectStartTransition() {
972
1065
  const destroyRef = inject(DestroyRef);
973
1066
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
974
1067
  return (fn) => {
975
- // attributed: loads already in flight when the transition starts are not ours
976
- // they can neither settle this transition early nor block it forever
1068
+ // attributed: loads already in flight when the transition starts are not ours
977
1069
  const pending = createAttributedPending(scope);
978
1070
  untracked(fn);
979
1071
  let sawPending = false;
@@ -998,8 +1090,7 @@ function injectStartTransition() {
998
1090
  settle();
999
1091
  return;
1000
1092
  }
1001
- // no-async fallback: once the reactive system has processed the writes (afterNextRender),
1002
- // if nothing ever went in flight, the transition is already complete.
1093
+ // no-async fallback: once the reactive system has processed the writes,
1003
1094
  afterNextRender(() => {
1004
1095
  if (!sawPending && !untracked(pending))
1005
1096
  settle();
@@ -1106,9 +1197,6 @@ function createTransaction() {
1106
1197
  clear: () => log.clear(),
1107
1198
  };
1108
1199
  }
1109
- // The currently-active transaction, set only for the synchronous duration of a `startTransaction`
1110
- // body (so stateful actions running inside it can record their writes). Module-level + sync
1111
- // set/reset is the honest shape: a transaction is call-scoped, not structural-per-injector.
1112
1200
  let active = null;
1113
1201
  /** The transaction in effect right now, or `null`. Stateful actions consult this to record undo. */
1114
1202
  function activeTransaction() {
@@ -1147,10 +1235,7 @@ function injectStartTransaction() {
1147
1235
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
1148
1236
  return (fn) => {
1149
1237
  const txn = createTransaction();
1150
- // attributed: loads already in flight when the transaction starts are not ours —
1151
- // they can neither commit this transaction early nor block its settle forever
1152
1238
  const pending = createAttributedPending(scope);
1153
- // Hold BEFORE the writes, so the display freezes at pre-transaction values.
1154
1239
  scope.beginHold();
1155
1240
  let finished = false;
1156
1241
  // eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
@@ -1159,9 +1244,6 @@ function injectStartTransaction() {
1159
1244
  const done = new Promise((resolve) => {
1160
1245
  resolveDone = resolve;
1161
1246
  });
1162
- // Every exit path funnels through here, so `done` always settles — including `abort()`
1163
- // and a throwing transaction body (which would otherwise leak the hold forever and
1164
- // freeze the boundary with no recovery).
1165
1247
  const finish = (restore) => {
1166
1248
  if (finished)
1167
1249
  return;
@@ -1175,9 +1257,6 @@ function injectStartTransaction() {
1175
1257
  scope.endHold();
1176
1258
  resolveDone();
1177
1259
  };
1178
- // The scope may outlive the calling context (a component transacting on an ancestor
1179
- // boundary): a destroy mid-flight kills the settle watcher, so without this the hold
1180
- // would leak and freeze the surviving scope forever. Keep the writes — they landed live.
1181
1260
  const releaseDestroy = destroyRef.onDestroy(() => finish(false));
1182
1261
  try {
1183
1262
  runInTransaction(txn, fn);
@@ -1199,7 +1278,7 @@ function injectStartTransaction() {
1199
1278
  finish(false);
1200
1279
  }
1201
1280
  else {
1202
- // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
1281
+ // no-async fallback
1203
1282
  afterNextRender(() => {
1204
1283
  if (!sawPending && !untracked(pending))
1205
1284
  finish(false);
@@ -1276,7 +1355,6 @@ class MmTransition {
1276
1355
  }
1277
1356
  onValue(v) {
1278
1357
  if (!this.current) {
1279
- // first render: nothing to hold yet — show immediately (also what SSR serializes)
1280
1358
  this.current = this.createView(v).view;
1281
1359
  return;
1282
1360
  }
@@ -1290,8 +1368,7 @@ class MmTransition {
1290
1368
  const { view, scope } = this.createView(v);
1291
1369
  this.setHidden(view, true);
1292
1370
  this.holding.set(true);
1293
- // Registration happens synchronously during view creation, so a resource already in
1294
- // flight counts from the start; later kickoffs are caught by the watcher.
1371
+ // Registration happens synchronously during view creation, so a resource already incl. later kickoffs are caught by the watcher.
1295
1372
  let sawPending = untracked(scope.pending);
1296
1373
  const watcher = effect(() => {
1297
1374
  const pending = scope.pending();
@@ -1348,8 +1425,6 @@ class MmTransition {
1348
1425
  this.holding.set(false);
1349
1426
  }
1350
1427
  createView(v) {
1351
- // Each view gets its own scope, so its subtree's resources register here by existing —
1352
- // and the outgoing view's background work can't block the swap (per-view isolation).
1353
1428
  const injector = Injector.create({
1354
1429
  parent: this.parent,
1355
1430
  providers: [provideTransitionScope()],
@@ -1436,7 +1511,7 @@ function getSignalEquality(sig) {
1436
1511
  if (internal && typeof internal.equal === 'function') {
1437
1512
  return internal.equal;
1438
1513
  }
1439
- return Object.is; // Default equality check
1514
+ return Object.is;
1440
1515
  }
1441
1516
 
1442
1517
  /**
@@ -1585,8 +1660,6 @@ function isIndexProp(prop) {
1585
1660
  return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
1586
1661
  }
1587
1662
 
1588
- // Container resolvers used by createVivify: each returns the current value when present and
1589
- // only creates a new container when it is null/undefined.
1590
1663
  function identity(x) {
1591
1664
  return x;
1592
1665
  }
@@ -1744,11 +1817,6 @@ function derived(source, optOrKey, opt) {
1744
1817
  cnt++;
1745
1818
  try {
1746
1819
  sig.update(updater);
1747
- // The wrapped computed evaluates its `equal` lazily — at the next read, which would
1748
- // normally happen after `cnt` has already dropped back to 0. For a reference-stable
1749
- // mutation that read compares the same object to itself and the version never bumps,
1750
- // so dependents are never notified. Reading here, while equality is still suppressed,
1751
- // forces the recompute (and version bump) inside the mutate window.
1752
1820
  untracked(sig);
1753
1821
  }
1754
1822
  finally {
@@ -1810,8 +1878,6 @@ function isDerivation(sig) {
1810
1878
 
1811
1879
  function keepPrevious(src, opt) {
1812
1880
  const mutableSrc = isWritableSignal$2(src) && isMutable(src);
1813
- // For a mutable source the linkedSignal's equality must be suppressible: a forwarded
1814
- // `mutate` keeps the same reference, which default equality would otherwise swallow.
1815
1881
  let cnt = 0;
1816
1882
  const baseEqual = opt?.equal;
1817
1883
  const equal = mutableSrc
@@ -1824,16 +1890,11 @@ function keepPrevious(src, opt) {
1824
1890
  if (isWritableSignal$2(src)) {
1825
1891
  persisted.set = src.set;
1826
1892
  persisted.update = src.update;
1827
- // NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
1828
- // source's readonly view would reintroduce the `undefined` flashes this wrapper exists
1829
- // to prevent.
1830
1893
  if (mutableSrc) {
1831
1894
  persisted.mutate = (updater) => {
1832
1895
  cnt++;
1833
1896
  try {
1834
1897
  src.mutate(updater);
1835
- // force the recompute while equality is suppressed, so the reference-stable
1836
- // mutation bumps the wrapper's version (see derived.ts for the same pattern)
1837
1898
  untracked(persisted);
1838
1899
  }
1839
1900
  finally {
@@ -1903,8 +1964,7 @@ function indexArray(source, map, opt = {}) {
1903
1964
  : toWritable(data, () => {
1904
1965
  // noop
1905
1966
  });
1906
- // copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
1907
- // (possibly shared/reused) options object
1967
+ // copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned (possibly shared/reused) options object
1908
1968
  if (isWritableSignal$1(data) && isMutable(data) && !opt.equal) {
1909
1969
  opt = {
1910
1970
  ...opt,
@@ -2603,8 +2663,7 @@ function observerSupported$1() {
2603
2663
  */
2604
2664
  function elementSize(target, opt) {
2605
2665
  return runInSensorContext(opt?.injector, () =>
2606
- // the host-element default must resolve INSIDE the sensor context, not as a
2607
- // parameter default (which would run before the injector wrapper)
2666
+ // the host-element default must resolve INSIDE the sensor context
2608
2667
  createElementSize(target ?? inject(ElementRef), opt));
2609
2668
  }
2610
2669
  function createElementSize(target, opt) {
@@ -2743,10 +2802,7 @@ function observerSupported() {
2743
2802
  * ```
2744
2803
  */
2745
2804
  function elementVisibility(target, opt) {
2746
- return runInSensorContext(opt?.injector, () =>
2747
- // the host-element default must resolve INSIDE the sensor context, not as a
2748
- // parameter default (which would run before the injector wrapper)
2749
- createElementVisibility(target ?? inject(ElementRef), opt));
2805
+ return runInSensorContext(opt?.injector, () => createElementVisibility(target ?? inject(ElementRef), opt));
2750
2806
  }
2751
2807
  function createElementVisibility(target, opt) {
2752
2808
  if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
@@ -2819,10 +2875,7 @@ function unwrap$1(target) {
2819
2875
  * ```
2820
2876
  */
2821
2877
  function focusWithin(target, opt) {
2822
- return runInSensorContext(opt?.injector, () =>
2823
- // the host-element default must resolve INSIDE the sensor context, not as a
2824
- // parameter default (which would run before the injector wrapper)
2825
- createFocusWithin(target ?? inject(ElementRef), opt));
2878
+ return runInSensorContext(opt?.injector, () => createFocusWithin(target ?? inject(ElementRef), opt));
2826
2879
  }
2827
2880
  function createFocusWithin(target, opt) {
2828
2881
  const debugName = opt?.debugName ?? 'focusWithin';
@@ -3306,8 +3359,6 @@ function createMousePosition(opt) {
3306
3359
  }
3307
3360
  pos.set({ x, y });
3308
3361
  };
3309
- // passive: the handler never calls preventDefault, and a non-passive touchmove on
3310
- // window forces the browser to wait on JS before scrolling (scroll jank on touch)
3311
3362
  const attach = (el) => {
3312
3363
  const controller = new AbortController();
3313
3364
  el.addEventListener('mousemove', updatePosition, {
@@ -3323,7 +3374,7 @@ function createMousePosition(opt) {
3323
3374
  return () => controller.abort();
3324
3375
  };
3325
3376
  if (isSignal(target)) {
3326
- // re-attach whenever the signal resolves to a (new) element — covers viewChild
3377
+ // covers viewChild case
3327
3378
  effect((cleanup) => {
3328
3379
  const el = resolve(target());
3329
3380
  if (!el)
@@ -3773,7 +3824,6 @@ function createScrollPosition(opt) {
3773
3824
  ms: throttle,
3774
3825
  });
3775
3826
  if (isSignal(target)) {
3776
- // re-attach whenever the signal resolves to a (new) element — covers viewChild
3777
3827
  effect((cleanup) => {
3778
3828
  const el = resolve(target());
3779
3829
  if (!el)
@@ -3967,7 +4017,6 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
3967
4017
  state.set(event);
3968
4018
  };
3969
4019
  const { destroyRef: providedDestroyRef,
3970
- // strip non-listener keys so they don't leak into addEventListener options
3971
4020
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
3972
4021
  injector: _injector,
3973
4022
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -3981,8 +4030,7 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
3981
4030
  resolved.addEventListener(eventName, handler, listenerOpts);
3982
4031
  cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
3983
4032
  }, { ...(ngDevMode ? { debugName: "effectRef" } : /* istanbul ignore next */ {}), injector });
3984
- // honor an explicit destroyRef for signal targets too — the effect would otherwise
3985
- // only follow the injector's lifetime, contradicting the documented option
4033
+ // honor an explicit destroyRef for signal targets
3986
4034
  providedDestroyRef?.onDestroy(() => effectRef.destroy());
3987
4035
  }
3988
4036
  else {
@@ -4218,102 +4266,325 @@ function isStore(value) {
4218
4266
  value[IS_STORE] === true);
4219
4267
  }
4220
4268
 
4269
+ function generateOrigin$1() {
4270
+ if (globalThis.crypto?.randomUUID)
4271
+ return globalThis.crypto.randomUUID();
4272
+ return Math.random().toString(36).substring(2);
4273
+ }
4274
+ const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4221
4275
  /**
4222
- * @internal Reads (or lazily builds + caches) the child node proxy for `prop` on `target`,
4223
- * holding it via a `WeakRef` and registering it for finalizer-driven cache pruning. The cache
4224
- * is keyed per backing signal, so child identity is stable across repeat reads.
4276
+ * Reference-identity-pruned structural diff the same short-circuit discipline as `merge3`:
4277
+ * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4278
+ * descends only where refs differ. O(changed paths), not O(tree).
4225
4279
  */
4226
- function getCachedChild(target, prop, build, cache, cleanupRegistry) {
4227
- let storeCache = cache.get(target);
4228
- if (!storeCache) {
4229
- storeCache = new Map();
4230
- cache.set(target, storeCache);
4280
+ function diffNode(prev, next, path, ops) {
4281
+ if (Object.is(prev, next))
4282
+ return;
4283
+ if (isRecord(prev) && isRecord(next)) {
4284
+ for (const key of Object.keys(prev)) {
4285
+ if (!Object.hasOwn(next, key))
4286
+ ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
4287
+ }
4288
+ for (const key of Object.keys(next)) {
4289
+ if (!Object.hasOwn(prev, key)) {
4290
+ // added key: deliberately NO `prev` property (absent ≠ undefined)
4291
+ ops.push({ kind: 'set', path: [...path, key], next: next[key] });
4292
+ }
4293
+ else {
4294
+ diffNode(prev[key], next[key], [...path, key], ops);
4295
+ }
4296
+ }
4297
+ return;
4231
4298
  }
4232
- const cachedRef = storeCache.get(prop);
4233
- if (cachedRef) {
4234
- const cached = cachedRef.deref();
4235
- if (cached)
4236
- return cached;
4237
- storeCache.delete(prop);
4238
- cleanupRegistry.unregister(cachedRef);
4299
+ if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4300
+ // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4301
+ // change is a whole unit — index attribution lies under insert/remove/reorder
4302
+ if (prev.length === next.length) {
4303
+ for (let i = 0; i < next.length; i++)
4304
+ diffNode(prev[i], next[i], [...path, i], ops);
4305
+ return;
4306
+ }
4307
+ ops.push({ kind: 'set', path, prev, next });
4308
+ return;
4239
4309
  }
4240
- const proxy = build();
4241
- const ref = new WeakRef(proxy);
4242
- storeCache.set(prop, ref);
4243
- cleanupRegistry.register(proxy, { target, prop }, ref);
4244
- return proxy;
4310
+ // leaf / type change / opaque — one unit, prev present (the slot existed)
4311
+ ops.push({ kind: 'set', path, prev, next });
4312
+ }
4313
+ /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4314
+ function applyAt(container, path, idx, op) {
4315
+ const seg = path[idx];
4316
+ const base = isPlainArray$1(container)
4317
+ ? container.slice()
4318
+ : isRecord(container)
4319
+ ? { ...container }
4320
+ : typeof seg === 'number'
4321
+ ? []
4322
+ : {};
4323
+ if (idx === path.length - 1) {
4324
+ if (op.kind === 'delete') {
4325
+ // arrays never receive deletes (length changes travel as whole-array sets)
4326
+ delete base[seg];
4327
+ }
4328
+ else {
4329
+ base[seg] = op.next;
4330
+ }
4331
+ return base;
4332
+ }
4333
+ base[seg] = applyAt(base[seg], path, idx + 1, op);
4334
+ return base;
4245
4335
  }
4246
4336
  /**
4247
- * @internal Whether a mutable parent's child value must always re-notify: in-place mutation
4248
- * keeps an object child's reference stable, so `Object.is` would swallow the change. Decided
4249
- * per-VALUE (not snapshotted at build) so a union child that becomes an object later still
4250
- * propagates parent-level mutations.
4337
+ * Pure, store-free application of ops onto a plain root value, returning the next immutable root
4338
+ * (structural-sharing along op paths, missing containers vivified `'auto'`-style). This is the
4339
+ * same transform {@link OpLog.apply} runs, extracted so a replica can fold a received batch into
4340
+ * a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
4341
+ * Accepts a batch or a bare op list.
4251
4342
  */
4252
- function mutableChildEqual(a, b) {
4253
- if (typeof a === 'object' && a !== null)
4254
- return false;
4255
- return Object.is(a, b);
4343
+ function applyOps(root, ops) {
4344
+ const list = Array.isArray(ops) ? ops : ops.ops;
4345
+ let next = root;
4346
+ for (const op of list) {
4347
+ if (op.path.length === 0) {
4348
+ if (op.kind === 'set')
4349
+ next = op.next;
4350
+ continue; // a root delete is meaningless — ignore (mirrors OpLog.apply)
4351
+ }
4352
+ next = applyAt(next, op.path, 0, op);
4353
+ }
4354
+ return next;
4256
4355
  }
4257
4356
  /**
4258
- * @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
4259
- * Both the read (`v?.[prop]`) and the write (`createFallbackOnChange` copies by the container's
4260
- * LIVE shape) are shape-adaptive, so a child cached before an array↔record↔null union flip stays
4261
- * correct after it. The only place a child node is constructed — shared by every container kind.
4357
+ * Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
4358
+ * {@link opLog}, exported so code outside a log can produce a batch — e.g. diffing a scratch
4359
+ * draft against a replica's current value to route a write to its owner). Trusts the
4360
+ * copy-on-write contract: an untouched subtree that kept its reference is skipped.
4262
4361
  */
4263
- function buildChildNode(target, prop, isMutableSource, options) {
4264
- const value = untracked(target);
4265
- const nodeVivify = resolveVivify(value, options.vivify);
4266
- const vivifyFn = createVivify(nodeVivify);
4267
- const equalFn = isMutableSource && (isRecord(value) || Array.isArray(value))
4268
- ? mutableChildEqual
4269
- : undefined;
4270
- const computation = derived(target, {
4271
- from: (v) => v?.[prop],
4272
- onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
4273
- equal: equalFn,
4274
- });
4275
- const childSample = untracked(computation);
4276
- const childVivify = resolveVivify(childSample, options.vivify);
4277
- const proxy = toStore(computation, options);
4278
- markAsLeaf(proxy, computation, childVivify !== false, options.noUnionLeaves);
4279
- return proxy;
4362
+ function diffOps(prev, next) {
4363
+ const ops = [];
4364
+ diffNode(prev, next, [], ops);
4365
+ return ops;
4280
4366
  }
4281
4367
  /**
4282
- * Converts a Signal into a deep-observable Store.
4283
- * Accessing nested properties returns a derived Signal of that path.
4368
+ * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
4369
+ * `prev` inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4370
+ * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4371
+ * carry — a wire-serialized batch that stripped them is not invertible.
4372
+ */
4373
+ function invertBatch(batch) {
4374
+ const ops = Array.isArray(batch) ? batch : batch.ops;
4375
+ const inverted = [];
4376
+ for (let i = ops.length - 1; i >= 0; i--) {
4377
+ const op = ops[i];
4378
+ if (op.kind === 'delete') {
4379
+ inverted.push({
4380
+ kind: 'set',
4381
+ path: op.path,
4382
+ next: op.prev,
4383
+ prev: undefined,
4384
+ });
4385
+ continue;
4386
+ }
4387
+ if (!Object.hasOwn(op, 'prev')) {
4388
+ inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4389
+ }
4390
+ else {
4391
+ inverted.push({
4392
+ kind: 'set',
4393
+ path: op.path,
4394
+ next: op.prev,
4395
+ prev: op.next,
4396
+ });
4397
+ }
4398
+ }
4399
+ return inverted;
4400
+ }
4401
+ /**
4402
+ * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4403
+ * immutably-updated objects) and emits its changes as minimal structural op batches — the
4404
+ * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4405
+ * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4284
4406
  *
4285
- * @remarks
4286
- * A node's *container kind* (array / record / primitive) is tracked reactively via a per-node
4287
- * `kind` computed, so the same proxy serves all three and a union node that flips between an
4288
- * array and a record keeps working. Flips are route-forward: after a flip the node behaves as
4289
- * its new kind on the next access, while child proxies cached under the old shape go stale and
4290
- * are pruned by the GC.
4407
+ * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4408
+ * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4409
+ * effect. A batch therefore coalesces everything written in one tick for coarser,
4410
+ * intentional units, stage writes on a `forkStore` and `commit()` (one set one batch).
4291
4411
  *
4292
- * @example
4293
- * const state = store({ user: { name: 'John' } });
4294
- * const nameSignal = state.user.name; // WritableSignal<string>
4412
+ * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4413
+ * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4414
+ * warning fires and nothing emits.
4415
+ *
4416
+ * ```ts
4417
+ * const s = store({ todos: [{ done: false }] });
4418
+ * const log = opLog(s, { origin: 'tab-a' });
4419
+ * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4420
+ * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4421
+ * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4422
+ * ```
4295
4423
  */
4296
- function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...rest } = {}) {
4297
- if (isStore(source))
4298
- return source;
4299
- // injector is needed ONLY to resolve the two proxy-globals tokens; if a caller supplies the
4300
- // globals directly (createStoreContext the worker-side seam with no DI), skip inject entirely
4301
- const sharedGlobals = rest[STORE_SHARED_GLOBALS];
4302
- const hasSharedGlobals = !!(sharedGlobals?.cache && sharedGlobals?.registry);
4303
- if (!injector && !hasSharedGlobals)
4304
- injector = inject(Injector);
4305
- const writableSource = isWritableSignal(source)
4306
- ? source
4307
- : toWritable(source, () => {
4308
- // noop
4309
- });
4310
- const isWritableSource = isWritableSignal(source);
4311
- const isMutableSource = isWritableSource && isMutable(writableSource);
4312
- const kind = computed(() => {
4313
- const v = source();
4314
- if (Array.isArray(v) && !isOpaque(v))
4315
- return 'array';
4316
- if (isRecord(v))
4424
+ function opLog(source, opt) {
4425
+ const origin = opt?.origin ?? generateOrigin$1();
4426
+ const storeKind = source[STORE_KIND];
4427
+ const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4428
+ if (isDevMode() && mutableSource) {
4429
+ 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.');
4430
+ }
4431
+ let prevRoot = untracked(source);
4432
+ let version = 0;
4433
+ let destroyed = false;
4434
+ const subscribers = new Set();
4435
+ const latest = signal(null, /* @ts-ignore */
4436
+ ...(ngDevMode ? [{ debugName: "latest" }] : /* istanbul ignore next */ []));
4437
+ /** Diff now, emit if there's a delta, advance the baseline. */
4438
+ const flush = () => {
4439
+ if (destroyed)
4440
+ return;
4441
+ const next = untracked(source);
4442
+ if (Object.is(prevRoot, next))
4443
+ return;
4444
+ const ops = [];
4445
+ diffNode(prevRoot, next, [], ops);
4446
+ prevRoot = next;
4447
+ if (!ops.length)
4448
+ return; // fresh refs, equal values — spurious-write tolerance
4449
+ const batch = { origin, version: ++version, ops };
4450
+ latest.set(batch);
4451
+ for (const cb of [...subscribers])
4452
+ cb(batch);
4453
+ };
4454
+ const run = () => {
4455
+ source(); // track every commit…
4456
+ untracked(flush); // …and emit the delta since the last flush
4457
+ };
4458
+ // default driver is an Angular effect (needs an injector); a supplied driver runs injector-free
4459
+ // (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
4460
+ const ref = opt?.driver
4461
+ ? opt.driver(run)
4462
+ : effect(run, { injector: opt?.injector ?? inject(Injector) });
4463
+ return {
4464
+ latest: latest.asReadonly(),
4465
+ subscribe: (cb) => {
4466
+ subscribers.add(cb);
4467
+ return () => subscribers.delete(cb);
4468
+ },
4469
+ // the emission core, callable on demand — reads the source untracked, so it never disturbs the
4470
+ // driver's subscription; a subsequent scheduled run just finds the baseline already advanced
4471
+ flush: () => flush(),
4472
+ apply: (batchOrOps) => {
4473
+ const ops = Array.isArray(batchOrOps)
4474
+ ? batchOrOps
4475
+ : batchOrOps.ops;
4476
+ if (!ops.length)
4477
+ return;
4478
+ // pending local writes must emit BEFORE the baseline advances past them
4479
+ flush();
4480
+ const root = applyOps(untracked(source), ops); // one atomic root, structural-shared
4481
+ source.set(root);
4482
+ prevRoot = root; // baseline advance: an applied batch never echoes
4483
+ },
4484
+ destroy: () => {
4485
+ destroyed = true;
4486
+ subscribers.clear();
4487
+ ref.destroy();
4488
+ },
4489
+ };
4490
+ }
4491
+
4492
+ /**
4493
+ * @internal Reads (or lazily builds + caches) the child node proxy for `prop` on `target`,
4494
+ * holding it via a `WeakRef` and registering it for finalizer-driven cache pruning. The cache
4495
+ * is keyed per backing signal, so child identity is stable across repeat reads.
4496
+ */
4497
+ function getCachedChild(target, prop, build, cache, cleanupRegistry) {
4498
+ let storeCache = cache.get(target);
4499
+ if (!storeCache) {
4500
+ storeCache = new Map();
4501
+ cache.set(target, storeCache);
4502
+ }
4503
+ const cachedRef = storeCache.get(prop);
4504
+ if (cachedRef) {
4505
+ const cached = cachedRef.deref();
4506
+ if (cached)
4507
+ return cached;
4508
+ storeCache.delete(prop);
4509
+ cleanupRegistry.unregister(cachedRef);
4510
+ }
4511
+ const proxy = build();
4512
+ const ref = new WeakRef(proxy);
4513
+ storeCache.set(prop, ref);
4514
+ cleanupRegistry.register(proxy, { target, prop }, ref);
4515
+ return proxy;
4516
+ }
4517
+ /**
4518
+ * @internal Whether a mutable parent's child value must always re-notify: in-place mutation
4519
+ * keeps an object child's reference stable, so `Object.is` would swallow the change. Decided
4520
+ * per-VALUE (not snapshotted at build) so a union child that becomes an object later still
4521
+ * propagates parent-level mutations.
4522
+ */
4523
+ function mutableChildEqual(a, b) {
4524
+ if (typeof a === 'object' && a !== null)
4525
+ return false;
4526
+ return Object.is(a, b);
4527
+ }
4528
+ /**
4529
+ * @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
4530
+ * Both the read (`v?.[prop]`) and the write (`createFallbackOnChange` copies by the container's
4531
+ * LIVE shape) are shape-adaptive, so a child cached before an array↔record↔null union flip stays
4532
+ * correct after it. The only place a child node is constructed — shared by every container kind.
4533
+ */
4534
+ function buildChildNode(target, prop, isMutableSource, options) {
4535
+ const value = untracked(target);
4536
+ const nodeVivify = resolveVivify(value, options.vivify);
4537
+ const vivifyFn = createVivify(nodeVivify);
4538
+ const equalFn = isMutableSource && (isRecord(value) || Array.isArray(value))
4539
+ ? mutableChildEqual
4540
+ : undefined;
4541
+ const computation = derived(target, {
4542
+ from: (v) => v?.[prop],
4543
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
4544
+ equal: equalFn,
4545
+ });
4546
+ const childSample = untracked(computation);
4547
+ const childVivify = resolveVivify(childSample, options.vivify);
4548
+ const proxy = toStore(computation, options);
4549
+ markAsLeaf(proxy, computation, childVivify !== false, options.noUnionLeaves);
4550
+ return proxy;
4551
+ }
4552
+ /**
4553
+ * Converts a Signal into a deep-observable Store.
4554
+ * Accessing nested properties returns a derived Signal of that path.
4555
+ *
4556
+ * @remarks
4557
+ * A node's *container kind* (array / record / primitive) is tracked reactively via a per-node
4558
+ * `kind` computed, so the same proxy serves all three and a union node that flips between an
4559
+ * array and a record keeps working. Flips are route-forward: after a flip the node behaves as
4560
+ * its new kind on the next access, while child proxies cached under the old shape go stale and
4561
+ * are pruned by the GC.
4562
+ *
4563
+ * @example
4564
+ * const state = store({ user: { name: 'John' } });
4565
+ * const nameSignal = state.user.name; // WritableSignal<string>
4566
+ */
4567
+ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...rest } = {}) {
4568
+ if (isStore(source))
4569
+ return source;
4570
+ // injector is needed ONLY to resolve the two proxy-globals tokens; if a caller supplies the
4571
+ // globals directly (createStoreContext — the worker-side seam with no DI), skip inject entirely
4572
+ const sharedGlobals = rest[STORE_SHARED_GLOBALS];
4573
+ const hasSharedGlobals = !!(sharedGlobals?.cache && sharedGlobals?.registry);
4574
+ if (!injector && !hasSharedGlobals)
4575
+ injector = inject(Injector);
4576
+ const writableSource = isWritableSignal(source)
4577
+ ? source
4578
+ : toWritable(source, () => {
4579
+ // noop
4580
+ });
4581
+ const isWritableSource = isWritableSignal(source);
4582
+ const isMutableSource = isWritableSource && isMutable(writableSource);
4583
+ const kind = computed(() => {
4584
+ const v = source();
4585
+ if (Array.isArray(v) && !isOpaque(v))
4586
+ return 'array';
4587
+ if (isRecord(v))
4317
4588
  return 'record';
4318
4589
  return 'primitive';
4319
4590
  }, /* @ts-ignore */
@@ -4325,8 +4596,7 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4325
4596
  vivify,
4326
4597
  noUnionLeaves,
4327
4598
  [STORE_SHARED_GLOBALS]: {
4328
- // the `injector!` reads run only when a global is absent, which (per hasSharedGlobals) means
4329
- // an injector was resolved above
4599
+ // the `injector!` reads run only when a global is absent
4330
4600
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4331
4601
  cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
4332
4602
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -4406,8 +4676,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4406
4676
  injector,
4407
4677
  vivify,
4408
4678
  noUnionLeaves,
4409
- // forward the resolved globals — re-resolving from the injector both re-injects
4410
- // needlessly and breaks in DI-less (worker) mode where injector is undefined
4411
4679
  [STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
4412
4680
  }));
4413
4681
  };
@@ -4423,8 +4691,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4423
4691
  return arrayLength();
4424
4692
  if (prop === Symbol.iterator)
4425
4693
  return function* () {
4426
- // read length reactively: a spread/for-of inside a computed/effect must re-run
4427
- // when items are added or removed, not only when already-read elements change
4428
4694
  const len = arrayLength();
4429
4695
  for (let i = 0; i < len(); i++)
4430
4696
  yield receiver[i];
@@ -4669,9 +4935,6 @@ function forkStore(base, opt) {
4669
4935
  const merge = reconcile;
4670
4936
  const staged = linkedSignal({ ...(ngDevMode ? { debugName: "staged" } : /* istanbul ignore next */ {}), source: () => base(),
4671
4937
  computation: (theirs, prev) => prev === undefined ? theirs : merge(prev.source, prev.value, theirs) });
4672
- // Inherit the base's shared options (injector, vivify, noUnionLeaves + the
4673
- // proxy cache/registry), same as extendStore — a fork should vivify like its
4674
- // base and share its injector-scoped cache. `opt` overrides (advanced use).
4675
4938
  const store = toStore(staged, {
4676
4939
  ...base[STORE_SHARED_OPTIONS],
4677
4940
  ...opt,
@@ -4680,233 +4943,679 @@ function forkStore(base, opt) {
4680
4943
  store,
4681
4944
  commit: () => base.set(untracked(staged)),
4682
4945
  discard: () => staged.set(untracked(base)),
4946
+ ops: () => diffOps(untracked(base), untracked(staged)),
4683
4947
  };
4684
4948
  }
4685
4949
 
4686
- function generateOrigin() {
4687
- if (globalThis.crypto?.randomUUID)
4688
- return globalThis.crypto.randomUUID();
4689
- return Math.random().toString(36).substring(2);
4950
+ /** Total order over stamps alone; ties break on `writer` via {@link compareTotal}. */
4951
+ function compareHlc(a, b) {
4952
+ return a.p !== b.p ? a.p - b.p : a.l - b.l;
4953
+ }
4954
+ /** The protocol's total order: (hlc.p, hlc.l, writer). Never returns 0 for distinct writers. */
4955
+ function compareTotal(a, writerA, b, writerB) {
4956
+ const byClock = compareHlc(a, b);
4957
+ if (byClock !== 0)
4958
+ return byClock;
4959
+ return writerA < writerB ? -1 : writerA > writerB ? 1 : 0;
4960
+ }
4961
+ const SKEW_WARN_MS = 5 * 60_000;
4962
+ /**
4963
+ * HLC per Kulkarni et al.: convergence never depends on wall clocks, but LWW fairness
4964
+ * degrades under large skew, so observing a remote clock far ahead warns in dev mode.
4965
+ */
4966
+ function createHlcClock(now = Date.now) {
4967
+ let p = 0;
4968
+ let l = 0;
4969
+ const advance = (wall, observed) => {
4970
+ const nextP = Math.max(p, wall, observed?.p ?? 0);
4971
+ if (nextP === p) {
4972
+ l = Math.max(l, observed && observed.p === nextP ? observed.l : 0) + 1;
4973
+ }
4974
+ else {
4975
+ p = nextP;
4976
+ l = observed && observed.p === nextP ? observed.l + 1 : 0;
4977
+ }
4978
+ };
4979
+ return {
4980
+ next: () => {
4981
+ advance(now());
4982
+ return { p, l };
4983
+ },
4984
+ observe: (remote) => {
4985
+ const wall = now();
4986
+ if (isDevMode() && remote.p - wall > SKEW_WARN_MS) {
4987
+ 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`);
4988
+ }
4989
+ advance(wall, remote);
4990
+ },
4991
+ };
4690
4992
  }
4691
- const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4993
+
4994
+ const OP_PROTO_VERSION = 1;
4995
+ const CONFLICT_BRAND = '~mmstackConflict';
4996
+ function isConflicted(value) {
4997
+ return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
4998
+ }
4999
+ const lww = (_ancestor, mine) => mine;
5000
+ const mergeThree = (ancestor, mine, theirs) => merge3(ancestor, mine, theirs);
5001
+ const preserve = (ancestor, mine, theirs) => ({ [CONFLICT_BRAND]: true, mine, theirs, ancestor });
4692
5002
  /**
4693
- * Reference-identity-pruned structural diff the same short-circuit discipline as `merge3`:
4694
- * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4695
- * descends only where refs differ. O(changed paths), not O(tree).
5003
+ * Identity-aware array merge (op-protocol RFC §12 v0): reconciles two concurrent versions of
5004
+ * an array item-wise by a user-provided identity, instead of last-writer-wins on the whole
5005
+ * array. Items are matched by key; per-item fields merge via `merge3` against the ancestor
5006
+ * item; items added on either side survive; an item removed on either side and unedited on
5007
+ * the other stays removed. Item ORDER follows `mine` (the total-order winner), with `theirs`-
5008
+ * only additions appended — positional merging is out of scope (fractional indexing is the
5009
+ * known upgrade if dogfooding demands it). Arrays still TRAVEL as whole-value sets; identity
5010
+ * only shapes conflict resolution, so the wire format is untouched.
4696
5011
  */
4697
- function diffNode(prev, next, path, ops) {
4698
- if (Object.is(prev, next))
4699
- return;
4700
- if (isRecord(prev) && isRecord(next)) {
4701
- for (const key of Object.keys(prev)) {
4702
- if (!Object.hasOwn(next, key))
4703
- ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
5012
+ function keyedArray(identity, opt) {
5013
+ const mergeItem = opt?.item ?? mergeThree;
5014
+ return (ancestor, mine, theirs, ctx) => {
5015
+ if (!Array.isArray(mine) || !Array.isArray(theirs)) {
5016
+ return mine; // type conflict → total-order winner, like lww
4704
5017
  }
4705
- for (const key of Object.keys(next)) {
4706
- if (!Object.hasOwn(prev, key)) {
4707
- // added key: deliberately NO `prev` property (absent ≠ undefined)
4708
- ops.push({ kind: 'set', path: [...path, key], next: next[key] });
5018
+ const anc = Array.isArray(ancestor) ? ancestor : [];
5019
+ const byKey = (arr) => {
5020
+ const map = new Map();
5021
+ for (const item of arr)
5022
+ map.set(identity(item), item);
5023
+ return map;
5024
+ };
5025
+ const ancMap = byKey(anc);
5026
+ const mineMap = byKey(mine);
5027
+ const theirsMap = byKey(theirs);
5028
+ const out = [];
5029
+ for (const item of mine) {
5030
+ const key = identity(item);
5031
+ const other = theirsMap.get(key);
5032
+ const base = ancMap.get(key);
5033
+ if (theirsMap.has(key)) {
5034
+ out.push(structuralEq(item, other)
5035
+ ? item
5036
+ : mergeItem(base, item, other, ctx));
4709
5037
  }
4710
- else {
4711
- diffNode(prev[key], next[key], [...path, key], ops);
5038
+ else if (!ancMap.has(key) || !structuralEq(item, base)) {
5039
+ out.push(item); // added by mine, or edited by mine while theirs removed it → keep
4712
5040
  }
5041
+ // else: theirs removed it and mine left it untouched → stays removed
4713
5042
  }
4714
- return;
4715
- }
4716
- if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4717
- // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4718
- // change is a whole unit — index attribution lies under insert/remove/reorder
4719
- if (prev.length === next.length) {
4720
- for (let i = 0; i < next.length; i++)
4721
- diffNode(prev[i], next[i], [...path, i], ops);
4722
- return;
5043
+ for (const item of theirs) {
5044
+ const key = identity(item);
5045
+ if (mineMap.has(key))
5046
+ continue;
5047
+ if (!ancMap.has(key) || !structuralEq(item, ancMap.get(key))) {
5048
+ out.push(item); // added by theirs, or edited by theirs while mine removed it → keep
5049
+ }
4723
5050
  }
4724
- ops.push({ kind: 'set', path, prev, next });
4725
- return;
5051
+ return out;
5052
+ };
5053
+ }
5054
+ function compilePolicies(entries) {
5055
+ return entries.map((e) => ({
5056
+ segments: typeof e.path === 'string' ? e.path.split('.') : e.path.map(String),
5057
+ merge: e.merge,
5058
+ }));
5059
+ }
5060
+ function policyFor(policies, path) {
5061
+ outer: for (const p of policies) {
5062
+ if (p.segments.length !== path.length)
5063
+ continue;
5064
+ for (let i = 0; i < path.length; i++) {
5065
+ if (p.segments[i] !== '*' && p.segments[i] !== String(path[i]))
5066
+ continue outer;
5067
+ }
5068
+ return p.merge;
4726
5069
  }
4727
- // leaf / type change / opaque — one unit, prev present (the slot existed)
4728
- ops.push({ kind: 'set', path, prev, next });
5070
+ return lww;
4729
5071
  }
4730
- /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4731
- function applyAt(container, path, idx, op) {
4732
- const seg = path[idx];
4733
- const base = isPlainArray$1(container)
4734
- ? container.slice()
4735
- : isRecord(container)
4736
- ? { ...container }
4737
- : typeof seg === 'number'
4738
- ? []
4739
- : {};
4740
- if (idx === path.length - 1) {
4741
- if (op.kind === 'delete') {
4742
- // arrays never receive deletes (length changes travel as whole-array sets)
4743
- delete base[seg];
5072
+ const SEP = '';
5073
+ const keyOf$1 = (path) => path.map(String).join(SEP);
5074
+ function structuralEq(a, b) {
5075
+ if (Object.is(a, b))
5076
+ return true;
5077
+ if (typeof a !== 'object' ||
5078
+ typeof b !== 'object' ||
5079
+ a === null ||
5080
+ b === null ||
5081
+ Array.isArray(a) !== Array.isArray(b)) {
5082
+ return false;
5083
+ }
5084
+ const ka = Object.keys(a);
5085
+ const kb = Object.keys(b);
5086
+ if (ka.length !== kb.length)
5087
+ return false;
5088
+ for (const k of ka) {
5089
+ if (!Object.hasOwn(b, k))
5090
+ return false;
5091
+ if (!structuralEq(a[k], b[k])) {
5092
+ return false;
4744
5093
  }
4745
- else {
4746
- base[seg] = op.next;
5094
+ }
5095
+ return true;
5096
+ }
5097
+ // total order (hlc, writer, origin): two origins can share a writer AND a stamp
5098
+ // (independent clocks, same ms), so only origin makes the order strict
5099
+ const compareStamp = (a, b) => {
5100
+ const byTotal = compareTotal(a.hlc, a.writer, b.hlc, b.writer);
5101
+ if (byTotal !== 0)
5102
+ return byTotal;
5103
+ return a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0;
5104
+ };
5105
+ const beats = (a, b) => compareStamp(a, b) > 0;
5106
+ /**
5107
+ * The unsequenced-topology convergence core (op-protocol RFC §4): a per-path last-writer-wins
5108
+ * register map over the total order (hlc, writer), with subtree dominance. Order-independent:
5109
+ * any arrival order of the same envelope set yields the same state.
5110
+ */
5111
+ function createConvergingApply(opt) {
5112
+ const registers = new Map();
5113
+ const policies = compilePolicies(opt?.policies ?? []);
5114
+ const resolveConcurrent = (winner, loser, path) => {
5115
+ const merge = policyFor(policies, path);
5116
+ if (merge === lww || winner.kind === 'delete' || loser.kind === 'delete') {
5117
+ return winner;
4747
5118
  }
4748
- return base;
5119
+ const resolved = merge(loser.prev, winner.next, loser.next, { path });
5120
+ if (Object.is(resolved, winner.next))
5121
+ return winner;
5122
+ return { kind: 'set', path, next: resolved, prev: winner.next };
5123
+ };
5124
+ // a sequential edit carries the value it overwrote; a mismatch means neither saw the other.
5125
+ // Structural, not referential: identity never survives the wire, so a peer that built on
5126
+ // the replicated copy of a value must still count as sequential.
5127
+ const concurrentWith = (incoming, registered) => {
5128
+ if (incoming.kind === 'delete' || registered.kind === 'delete')
5129
+ return false;
5130
+ if (!Object.hasOwn(incoming, 'prev'))
5131
+ return true;
5132
+ return !structuralEq(incoming.prev, registered.next);
5133
+ };
5134
+ return {
5135
+ ingest: (env, o) => {
5136
+ const stamp = { hlc: env.hlc, writer: env.writer, origin: env.origin };
5137
+ const out = [];
5138
+ for (const op of env.ops) {
5139
+ const key = keyOf$1(op.path);
5140
+ let dominated = false;
5141
+ let exact;
5142
+ for (let len = 0; len <= op.path.length; len++) {
5143
+ const reg = registers.get(keyOf$1(op.path.slice(0, len)));
5144
+ if (!reg)
5145
+ continue;
5146
+ if (len === op.path.length)
5147
+ exact = reg;
5148
+ else if (beats(reg, stamp)) {
5149
+ dominated = true;
5150
+ break;
5151
+ }
5152
+ }
5153
+ if (dominated)
5154
+ continue;
5155
+ if (exact && beats(exact, stamp)) {
5156
+ if (concurrentWith(op, exact.op)) {
5157
+ const resolved = resolveConcurrent(exact.op, op, op.path);
5158
+ if (resolved !== exact.op) {
5159
+ exact.op = resolved;
5160
+ if (!o?.local)
5161
+ out.push(resolved);
5162
+ }
5163
+ }
5164
+ continue;
5165
+ }
5166
+ let accepted = op;
5167
+ if (exact && concurrentWith(op, exact.op)) {
5168
+ accepted = resolveConcurrent(op, exact.op, op.path);
5169
+ }
5170
+ const isDescendant = key === ''
5171
+ ? (k) => k !== ''
5172
+ : (k) => k.startsWith(key + SEP);
5173
+ const replays = [];
5174
+ for (const [k, reg] of registers) {
5175
+ if (!isDescendant(k))
5176
+ continue;
5177
+ if (beats(stamp, reg))
5178
+ registers.delete(k);
5179
+ else
5180
+ replays.push(reg);
5181
+ }
5182
+ replays.sort(compareStamp);
5183
+ registers.set(key, { hlc: env.hlc, writer: env.writer, origin: env.origin, op: accepted });
5184
+ if (!o?.local) {
5185
+ out.push(accepted);
5186
+ for (const r of replays)
5187
+ out.push(r.op);
5188
+ }
5189
+ }
5190
+ return out;
5191
+ },
5192
+ reset: () => registers.clear(),
5193
+ };
5194
+ }
5195
+ function getAtPath(root, path) {
5196
+ let cur = root;
5197
+ for (const seg of path) {
5198
+ if (cur === null || typeof cur !== 'object')
5199
+ return undefined;
5200
+ cur = cur[seg];
4749
5201
  }
4750
- base[seg] = applyAt(base[seg], path, idx + 1, op);
4751
- return base;
5202
+ return cur;
4752
5203
  }
4753
5204
  /**
4754
- * Pure, store-free application of ops onto a plain root value, returning the next immutable root
4755
- * (structural-sharing along op paths, missing containers vivified `'auto'`-style). This is the
4756
- * same transform {@link OpLog.apply} runs, extracted so a replica can fold a received batch into
4757
- * a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
4758
- * Accepts a batch or a bare op list.
5205
+ * The shared rebase routine (op-protocol RFC §5): invert pending, apply remote, re-apply
5206
+ * pending through the merge policies. Pure branching's `rebase()` and the sequenced relay
5207
+ * client both call this.
4759
5208
  */
4760
- function applyOps(root, ops) {
4761
- const list = Array.isArray(ops) ? ops : ops.ops;
4762
- let next = root;
4763
- for (const op of list) {
4764
- if (op.path.length === 0) {
4765
- if (op.kind === 'set')
4766
- next = op.next;
4767
- continue; // a root delete is meaningless — ignore (mirrors OpLog.apply)
5209
+ function rebaseOps(root, pending, remote, policies) {
5210
+ const compiled = compilePolicies(policies ?? []);
5211
+ let base = root;
5212
+ for (let i = pending.length - 1; i >= 0; i--) {
5213
+ base = applyOps(base, invertBatch(pending[i]));
5214
+ }
5215
+ base = applyOps(base, remote);
5216
+ const rebased = [];
5217
+ for (const batch of pending) {
5218
+ const next = [];
5219
+ for (const op of batch) {
5220
+ const cur = getAtPath(base, op.path);
5221
+ if (op.kind === 'delete') {
5222
+ next.push({ kind: 'delete', path: op.path, prev: cur });
5223
+ }
5224
+ else if (cur === undefined) {
5225
+ next.push({ kind: 'set', path: op.path, next: op.next });
5226
+ }
5227
+ else if (Object.hasOwn(op, 'prev') && !structuralEq(op.prev, cur)) {
5228
+ const merge = policyFor(compiled, op.path);
5229
+ const resolved = merge(op.prev, op.next, cur, { path: op.path });
5230
+ next.push({ kind: 'set', path: op.path, next: resolved, prev: cur });
5231
+ }
5232
+ else {
5233
+ next.push({ kind: 'set', path: op.path, next: op.next, prev: cur });
5234
+ }
4768
5235
  }
4769
- next = applyAt(next, op.path, 0, op);
5236
+ base = applyOps(base, next);
5237
+ rebased.push(next);
4770
5238
  }
4771
- return next;
5239
+ return { root: base, pending: rebased };
4772
5240
  }
4773
5241
  /**
4774
- * Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
4775
- * {@link opLog}, exported so code outside a log can produce a batch e.g. diffing a scratch
4776
- * draft against a replica's current value to route a write to its owner). Trusts the
4777
- * copy-on-write contract: an untouched subtree that kept its reference is skipped.
5242
+ * A per-path-policy `ForkStrategy` for `forkStore`: a three-way reconcile built from the
5243
+ * shared rebase (invert mine apply theirs' delta re-apply mine through the policies).
5244
+ * Paths only one side touched resolve like `merge3`; paths BOTH touched go through the
5245
+ * matching {@link MergePolicyEntry} (`lww` default fork wins, matching `'fine'`; or
5246
+ * `mergeThree` / `preserve` / custom). Same copy-on-write contract as `'fine'`.
4778
5247
  */
4779
- function diffOps(prev, next) {
4780
- const ops = [];
4781
- diffNode(prev, next, [], ops);
4782
- return ops;
5248
+ function policyStrategy(policies) {
5249
+ return (ancestor, mine, theirs) => rebaseOps(mine, [diffOps(ancestor, mine)], diffOps(ancestor, theirs), policies).root;
4783
5250
  }
4784
- /**
4785
- * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
4786
- * `prev` — inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4787
- * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4788
- * carry — a wire-serialized batch that stripped them is not invertible.
4789
- */
4790
- function invertBatch(batch) {
4791
- const ops = Array.isArray(batch) ? batch : batch.ops;
4792
- const inverted = [];
4793
- for (let i = ops.length - 1; i >= 0; i--) {
4794
- const op = ops[i];
4795
- if (op.kind === 'delete') {
4796
- inverted.push({
4797
- kind: 'set',
4798
- path: op.path,
4799
- next: op.prev,
4800
- prev: undefined,
4801
- });
4802
- continue;
4803
- }
4804
- if (!Object.hasOwn(op, 'prev')) {
4805
- inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4806
- }
4807
- else {
4808
- inverted.push({
4809
- kind: 'set',
4810
- path: op.path,
4811
- next: op.prev,
4812
- prev: op.next,
4813
- });
4814
- }
4815
- }
4816
- return inverted;
5251
+ function generateOrigin() {
5252
+ if (globalThis.crypto?.randomUUID)
5253
+ return globalThis.crypto.randomUUID();
5254
+ return Math.random().toString(36).substring(2);
4817
5255
  }
4818
5256
  /**
4819
- * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4820
- * immutably-updated objects) and emits its changes as minimal structural op batches — the
4821
- * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4822
- * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4823
- *
4824
- * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4825
- * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4826
- * effect. A batch therefore coalesces everything written in one tick — for coarser,
4827
- * intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
4828
- *
4829
- * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4830
- * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4831
- * warning fires and nothing emits.
4832
- *
4833
- * ```ts
4834
- * const s = store({ todos: [{ done: false }] });
4835
- * const log = opLog(s, { origin: 'tab-a' });
4836
- * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4837
- * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4838
- * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4839
- * ```
5257
+ * Wires a copy-on-write signal (a `store` root) to the op protocol: local writes emit
5258
+ * stamped envelopes, received envelopes fold in through the converging apply. The
5259
+ * unsequenced-topology client core that `tabSync(store)` and P2P transports build on.
4840
5260
  */
4841
- function opLog(source, opt) {
4842
- const origin = opt?.origin ?? generateOrigin();
4843
- // a store proxy's `has` trap answers for the VALUE's keys, so `isMutable`'s `'mutate' in`
4844
- // probe can't see the brand — ask the store's own kind symbol first
4845
- const storeKind = source[STORE_KIND];
4846
- const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4847
- if (isDevMode() && mutableSource) {
4848
- 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.');
4849
- }
4850
- let prevRoot = untracked(source);
4851
- let version = 0;
4852
- let destroyed = false;
5261
+ const RECENT_LOCAL_CAP = 64;
5262
+ function opSync(source, opt) {
5263
+ const origin = opt.origin ?? generateOrigin();
5264
+ const clock = opt.clock ?? createHlcClock();
5265
+ const conv = createConvergingApply({ policies: opt.policies });
4853
5266
  const subscribers = new Set();
4854
- const latest = signal(null, /* @ts-ignore */
4855
- ...(ngDevMode ? [{ debugName: "latest" }] : /* istanbul ignore next */ []));
4856
- /** Diff now, emit if there's a delta, advance the baseline. */
4857
- const flush = () => {
4858
- if (destroyed)
4859
- return;
4860
- const next = untracked(source);
4861
- if (Object.is(prevRoot, next))
4862
- return;
4863
- const ops = [];
4864
- diffNode(prevRoot, next, [], ops);
4865
- prevRoot = next;
4866
- if (!ops.length)
4867
- return; // fresh refs, equal values — spurious-write tolerance
4868
- const batch = { origin, version: ++version, ops };
4869
- latest.set(batch);
5267
+ const versions = new Map();
5268
+ const recentLocal = [];
5269
+ let version = 0;
5270
+ const log = opLog(source, opt.driver
5271
+ ? { origin, driver: opt.driver }
5272
+ : { origin, injector: opt.injector ?? inject(Injector) });
5273
+ const emitLocal = (ops) => {
5274
+ const env = {
5275
+ proto: OP_PROTO_VERSION,
5276
+ origin,
5277
+ writer: opt.writer,
5278
+ version: ++version,
5279
+ hlc: clock.next(),
5280
+ policyVersion: opt.policyVersion ?? 0,
5281
+ ops,
5282
+ };
5283
+ versions.set(origin, env.version);
5284
+ conv.ingest(env, { local: true });
5285
+ recentLocal.push(env);
5286
+ if (recentLocal.length > RECENT_LOCAL_CAP)
5287
+ recentLocal.shift();
4870
5288
  for (const cb of [...subscribers])
4871
- cb(batch);
5289
+ cb(env);
4872
5290
  };
4873
- const run = () => {
4874
- source(); // track every commit…
4875
- untracked(flush); // …and emit the delta since the last flush
4876
- };
4877
- // default driver is an Angular effect (needs an injector); a supplied driver runs injector-free
4878
- // (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
4879
- const ref = opt?.driver
4880
- ? opt.driver(run)
4881
- : effect(run, { injector: opt?.injector ?? inject(Injector) });
5291
+ const unsub = log.subscribe((batch) => emitLocal(batch.ops));
4882
5292
  return {
4883
- latest: latest.asReadonly(),
5293
+ origin,
4884
5294
  subscribe: (cb) => {
4885
5295
  subscribers.add(cb);
4886
5296
  return () => subscribers.delete(cb);
4887
5297
  },
4888
- // the emission core, callable on demand — reads the source untracked, so it never disturbs the
4889
- // driver's subscription; a subsequent scheduled run just finds the baseline already advanced
4890
- flush: () => flush(),
4891
- apply: (batchOrOps) => {
4892
- const ops = Array.isArray(batchOrOps)
4893
- ? batchOrOps
4894
- : batchOrOps.ops;
4895
- if (!ops.length)
5298
+ receive: (env) => {
5299
+ if (env.origin === origin)
4896
5300
  return;
4897
- // pending local writes must emit BEFORE the baseline advances past them
4898
- flush();
4899
- const root = applyOps(untracked(source), ops); // one atomic root, structural-shared
4900
- source.set(root);
4901
- prevRoot = root; // baseline advance: an applied batch never echoes
5301
+ if (env.proto !== OP_PROTO_VERSION) {
5302
+ if (isDevMode()) {
5303
+ console.warn(`[@mmstack/primitives] dropped envelope with proto ${env.proto} (expected ${OP_PROTO_VERSION})`);
5304
+ }
5305
+ return;
5306
+ }
5307
+ clock.observe(env.hlc);
5308
+ const known = versions.get(env.origin);
5309
+ if (known !== undefined && env.version <= known)
5310
+ return; // duplicate/covered — idempotent
5311
+ if (known !== undefined && env.version !== known + 1) {
5312
+ opt.onGap?.(env.origin, known + 1, env.version);
5313
+ }
5314
+ versions.set(env.origin, env.version);
5315
+ log.flush();
5316
+ const ops = conv.ingest(env);
5317
+ if (ops.length)
5318
+ log.apply(ops);
5319
+ },
5320
+ flush: () => log.flush(),
5321
+ watermark: () => Object.fromEntries(versions),
5322
+ snapshot: () => {
5323
+ log.flush();
5324
+ return { root: untracked(source), wm: Object.fromEntries(versions) };
5325
+ },
5326
+ seed: () => {
5327
+ log.flush();
5328
+ emitLocal([{ kind: 'set', path: [], next: untracked(source) }]);
5329
+ },
5330
+ hydrate: (root, wm) => {
5331
+ log.flush();
5332
+ const covered = wm?.[origin] ?? 0;
5333
+ const pending = recentLocal.filter((e) => e.version > covered);
5334
+ conv.reset();
5335
+ let next = root;
5336
+ for (const e of pending)
5337
+ next = applyOps(next, e.ops);
5338
+ log.apply([{ kind: 'set', path: [], next }]);
5339
+ for (const [o, v] of Object.entries(wm ?? {})) {
5340
+ versions.set(o, Math.max(versions.get(o) ?? 0, v));
5341
+ }
5342
+ for (const e of pending)
5343
+ conv.ingest(e, { local: true });
4902
5344
  },
4903
5345
  destroy: () => {
4904
- destroyed = true;
5346
+ unsub();
4905
5347
  subscribers.clear();
4906
- ref.destroy();
5348
+ log.destroy();
5349
+ },
5350
+ };
5351
+ }
5352
+
5353
+ /**
5354
+ * Undo/redo for a copy-on-write store, built on the op-log: each tracked change is stored as
5355
+ * its inverse batch, so `undo()` is one `apply` and history costs only the diffs, not full
5356
+ * snapshots. Redoing is invert-of-the-inverse. A new edit made after an undo clears the redo
5357
+ * stack (linear history). Applying a redo/undo does not itself re-enter history.
5358
+ *
5359
+ * Composes with sync for collaborative undo: pass `track: syncClient` so only YOUR writes are
5360
+ * undoable, while `undo()` emits a normal op that propagates to peers (it writes through the
5361
+ * store, which the sync client picks up).
5362
+ */
5363
+ function storeHistory(source, opt) {
5364
+ const limit = opt?.limit ?? 100;
5365
+ const logOpt = { origin: opt?.origin };
5366
+ if (opt?.driver)
5367
+ logOpt.driver = opt.driver;
5368
+ else
5369
+ logOpt.injector = opt?.injector ?? inject(Injector);
5370
+ const log = opLog(source, logOpt);
5371
+ const undoStack = [];
5372
+ const redoStack = [];
5373
+ const version = signal(0, /* @ts-ignore */
5374
+ ...(ngDevMode ? [{ debugName: "version" }] : /* istanbul ignore next */ [])); // monotonic: bumps on every mutation so the computeds recompute
5375
+ let applying = false;
5376
+ const push = (stack, inverse) => {
5377
+ stack.push(inverse);
5378
+ if (stack.length > limit)
5379
+ stack.shift();
5380
+ };
5381
+ const record = (batch) => {
5382
+ if (applying)
5383
+ return; // an undo/redo's own emission must not re-enter history
5384
+ if (!batch.ops.length)
5385
+ return;
5386
+ push(undoStack, invertBatch(batch));
5387
+ redoStack.length = 0; // a fresh edit forks the timeline
5388
+ version.update((v) => v + 1);
5389
+ };
5390
+ // track the sync client's local stream when given, else self-diff every store change
5391
+ const unsub = (opt?.track ?? log).subscribe(record);
5392
+ const run = (from, to) => {
5393
+ const inverse = from.pop();
5394
+ if (!inverse)
5395
+ return;
5396
+ log.flush(); // settle pending local writes before applying
5397
+ applying = true;
5398
+ try {
5399
+ log.apply(inverse);
5400
+ }
5401
+ finally {
5402
+ applying = false;
5403
+ }
5404
+ push(to, invertBatch(inverse)); // the inverse of what we applied restores the other direction
5405
+ version.update((v) => v + 1);
5406
+ };
5407
+ return {
5408
+ canUndo: computed(() => (version(), undoStack.length > 0)),
5409
+ canRedo: computed(() => (version(), redoStack.length > 0)),
5410
+ undo: () => run(undoStack, redoStack),
5411
+ redo: () => run(redoStack, undoStack),
5412
+ clear: () => {
5413
+ undoStack.length = 0;
5414
+ redoStack.length = 0;
5415
+ version.update((v) => v + 1);
5416
+ },
5417
+ destroy: () => {
5418
+ unsub();
5419
+ log.destroy();
5420
+ },
5421
+ };
5422
+ }
5423
+
5424
+ const PERSISTED_STORE_OPTIONS = new InjectionToken('@mmstack/primitives:persisted-store-options');
5425
+ /**
5426
+ * Wire the {@link AsyncStore} backend (and any shared debounce) once, override per call. The
5427
+ * typical use is to install idb-keyval at bootstrap so every `persist`/`persistedStore` persists
5428
+ * without re-passing the backend.
5429
+ *
5430
+ * @example
5431
+ * import * as idbKeyval from 'idb-keyval';
5432
+ * providePersistedStoreOptions({ store: idbKeyval });
5433
+ */
5434
+ function providePersistedStoreOptions(opt) {
5435
+ return { provide: PERSISTED_STORE_OPTIONS, useValue: opt };
5436
+ }
5437
+ /**
5438
+ * Attach durable local persistence to an EXISTING store: its whole-value snapshot is written to an
5439
+ * async backend (IndexedDB via idb-keyval or Dexie) and restored on boot. A reader over the store,
5440
+ * so it composes with the other op-log readers (`tabSync`, `@mmstack/mesh`) on the same store — a
5441
+ * persisted, synced graph is just two readers. Local durability, not sync.
5442
+ *
5443
+ * Because the backend is async, hydration cannot precede the first read: the store keeps its current
5444
+ * value, then adopts the persisted snapshot once the backend answers, UNLESS a write happened first
5445
+ * (an explicit boot-time write wins over stale disk). Writes are coalesced and flushed on teardown
5446
+ * and on page hide, so the last change is never lost. On the server it is a no-op.
5447
+ *
5448
+ * When the persisted shape evolves, pass `version` and a `migrate` hook: an older snapshot is
5449
+ * brought forward on boot before it is adopted, then re-persisted in the new shape. Because boot is
5450
+ * already async, `migrate` may be async, so the migration ladder can be lazy-imported.
5451
+ */
5452
+ function persist(source, opt) {
5453
+ const injector = opt.injector ?? inject(Injector);
5454
+ const defaults = injector.get(PERSISTED_STORE_OPTIONS, null);
5455
+ const key = opt.key;
5456
+ const backend = opt.store ?? defaults?.store;
5457
+ const serialize = opt.serialize ?? ((v) => v);
5458
+ const deserialize = opt.deserialize ?? ((r) => r);
5459
+ const version = opt.version;
5460
+ const debounceMs = opt.writeDebounceMs ?? defaults?.writeDebounceMs ?? 300;
5461
+ const read = source;
5462
+ const setRoot = (value) => source.set(value);
5463
+ const VERSION_KEY = '__mmstack_pv';
5464
+ const encode = (value) => version === undefined
5465
+ ? serialize(value)
5466
+ : { [VERSION_KEY]: version, data: serialize(value) };
5467
+ const isServer = isPlatformServer(injector.get(PLATFORM_ID));
5468
+ const initialRef = untracked(read); // copy-on-write: an untouched store keeps this reference
5469
+ const hydrated = signal(false, /* @ts-ignore */
5470
+ ...(ngDevMode ? [{ debugName: "hydrated" }] : /* istanbul ignore next */ []));
5471
+ if (isServer || !backend) {
5472
+ if (!backend && !isServer && isDevMode()) {
5473
+ console.warn(`[@mmstack/primitives] persist("${key}"): no AsyncStore backend (pass { store } or providePersistedStoreOptions). Running in-memory, not persisted.`);
5474
+ }
5475
+ hydrated.set(true);
5476
+ return {
5477
+ hydrated: hydrated.asReadonly(),
5478
+ flush: () => Promise.resolve(),
5479
+ clear: () => {
5480
+ setRoot(initialRef);
5481
+ return Promise.resolve();
5482
+ },
5483
+ };
5484
+ }
5485
+ let persistedRef = initialRef;
5486
+ void (async () => {
5487
+ try {
5488
+ const raw = await backend.get(key);
5489
+ // apply the snapshot only if nothing wrote in the boot window (explicit write wins)
5490
+ if (raw !== undefined && raw !== null && untracked(read) === initialRef) {
5491
+ let fromVersion = 0;
5492
+ let payload = raw;
5493
+ if (typeof raw === 'object' &&
5494
+ raw !== null &&
5495
+ VERSION_KEY in raw) {
5496
+ const env = raw;
5497
+ fromVersion =
5498
+ typeof env[VERSION_KEY] === 'number'
5499
+ ? env[VERSION_KEY]
5500
+ : 0;
5501
+ payload = env['data'];
5502
+ }
5503
+ const target = version ?? 0;
5504
+ if (fromVersion > target) {
5505
+ if (isDevMode()) {
5506
+ console.warn(`[@mmstack/primitives] persist("${key}"): stored snapshot is version ${fromVersion} but this build is ${target}; leaving it untouched (a newer build wrote it).`);
5507
+ }
5508
+ }
5509
+ else {
5510
+ const migrated = !!(opt.migrate && fromVersion < target);
5511
+ let value = deserialize(payload);
5512
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
5513
+ if (migrated)
5514
+ value = await opt.migrate(value, fromVersion);
5515
+ if (untracked(read) === initialRef) {
5516
+ setRoot(value);
5517
+ if (!migrated)
5518
+ persistedRef = value;
5519
+ }
5520
+ }
5521
+ }
5522
+ }
5523
+ catch (err) {
5524
+ if (isDevMode()) {
5525
+ console.warn(`[@mmstack/primitives] persist("${key}") hydrate failed`, err);
5526
+ }
5527
+ }
5528
+ finally {
5529
+ hydrated.set(true);
5530
+ }
5531
+ })();
5532
+ let timer;
5533
+ const write = async (value) => {
5534
+ try {
5535
+ await backend.set(key, encode(value));
5536
+ persistedRef = value;
5537
+ }
5538
+ catch (err) {
5539
+ if (isDevMode()) {
5540
+ console.warn(`[@mmstack/primitives] persist("${key}") write failed`, err);
5541
+ }
5542
+ }
5543
+ };
5544
+ const cancelTimer = () => {
5545
+ if (timer !== undefined) {
5546
+ clearTimeout(timer);
5547
+ timer = undefined;
5548
+ }
5549
+ };
5550
+ const flush = async () => {
5551
+ cancelTimer();
5552
+ const current = untracked(read);
5553
+ if (!untracked(hydrated) ||
5554
+ current === initialRef ||
5555
+ current === persistedRef)
5556
+ return;
5557
+ await write(current);
5558
+ };
5559
+ effect(() => {
5560
+ if (!hydrated())
5561
+ return;
5562
+ const value = read();
5563
+ untracked(() => {
5564
+ cancelTimer();
5565
+ // untouched / reset-to-initial, or already the value on disk (e.g. just hydrated): skip
5566
+ if (value === initialRef || value === persistedRef)
5567
+ return;
5568
+ timer = setTimeout(() => {
5569
+ timer = undefined;
5570
+ void write(value);
5571
+ }, debounceMs);
5572
+ });
5573
+ }, { injector });
5574
+ const onHide = () => {
5575
+ void flush();
5576
+ };
5577
+ if (typeof document !== 'undefined') {
5578
+ document.addEventListener('visibilitychange', onHide);
5579
+ window.addEventListener('pagehide', onHide);
5580
+ }
5581
+ injector.get(DestroyRef).onDestroy(() => {
5582
+ void flush();
5583
+ if (typeof document !== 'undefined') {
5584
+ document.removeEventListener('visibilitychange', onHide);
5585
+ window.removeEventListener('pagehide', onHide);
5586
+ }
5587
+ });
5588
+ return {
5589
+ hydrated: hydrated.asReadonly(),
5590
+ flush,
5591
+ clear: async () => {
5592
+ cancelTimer();
5593
+ setRoot(initialRef); // back to initialRef, so the persist effect skips (no re-write over the delete)
5594
+ persistedRef = initialRef; // disk is now empty
5595
+ try {
5596
+ await backend.del(key);
5597
+ }
5598
+ catch (err) {
5599
+ if (isDevMode()) {
5600
+ console.warn(`[@mmstack/primitives] persist("${key}") clear failed`, err);
5601
+ }
5602
+ }
4907
5603
  },
4908
5604
  };
4909
5605
  }
5606
+ /**
5607
+ * A `store` with {@link persist} already attached: a whole-value snapshot persisted to an async
5608
+ * backend and restored on boot. Equivalent to `const s = store(initial); persist(s, opt)` — reach
5609
+ * for `persist` directly when you want persistence on a store you already have (e.g. to also
5610
+ * `meshSync` it).
5611
+ */
5612
+ function persistedStore(initial, opt) {
5613
+ const injector = opt.injector ?? inject(Injector);
5614
+ // store() reads only the signal/store opts it knows; the persistence keys ride along harmlessly
5615
+ const s = store(initial, { ...opt, injector });
5616
+ const handle = persist(s, { ...opt, injector });
5617
+ return { store: s, ...handle };
5618
+ }
4910
5619
 
4911
5620
  const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
4912
5621
  function keyOf(item, key) {
@@ -4985,13 +5694,9 @@ function reconcileValue(prev, next, key) {
4985
5694
  */
4986
5695
  function projection(fn, seed, opt) {
4987
5696
  const { key = 'id', ...storeOpt } = opt ?? {};
4988
- // linkedSignal rather than an effect-driven signal: the computation runs in the tracked
4989
- // context (fn's reads are dependencies) and `previous` hands back the last emitted value for
4990
- // the reconcile, so the projection is glitch-free, lazy, and needs no effect scheduler.
4991
5697
  const root = linkedSignal({ ...(ngDevMode ? { debugName: "root" } : /* istanbul ignore next */ {}), source: () => undefined,
4992
5698
  computation: (_, previous) => {
4993
5699
  const base = previous ? previous.value : seed;
4994
- // a plain mutable scratch seeded with the current value; fn mutates it or returns new data
4995
5700
  const draft = structuredClone(base);
4996
5701
  const returned = fn(draft);
4997
5702
  const next = (returned === undefined ? draft : returned);
@@ -5187,6 +5892,85 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
5187
5892
  return writable;
5188
5893
  }
5189
5894
 
5895
+ /** Op-mode sync for a writable store: hello exchange, then live envelopes (RFC §6 tab flavor). */
5896
+ function storeTabSync(sig, opt, bus, injector) {
5897
+ const sync = opSync(sig, {
5898
+ writer: opt.writer ?? 'local',
5899
+ policies: opt.policies,
5900
+ injector,
5901
+ });
5902
+ const helloTimeoutMs = opt.helloTimeoutMs ?? 250;
5903
+ const jitterMs = opt.jitterMs ?? 25;
5904
+ let phase = 'joining';
5905
+ const joinBuffer = [];
5906
+ const responseTimers = new Map();
5907
+ let helloTimer;
5908
+ function goLive() {
5909
+ if (phase === 'live')
5910
+ return;
5911
+ phase = 'live';
5912
+ if (helloTimer !== undefined) {
5913
+ clearTimeout(helloTimer);
5914
+ helloTimer = undefined;
5915
+ }
5916
+ for (const env of joinBuffer.splice(0))
5917
+ sync.receive(env);
5918
+ }
5919
+ const { unsub, post } = bus.subscribe(opt.id, (msg) => {
5920
+ if (!msg || typeof msg !== 'object')
5921
+ return;
5922
+ switch (msg.t) {
5923
+ case 'env':
5924
+ if (phase === 'joining')
5925
+ joinBuffer.push(msg.env);
5926
+ else
5927
+ sync.receive(msg.env);
5928
+ return;
5929
+ case 'hello': {
5930
+ if (phase !== 'live' || msg.from === sync.origin)
5931
+ return;
5932
+ // first responder wins: jittered answer, cancelled when someone else answers first
5933
+ const timer = setTimeout(() => {
5934
+ responseTimers.delete(msg.from);
5935
+ const snap = sync.snapshot();
5936
+ const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
5937
+ post(covered
5938
+ ? { t: 'uptodate', to: msg.from }
5939
+ : { t: 'state', to: msg.from, root: snap.root, wm: snap.wm });
5940
+ }, Math.random() * jitterMs);
5941
+ responseTimers.set(msg.from, timer);
5942
+ return;
5943
+ }
5944
+ case 'state':
5945
+ case 'uptodate': {
5946
+ const scheduled = responseTimers.get(msg.to);
5947
+ if (scheduled !== undefined) {
5948
+ clearTimeout(scheduled);
5949
+ responseTimers.delete(msg.to);
5950
+ }
5951
+ if (msg.to !== sync.origin || phase !== 'joining')
5952
+ return;
5953
+ if (msg.t === 'state')
5954
+ sync.hydrate(msg.root, msg.wm);
5955
+ goLive();
5956
+ return;
5957
+ }
5958
+ }
5959
+ });
5960
+ const unsubEnv = sync.subscribe((env) => post({ t: 'env', env }));
5961
+ post({ t: 'hello', from: sync.origin, wm: sync.watermark() });
5962
+ helloTimer = setTimeout(goLive, helloTimeoutMs);
5963
+ injector.get(DestroyRef).onDestroy(() => {
5964
+ if (helloTimer !== undefined)
5965
+ clearTimeout(helloTimer);
5966
+ for (const timer of responseTimers.values())
5967
+ clearTimeout(timer);
5968
+ responseTimers.clear();
5969
+ unsubEnv();
5970
+ unsub();
5971
+ sync.destroy();
5972
+ });
5973
+ }
5190
5974
  class MessageBus {
5191
5975
  channel = new BroadcastChannel('mmstack-tab-sync-bus');
5192
5976
  listeners = new Map();
@@ -5298,6 +6082,20 @@ function tabSync(sig, opt) {
5298
6082
  return sig;
5299
6083
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
5300
6084
  const bus = injector.get(MessageBus);
6085
+ const storeKind = sig[STORE_KIND];
6086
+ if (storeKind === 'writable') {
6087
+ storeTabSync(sig, { ...optObj, id }, bus, injector);
6088
+ return sig;
6089
+ }
6090
+ if (storeKind === 'readonly') {
6091
+ if (isDevMode()) {
6092
+ console.warn('[@mmstack/primitives] tabSync: a readonly store cannot receive remote ops — not synced.');
6093
+ }
6094
+ return sig;
6095
+ }
6096
+ if (storeKind === 'mutable' && isDevMode()) {
6097
+ console.warn('[@mmstack/primitives] tabSync: mutable stores fall back to whole-value sync (op diffing needs copy-on-write).');
6098
+ }
5301
6099
  const NONE = Symbol();
5302
6100
  let received = NONE;
5303
6101
  const { unsub, post } = bus.subscribe(id, (next) => {
@@ -5528,5 +6326,5 @@ function withHistory(sourceOrValue, opt) {
5528
6326
  * Generated bundle index. Do not edit.
5529
6327
  */
5530
6328
 
5531
- 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 };
6329
+ 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 };
5532
6330
  //# sourceMappingURL=mmstack-primitives.mjs.map