@mmstack/primitives 22.5.0 → 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,6 +4266,229 @@ 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);
4275
+ /**
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).
4279
+ */
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;
4298
+ }
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;
4309
+ }
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;
4335
+ }
4336
+ /**
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.
4342
+ */
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;
4355
+ }
4356
+ /**
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.
4361
+ */
4362
+ function diffOps(prev, next) {
4363
+ const ops = [];
4364
+ diffNode(prev, next, [], ops);
4365
+ return ops;
4366
+ }
4367
+ /**
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`).
4406
+ *
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).
4411
+ *
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
+ * ```
4423
+ */
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
+
4221
4492
  /**
4222
4493
  * @internal Reads (or lazily builds + caches) the child node proxy for `prop` on `target`,
4223
4494
  * holding it via a `WeakRef` and registering it for finalizer-driven cache pruning. The cache
@@ -4296,7 +4567,11 @@ function buildChildNode(target, prop, isMutableSource, options) {
4296
4567
  function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...rest } = {}) {
4297
4568
  if (isStore(source))
4298
4569
  return source;
4299
- if (!injector)
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)
4300
4575
  injector = inject(Injector);
4301
4576
  const writableSource = isWritableSignal(source)
4302
4577
  ? source
@@ -4315,13 +4590,17 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4315
4590
  }, /* @ts-ignore */
4316
4591
  ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
4317
4592
  const STORE_OPTIONS = {
4318
- injector,
4593
+ // may be undefined in worker/DI-less mode; unused downstream once globals are resolved
4594
+ // (children thread the resolved globals via STORE_SHARED_OPTIONS, derived needs no injector)
4595
+ injector: injector,
4319
4596
  vivify,
4320
4597
  noUnionLeaves,
4321
4598
  [STORE_SHARED_GLOBALS]: {
4322
- cache: rest[STORE_SHARED_GLOBALS]?.cache ?? injector.get(PROXY_CACHE_TOKEN),
4323
- registry: rest[STORE_SHARED_GLOBALS]?.registry ??
4324
- injector.get(PROXY_CLEANUP_TOKEN),
4599
+ // the `injector!` reads run only when a global is absent
4600
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4601
+ cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
4602
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4603
+ registry: sharedGlobals?.registry ?? injector.get(PROXY_CLEANUP_TOKEN),
4325
4604
  },
4326
4605
  };
4327
4606
  // built lazily so non-array nodes never allocate it
@@ -4393,7 +4672,12 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4393
4672
  return () => {
4394
4673
  if (!isWritableSource)
4395
4674
  return s;
4396
- return untracked(() => toStore(source.asReadonly(), { injector, vivify, noUnionLeaves }));
4675
+ return untracked(() => toStore(source.asReadonly(), {
4676
+ injector,
4677
+ vivify,
4678
+ noUnionLeaves,
4679
+ [STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
4680
+ }));
4397
4681
  };
4398
4682
  const k = untracked(kind);
4399
4683
  if (prop === 'extend' && k !== 'array')
@@ -4407,8 +4691,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4407
4691
  return arrayLength();
4408
4692
  if (prop === Symbol.iterator)
4409
4693
  return function* () {
4410
- // read length reactively: a spread/for-of inside a computed/effect must re-run
4411
- // when items are added or removed, not only when already-read elements change
4412
4694
  const len = arrayLength();
4413
4695
  for (let i = 0; i < len(); i++)
4414
4696
  yield receiver[i];
@@ -4559,6 +4841,40 @@ function mutableStore(value, opt) {
4559
4841
  ...opt,
4560
4842
  });
4561
4843
  }
4844
+ /**
4845
+ * Builds a DI-less store context — the shared proxy-cache and cleanup registry that {@link toStore}
4846
+ * normally resolves from the injector — so a `store`/`toStore`/`opLog` graph can run with NO Angular
4847
+ * injection context. Spread the result into the options:
4848
+ *
4849
+ * ```ts
4850
+ * import { microtaskOpLogDriver } from '@mmstack/worker/host';
4851
+ * const ctx = createStoreContext();
4852
+ * const s = store({ todos: [] }, ctx);
4853
+ * const log = opLog(s, { driver: microtaskOpLogDriver(), origin: 'worker' }); // no injector anywhere
4854
+ * ```
4855
+ *
4856
+ * **This is a worker-only fallback — do NOT use it on the main thread.** DI is the default and
4857
+ * correct path in an app: the injector scopes the proxy-cache/cleanup singletons per app instance,
4858
+ * which on the SERVER keeps one request's store identity from bleeding into another's (the exact
4859
+ * hazard a module-scope singleton would reintroduce). A Web Worker is safe because it is a single
4860
+ * store graph per thread and never runs during SSR (spawn is a `PLATFORM_ID === 'server'` no-op),
4861
+ * so there is no cross-request scope to contaminate. Never hoist a `createStoreContext()` to module
4862
+ * scope on a shared/main thread.
4863
+ *
4864
+ * **Share ONE context across every store in a worker** — the same way `providedIn: 'root'` shares
4865
+ * one cache across all of an app's stores. `@mmstack/worker/host` memoizes this per worker
4866
+ * (`workerStoreContext()`); reach for `createStoreContext()` directly only in a bare
4867
+ * (non-worker-host) DI-less setup, and hold the single instance yourself.
4868
+ */
4869
+ function createStoreContext() {
4870
+ const cache = new WeakMap();
4871
+ const registry = new FinalizationRegistry(({ target, prop }) => {
4872
+ const entry = cache.get(target);
4873
+ if (entry)
4874
+ entry.delete(prop);
4875
+ });
4876
+ return { [STORE_SHARED_GLOBALS]: { cache, registry } };
4877
+ }
4562
4878
 
4563
4879
  function isPlainRecord(value) {
4564
4880
  if (value === null || typeof value !== 'object')
@@ -4619,9 +4935,6 @@ function forkStore(base, opt) {
4619
4935
  const merge = reconcile;
4620
4936
  const staged = linkedSignal({ ...(ngDevMode ? { debugName: "staged" } : /* istanbul ignore next */ {}), source: () => base(),
4621
4937
  computation: (theirs, prev) => prev === undefined ? theirs : merge(prev.source, prev.value, theirs) });
4622
- // Inherit the base's shared options (injector, vivify, noUnionLeaves + the
4623
- // proxy cache/registry), same as extendStore — a fork should vivify like its
4624
- // base and share its injector-scoped cache. `opt` overrides (advanced use).
4625
4938
  const store = toStore(staged, {
4626
4939
  ...base[STORE_SHARED_OPTIONS],
4627
4940
  ...opt,
@@ -4630,193 +4943,767 @@ function forkStore(base, opt) {
4630
4943
  store,
4631
4944
  commit: () => base.set(untracked(staged)),
4632
4945
  discard: () => staged.set(untracked(base)),
4946
+ ops: () => diffOps(untracked(base), untracked(staged)),
4633
4947
  };
4634
4948
  }
4635
4949
 
4636
- function generateOrigin() {
4637
- if (globalThis.crypto?.randomUUID)
4638
- return globalThis.crypto.randomUUID();
4639
- 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
+ };
4640
4992
  }
4641
- const isPlainArray = (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 });
4642
5002
  /**
4643
- * Reference-identity-pruned structural diff the same short-circuit discipline as `merge3`:
4644
- * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4645
- * 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.
4646
5011
  */
4647
- function diffNode(prev, next, path, ops) {
4648
- if (Object.is(prev, next))
4649
- return;
4650
- if (isRecord(prev) && isRecord(next)) {
4651
- for (const key of Object.keys(prev)) {
4652
- if (!Object.hasOwn(next, key))
4653
- 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
4654
5017
  }
4655
- for (const key of Object.keys(next)) {
4656
- if (!Object.hasOwn(prev, key)) {
4657
- // added key: deliberately NO `prev` property (absent ≠ undefined)
4658
- 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));
4659
5037
  }
4660
- else {
4661
- 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
4662
5040
  }
5041
+ // else: theirs removed it and mine left it untouched → stays removed
4663
5042
  }
4664
- return;
4665
- }
4666
- if (isPlainArray(prev) && isPlainArray(next)) {
4667
- // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4668
- // change is a whole unit — index attribution lies under insert/remove/reorder
4669
- if (prev.length === next.length) {
4670
- for (let i = 0; i < next.length; i++)
4671
- diffNode(prev[i], next[i], [...path, i], ops);
4672
- 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
+ }
4673
5050
  }
4674
- ops.push({ kind: 'set', path, prev, next });
4675
- return;
4676
- }
4677
- // leaf / type change / opaque — one unit, prev present (the slot existed)
4678
- ops.push({ kind: 'set', path, prev, next });
5051
+ return out;
5052
+ };
4679
5053
  }
4680
- /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4681
- function applyAt(container, path, idx, op) {
4682
- const seg = path[idx];
4683
- const base = isPlainArray(container)
4684
- ? container.slice()
4685
- : isRecord(container)
4686
- ? { ...container }
4687
- : typeof seg === 'number'
4688
- ? []
4689
- : {};
4690
- if (idx === path.length - 1) {
4691
- if (op.kind === 'delete') {
4692
- // arrays never receive deletes (length changes travel as whole-array sets)
4693
- delete base[seg];
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;
4694
5067
  }
4695
- else {
4696
- base[seg] = op.next;
5068
+ return p.merge;
5069
+ }
5070
+ return lww;
5071
+ }
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;
4697
5093
  }
4698
- return base;
4699
5094
  }
4700
- base[seg] = applyAt(base[seg], path, idx + 1, op);
4701
- return base;
5095
+ return true;
4702
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;
4703
5106
  /**
4704
- * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add a `set` with no
4705
- * `prev` inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4706
- * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4707
- * carry — a wire-serialized batch that stripped them is not invertible.
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.
4708
5110
  */
4709
- function invertBatch(batch) {
4710
- const ops = Array.isArray(batch) ? batch : batch.ops;
4711
- const inverted = [];
4712
- for (let i = ops.length - 1; i >= 0; i--) {
4713
- const op = ops[i];
4714
- if (op.kind === 'delete') {
4715
- inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: undefined });
4716
- continue;
4717
- }
4718
- if (!Object.hasOwn(op, 'prev')) {
4719
- inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4720
- }
4721
- else {
4722
- inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: op.next });
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;
4723
5118
  }
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];
4724
5201
  }
4725
- return inverted;
5202
+ return cur;
4726
5203
  }
4727
5204
  /**
4728
- * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4729
- * immutably-updated objects) and emits its changes as minimal structural op batches the
4730
- * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4731
- * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4732
- *
4733
- * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4734
- * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4735
- * effect. A batch therefore coalesces everything written in one tick — for coarser,
4736
- * intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
4737
- *
4738
- * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4739
- * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4740
- * warning fires and nothing emits.
4741
- *
4742
- * ```ts
4743
- * const s = store({ todos: [{ done: false }] });
4744
- * const log = opLog(s, { origin: 'tab-a' });
4745
- * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4746
- * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4747
- * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4748
- * ```
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.
4749
5208
  */
4750
- function opLog(source, opt) {
4751
- const injector = opt?.injector ?? inject(Injector);
4752
- const origin = opt?.origin ?? generateOrigin();
4753
- // a store proxy's `has` trap answers for the VALUE's keys, so `isMutable`'s `'mutate' in`
4754
- // probe can't see the brand — ask the store's own kind symbol first
4755
- const storeKind = source[STORE_KIND];
4756
- const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4757
- if (isDevMode() && mutableSource) {
4758
- 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.');
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]));
4759
5214
  }
4760
- let prevRoot = untracked(source);
4761
- let version = 0;
4762
- let destroyed = false;
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
+ }
5235
+ }
5236
+ base = applyOps(base, next);
5237
+ rebased.push(next);
5238
+ }
5239
+ return { root: base, pending: rebased };
5240
+ }
5241
+ /**
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'`.
5247
+ */
5248
+ function policyStrategy(policies) {
5249
+ return (ancestor, mine, theirs) => rebaseOps(mine, [diffOps(ancestor, mine)], diffOps(ancestor, theirs), policies).root;
5250
+ }
5251
+ function generateOrigin() {
5252
+ if (globalThis.crypto?.randomUUID)
5253
+ return globalThis.crypto.randomUUID();
5254
+ return Math.random().toString(36).substring(2);
5255
+ }
5256
+ /**
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.
5260
+ */
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 });
4763
5266
  const subscribers = new Set();
4764
- const latest = signal(null, /* @ts-ignore */
4765
- ...(ngDevMode ? [{ debugName: "latest" }] : /* istanbul ignore next */ []));
4766
- /** Diff now, emit if there's a delta, advance the baseline. */
4767
- const flush = () => {
4768
- if (destroyed)
4769
- return;
4770
- const next = untracked(source);
4771
- if (Object.is(prevRoot, next))
4772
- return;
4773
- const ops = [];
4774
- diffNode(prevRoot, next, [], ops);
4775
- prevRoot = next;
4776
- if (!ops.length)
4777
- return; // fresh refs, equal values — spurious-write tolerance
4778
- const batch = { origin, version: ++version, ops };
4779
- 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();
4780
5288
  for (const cb of [...subscribers])
4781
- cb(batch);
5289
+ cb(env);
4782
5290
  };
4783
- const ref = effect(() => {
4784
- source(); // track every commit…
4785
- untracked(flush); // …and emit the delta since the last flush
4786
- }, { ...(ngDevMode ? { debugName: "ref" } : /* istanbul ignore next */ {}), injector: opt?.injector });
5291
+ const unsub = log.subscribe((batch) => emitLocal(batch.ops));
4787
5292
  return {
4788
- latest: latest.asReadonly(),
5293
+ origin,
4789
5294
  subscribe: (cb) => {
4790
5295
  subscribers.add(cb);
4791
5296
  return () => subscribers.delete(cb);
4792
5297
  },
4793
- apply: (batchOrOps) => {
4794
- const ops = Array.isArray(batchOrOps)
4795
- ? batchOrOps
4796
- : batchOrOps.ops;
4797
- if (!ops.length)
5298
+ receive: (env) => {
5299
+ if (env.origin === origin)
4798
5300
  return;
4799
- // pending local writes must emit BEFORE the baseline advances past them
4800
- flush();
4801
- let root = untracked(source);
4802
- for (const op of ops) {
4803
- if (op.path.length === 0) {
4804
- if (op.kind === 'set')
4805
- root = op.next;
4806
- continue; // a root delete is meaningless — ignore
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})`);
4807
5304
  }
4808
- root = applyAt(root, op.path, 0, op);
5305
+ return;
4809
5306
  }
4810
- source.set(root);
4811
- prevRoot = root; // baseline advance: an applied batch never echoes
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 });
4812
5344
  },
4813
5345
  destroy: () => {
4814
- destroyed = true;
5346
+ unsub();
4815
5347
  subscribers.clear();
4816
- 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
+ }
4817
5603
  },
4818
5604
  };
4819
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
+ }
5619
+
5620
+ const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
5621
+ function keyOf(item, key) {
5622
+ if (typeof key === 'function')
5623
+ return key(item);
5624
+ return isRecord(item) ? item[key] : item;
5625
+ }
5626
+ /**
5627
+ * Produces a value equal to `next` but sharing as much of `prev`'s reference structure as possible:
5628
+ * an object subtree that did not change keeps its `prev` reference, and array items are matched by
5629
+ * `key` so a surviving item keeps its identity across a reorder/insert/remove (only added items are
5630
+ * new, only removed items are dropped). This is what lets a derived store recompute without tearing
5631
+ * down every downstream `computed` that reads an unchanged part of it.
5632
+ */
5633
+ function reconcile(prev, next, key = 'id') {
5634
+ return reconcileValue(prev, next, key);
5635
+ }
5636
+ function reconcileValue(prev, next, key) {
5637
+ if (Object.is(prev, next))
5638
+ return prev;
5639
+ if (isPlainArray(prev) && isPlainArray(next)) {
5640
+ const byKey = new Map();
5641
+ for (const item of prev)
5642
+ byKey.set(keyOf(item, key), item);
5643
+ let changed = prev.length !== next.length;
5644
+ const out = next.map((item, i) => {
5645
+ const match = byKey.get(keyOf(item, key));
5646
+ const rv = match !== undefined ? reconcileValue(match, item, key) : item;
5647
+ if (rv !== prev[i])
5648
+ changed = true;
5649
+ return rv;
5650
+ });
5651
+ return changed ? out : prev;
5652
+ }
5653
+ if (isRecord(prev) && isRecord(next)) {
5654
+ const nextKeys = Object.keys(next);
5655
+ let changed = Object.keys(prev).length !== nextKeys.length;
5656
+ const out = {};
5657
+ for (const k of nextKeys) {
5658
+ const rv = Object.hasOwn(prev, k)
5659
+ ? reconcileValue(prev[k], next[k], key)
5660
+ : next[k];
5661
+ out[k] = rv;
5662
+ if (rv !== prev[k])
5663
+ changed = true;
5664
+ }
5665
+ return changed ? out : prev;
5666
+ }
5667
+ return next;
5668
+ }
5669
+ /**
5670
+ * A derived STORE, the store-shaped counterpart to `computed`. `fn` receives a mutable draft seeded
5671
+ * with the current value and either mutates it in place or returns a new value; whichever it does,
5672
+ * the result is reconciled against the previous value (see {@link reconcile}) so unchanged subtrees
5673
+ * keep reference identity and keyed array items keep their proxy identity. Reading through the
5674
+ * returned store is fine-grained: a `computed` over one field only recomputes when that field
5675
+ * actually changes, even though the whole projection re-ran.
5676
+ *
5677
+ * Recompute is pull-based, exactly like `computed`: the projection is memoized and re-runs on the
5678
+ * first read after a signal `fn` depends on changes, so reads are always coherent (no waiting on an
5679
+ * effect flush) and nothing recomputes while nobody reads. `fn` must be pure, it runs inside the
5680
+ * reactive computation. Prefer `computed` for a plain value; reach for `projection` when you want
5681
+ * the per-property tracking of a store on top of a derivation.
5682
+ *
5683
+ * ```ts
5684
+ * const active = projection<User[]>(() => users().filter((u) => u.active), [], { key: 'id' });
5685
+ * // active[0].name(); — surviving users keep identity across recomputes
5686
+ * ```
5687
+ *
5688
+ * Needs an injection context (or an explicit `injector`) for the store layer's cleanup on the main
5689
+ * thread; with an explicit store context (`createStoreContext()`) it is injector-free, so it also
5690
+ * runs on a worker host.
5691
+ *
5692
+ * @param fn receives the current draft; mutate it, or return new data.
5693
+ * @param seed the initial value, held before the first run.
5694
+ */
5695
+ function projection(fn, seed, opt) {
5696
+ const { key = 'id', ...storeOpt } = opt ?? {};
5697
+ const root = linkedSignal({ ...(ngDevMode ? { debugName: "root" } : /* istanbul ignore next */ {}), source: () => undefined,
5698
+ computation: (_, previous) => {
5699
+ const base = previous ? previous.value : seed;
5700
+ const draft = structuredClone(base);
5701
+ const returned = fn(draft);
5702
+ const next = (returned === undefined ? draft : returned);
5703
+ return reconcile(base, next, key);
5704
+ } });
5705
+ return toStore(root, storeOpt).asReadonlyStore();
5706
+ }
4820
5707
 
4821
5708
  /**
4822
5709
  * @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
@@ -5005,6 +5892,85 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
5005
5892
  return writable;
5006
5893
  }
5007
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
+ }
5008
5974
  class MessageBus {
5009
5975
  channel = new BroadcastChannel('mmstack-tab-sync-bus');
5010
5976
  listeners = new Map();
@@ -5116,6 +6082,20 @@ function tabSync(sig, opt) {
5116
6082
  return sig;
5117
6083
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
5118
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
+ }
5119
6099
  const NONE = Symbol();
5120
6100
  let received = NONE;
5121
6101
  const { unsub, post } = bus.subscribe(id, (next) => {
@@ -5346,5 +6326,5 @@ function withHistory(sourceOrValue, opt) {
5346
6326
  * Generated bundle index. Do not edit.
5347
6327
  */
5348
6328
 
5349
- export { MmActivity, MmTransition, MmViewTransitionName, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, createAttributedPending, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, deferredValue, derived, 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, provideForwardingTransitionScope, providePausableOptions, providePaused, provideTransitionScope, 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 };
5350
6330
  //# sourceMappingURL=mmstack-primitives.mjs.map