@mmstack/primitives 20.10.1 → 20.11.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.
@@ -208,8 +208,7 @@ class MmActivity {
208
208
  if (this.onServer)
209
209
  return;
210
210
  for (const node of this.view.rootNodes) {
211
- // covers HTML and SVG roots; text/comment roots can't be styled their CD is still
212
- // detached, but prefer an element root for true visual hiding
211
+ // covers HTML and SVG roots; text/comment roots can't be styled, their CD is still detached
213
212
  if (node instanceof HTMLElement || node instanceof SVGElement)
214
213
  node.style.display = visible ? '' : 'none';
215
214
  }
@@ -227,8 +226,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
227
226
  selector: '[mmActivity]',
228
227
  }]
229
228
  }], ctorParameters: () => [], propDecorators: { visible: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmActivity", required: true }] }] } });
230
- // Shared never-paused signal returned outside a boundary / on the server (SSR renders the full tree,
231
- // nothing is paused). Readonly so a consumer can't cast-and-`.set()` the shared default for everyone.
232
229
  const NEVER_PAUSED = signal(false).asReadonly();
233
230
  /**
234
231
  * Inject the nearest paused-state signal — `true` while the surrounding subtree is paused (hidden by
@@ -493,7 +490,7 @@ function deferredValue(source, opt) {
493
490
  let cancel = null;
494
491
  const watch = effect(() => {
495
492
  const v = source();
496
- cancel?.(); // latest wins: rapid changes coalesce into one catch-up
493
+ cancel?.();
497
494
  cancel = schedule(() => {
498
495
  cancel = null;
499
496
  out.set(v);
@@ -505,8 +502,6 @@ function deferredValue(source, opt) {
505
502
  cancel = null;
506
503
  });
507
504
  const result = computed(() => out());
508
- // "behind" is a value comparison, not a schedule flag: an equal-valued catch-up
509
- // (e.g. type a char, delete it before the deferred view caught up) is not pending
510
505
  result.pending = computed(() => !equal(out(), source()), ...(ngDevMode ? [{ debugName: "pending" }] : []));
511
506
  return result;
512
507
  }
@@ -530,6 +525,46 @@ function resolveScheduler(strategy, injector) {
530
525
  };
531
526
  }
532
527
 
528
+ const CONCURRENCY_INSTRUMENTATION = new InjectionToken('@mmstack/primitives:concurrency-instrumentation');
529
+ function provideConcurrencyInstrumentation(listener) {
530
+ return { provide: CONCURRENCY_INSTRUMENTATION, useValue: listener };
531
+ }
532
+ const now = () => typeof globalThis.performance !== 'undefined'
533
+ ? globalThis.performance.now()
534
+ : Date.now();
535
+ /**
536
+ * Chrome DevTools "Performance" custom-tracks preset (idea/concurrency-devtools.md): writes a
537
+ * `performance.measure` for each pending/transaction window onto an "mmstack" extension track,
538
+ * so reactive coordination shows up on the Performance panel timeline. Dev-only, zero backend,
539
+ * no dependencies. Give each measure the scope name for readability.
540
+ */
541
+ function perfCustomTracks(track = 'mmstack concurrency') {
542
+ const canMeasure = typeof globalThis.performance !== 'undefined' &&
543
+ typeof globalThis.performance.measure === 'function';
544
+ const span = (name, start) => {
545
+ if (!canMeasure)
546
+ return;
547
+ try {
548
+ globalThis.performance.measure(name, {
549
+ start,
550
+ end: now(),
551
+ detail: {
552
+ devtools: { dataType: 'track-entry', track, color: 'primary' },
553
+ },
554
+ });
555
+ }
556
+ catch {
557
+ // measure options with detail are unsupported on this engine — skip silently
558
+ }
559
+ };
560
+ return {
561
+ pendingStart: (e) => e.at,
562
+ pendingEnd: (handle, e) => span(`pending`, handle ?? e.at),
563
+ transactionStart: (e) => e.at,
564
+ transactionEnd: (handle, e) => span(`transaction`, handle ?? e.at),
565
+ };
566
+ }
567
+
533
568
  /**
534
569
  * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
535
570
  * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
@@ -604,8 +639,13 @@ function isMutable(value) {
604
639
  return 'mutate' in value && typeof value.mutate === 'function';
605
640
  }
606
641
 
607
- function createTransitionScope() {
642
+ function createTransitionScope(opt) {
608
643
  const list = mutable([]);
644
+ const inst = opt?.instrumentation;
645
+ const name = opt?.name ?? 'scope';
646
+ const at = () => typeof globalThis.performance !== 'undefined'
647
+ ? globalThis.performance.now()
648
+ : Date.now();
609
649
  const pending = computed(() => list().some(({ ref }) => {
610
650
  const s = ref.status();
611
651
  return s === 'loading' || s === 'reloading';
@@ -616,11 +656,17 @@ function createTransitionScope() {
616
656
  resources: computed(() => list().map((e) => e.ref)),
617
657
  pending,
618
658
  suspended: (type) => list().some(({ ref, suspends }) => suspends && (type === 'loading' ? ref.isLoading() : !ref.hasValue())),
619
- add: (ref, opt) => untracked(() => list.inline((c) => c.push({ ref, suspends: opt?.suspends ?? true }))),
659
+ add: (ref, o) => untracked(() => {
660
+ const suspends = o?.suspends ?? true;
661
+ list.inline((c) => c.push({ ref, suspends }));
662
+ inst?.resourceRegistered?.({ scope: name, suspends });
663
+ }),
620
664
  remove: (ref) => untracked(() => list.inline((c) => {
621
665
  const i = c.findIndex((e) => e.ref === ref);
622
- if (i !== -1)
666
+ if (i !== -1) {
623
667
  c.splice(i, 1);
668
+ inst?.resourceRemoved?.({ scope: name });
669
+ }
624
670
  })),
625
671
  commit: (value) => linkedSignal({
626
672
  source: () => ({ v: value(), settled: !pending() }),
@@ -635,6 +681,8 @@ function createTransitionScope() {
635
681
  aborted++;
636
682
  }
637
683
  }
684
+ if (aborted > 0)
685
+ inst?.abortPending?.({ scope: name, aborted, at: at() });
638
686
  return aborted;
639
687
  }),
640
688
  holding,
@@ -703,13 +751,59 @@ function bridgeScopeToPendingTasks(scope, injector) {
703
751
  });
704
752
  });
705
753
  }
754
+ /**
755
+ * While a listener is installed, bracket each pending window of `scope` with a
756
+ * `pendingStart`/`pendingEnd` span (the reactive tap that needs an injection context). No-op
757
+ * when no listener is provided, so it stays zero-cost by default.
758
+ */
759
+ function bridgeScopeToInstrumentation(scope, name, injector) {
760
+ const run = (fn) => injector ? runInInjectionContext(injector, fn) : fn();
761
+ run(() => {
762
+ const inst = inject(CONCURRENCY_INSTRUMENTATION, { optional: true });
763
+ if (!inst?.pendingStart && !inst?.pendingEnd)
764
+ return;
765
+ const at = () => typeof globalThis.performance !== 'undefined'
766
+ ? globalThis.performance.now()
767
+ : Date.now();
768
+ let handle;
769
+ let open = false;
770
+ effect(() => {
771
+ const pending = scope.pending();
772
+ untracked(() => {
773
+ if (pending && !open) {
774
+ open = true;
775
+ handle = inst.pendingStart?.({
776
+ scope: name,
777
+ resources: scope.resources().length,
778
+ at: at(),
779
+ });
780
+ }
781
+ else if (!pending && open) {
782
+ open = false;
783
+ inst.pendingEnd?.(handle, { at: at() });
784
+ }
785
+ });
786
+ });
787
+ inject(DestroyRef).onDestroy(() => {
788
+ if (open)
789
+ inst.pendingEnd?.(handle, { at: at() });
790
+ });
791
+ });
792
+ }
706
793
  /** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
707
- function provideTransitionScope() {
794
+ function provideTransitionScope(opt) {
708
795
  return {
709
796
  provide: TRANSITION_SCOPE,
710
797
  useFactory: () => {
711
- const scope = createTransitionScope();
798
+ const listener = opt?.instrumentation ??
799
+ inject(CONCURRENCY_INSTRUMENTATION, { optional: true }) ??
800
+ undefined;
801
+ const scope = createTransitionScope({
802
+ name: opt?.name,
803
+ instrumentation: listener,
804
+ });
712
805
  bridgeScopeToPendingTasks(scope);
806
+ bridgeScopeToInstrumentation(scope, opt?.name ?? 'scope');
713
807
  return scope;
714
808
  },
715
809
  };
@@ -857,8 +951,6 @@ function use(res) {
857
951
  frame.seen.add(res);
858
952
  frame.deps.push(res);
859
953
  }
860
- // status() is read tracked even on the short-circuit paths, so the owning computed
861
- // re-evaluates when the load settles / the error clears.
862
954
  if (res.status() === 'error') {
863
955
  frame.errors.push(res.error?.());
864
956
  throw BLOCKED;
@@ -906,9 +998,6 @@ function latest(fn, opt) {
906
998
  }
907
999
  }, opt?.debugName ? { debugName: `${opt.debugName}:evaluation` } : undefined);
908
1000
  const equal = opt?.equal ?? Object.is;
909
- // The stale-while-revalidate atom: holds the last successful result through blocked /
910
- // errored rounds. `equal` gates notification, so an in-flight cycle that lands on an
911
- // equal value never ripples to consumers — while `pending` (independent) still cycles.
912
1001
  const held = linkedSignal(...(ngDevMode ? [{ debugName: "held", source: evaluation,
913
1002
  computation: (ev, prev) => ev.kind === 'value'
914
1003
  ? { has: true, v: ev.value }
@@ -980,8 +1069,7 @@ function injectStartTransition() {
980
1069
  const destroyRef = inject(DestroyRef);
981
1070
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
982
1071
  return (fn) => {
983
- // attributed: loads already in flight when the transition starts are not ours
984
- // they can neither settle this transition early nor block it forever
1072
+ // attributed: loads already in flight when the transition starts are not ours
985
1073
  const pending = createAttributedPending(scope);
986
1074
  untracked(fn);
987
1075
  let sawPending = false;
@@ -1006,8 +1094,7 @@ function injectStartTransition() {
1006
1094
  settle();
1007
1095
  return;
1008
1096
  }
1009
- // no-async fallback: once the reactive system has processed the writes (afterNextRender),
1010
- // if nothing ever went in flight, the transition is already complete.
1097
+ // no-async fallback: once the reactive system has processed the writes,
1011
1098
  afterNextRender(() => {
1012
1099
  if (!sawPending && !untracked(pending))
1013
1100
  settle();
@@ -1112,9 +1199,6 @@ function createTransaction() {
1112
1199
  clear: () => log.clear(),
1113
1200
  };
1114
1201
  }
1115
- // The currently-active transaction, set only for the synchronous duration of a `startTransaction`
1116
- // body (so stateful actions running inside it can record their writes). Module-level + sync
1117
- // set/reset is the honest shape: a transaction is call-scoped, not structural-per-injector.
1118
1202
  let active = null;
1119
1203
  /** The transaction in effect right now, or `null`. Stateful actions consult this to record undo. */
1120
1204
  function activeTransaction() {
@@ -1153,10 +1237,7 @@ function injectStartTransaction() {
1153
1237
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
1154
1238
  return (fn) => {
1155
1239
  const txn = createTransaction();
1156
- // attributed: loads already in flight when the transaction starts are not ours —
1157
- // they can neither commit this transaction early nor block its settle forever
1158
1240
  const pending = createAttributedPending(scope);
1159
- // Hold BEFORE the writes, so the display freezes at pre-transaction values.
1160
1241
  scope.beginHold();
1161
1242
  let finished = false;
1162
1243
  // eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
@@ -1165,9 +1246,6 @@ function injectStartTransaction() {
1165
1246
  const done = new Promise((resolve) => {
1166
1247
  resolveDone = resolve;
1167
1248
  });
1168
- // Every exit path funnels through here, so `done` always settles — including `abort()`
1169
- // and a throwing transaction body (which would otherwise leak the hold forever and
1170
- // freeze the boundary with no recovery).
1171
1249
  const finish = (restore) => {
1172
1250
  if (finished)
1173
1251
  return;
@@ -1181,9 +1259,6 @@ function injectStartTransaction() {
1181
1259
  scope.endHold();
1182
1260
  resolveDone();
1183
1261
  };
1184
- // The scope may outlive the calling context (a component transacting on an ancestor
1185
- // boundary): a destroy mid-flight kills the settle watcher, so without this the hold
1186
- // would leak and freeze the surviving scope forever. Keep the writes — they landed live.
1187
1262
  const releaseDestroy = destroyRef.onDestroy(() => finish(false));
1188
1263
  try {
1189
1264
  runInTransaction(txn, fn);
@@ -1205,7 +1280,7 @@ function injectStartTransaction() {
1205
1280
  finish(false);
1206
1281
  }
1207
1282
  else {
1208
- // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
1283
+ // no-async fallback
1209
1284
  afterNextRender(() => {
1210
1285
  if (!sawPending && !untracked(pending))
1211
1286
  finish(false);
@@ -1283,7 +1358,6 @@ class MmTransition {
1283
1358
  }
1284
1359
  onValue(v) {
1285
1360
  if (!this.current) {
1286
- // first render: nothing to hold yet — show immediately (also what SSR serializes)
1287
1361
  this.current = this.createView(v).view;
1288
1362
  return;
1289
1363
  }
@@ -1297,8 +1371,7 @@ class MmTransition {
1297
1371
  const { view, scope } = this.createView(v);
1298
1372
  this.setHidden(view, true);
1299
1373
  this.holding.set(true);
1300
- // Registration happens synchronously during view creation, so a resource already in
1301
- // flight counts from the start; later kickoffs are caught by the watcher.
1374
+ // Registration happens synchronously during view creation, so a resource already incl. later kickoffs are caught by the watcher.
1302
1375
  let sawPending = untracked(scope.pending);
1303
1376
  const watcher = effect(() => {
1304
1377
  const pending = scope.pending();
@@ -1355,8 +1428,6 @@ class MmTransition {
1355
1428
  this.holding.set(false);
1356
1429
  }
1357
1430
  createView(v) {
1358
- // Each view gets its own scope, so its subtree's resources register here by existing —
1359
- // and the outgoing view's background work can't block the swap (per-view isolation).
1360
1431
  const injector = Injector.create({
1361
1432
  parent: this.parent,
1362
1433
  providers: [provideTransitionScope()],
@@ -1442,7 +1513,7 @@ function getSignalEquality(sig) {
1442
1513
  if (internal && typeof internal.equal === 'function') {
1443
1514
  return internal.equal;
1444
1515
  }
1445
- return Object.is; // Default equality check
1516
+ return Object.is;
1446
1517
  }
1447
1518
 
1448
1519
  /**
@@ -1590,8 +1661,6 @@ function isIndexProp(prop) {
1590
1661
  return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
1591
1662
  }
1592
1663
 
1593
- // Container resolvers used by createVivify: each returns the current value when present and
1594
- // only creates a new container when it is null/undefined.
1595
1664
  function identity(x) {
1596
1665
  return x;
1597
1666
  }
@@ -1749,11 +1818,6 @@ function derived(source, optOrKey, opt) {
1749
1818
  cnt++;
1750
1819
  try {
1751
1820
  sig.update(updater);
1752
- // The wrapped computed evaluates its `equal` lazily — at the next read, which would
1753
- // normally happen after `cnt` has already dropped back to 0. For a reference-stable
1754
- // mutation that read compares the same object to itself and the version never bumps,
1755
- // so dependents are never notified. Reading here, while equality is still suppressed,
1756
- // forces the recompute (and version bump) inside the mutate window.
1757
1821
  untracked(sig);
1758
1822
  }
1759
1823
  finally {
@@ -1841,8 +1905,6 @@ function createSetter(source) {
1841
1905
 
1842
1906
  function keepPrevious(src, opt) {
1843
1907
  const mutableSrc = isWritableSignal(src) && isMutable(src);
1844
- // For a mutable source the linkedSignal's equality must be suppressible: a forwarded
1845
- // `mutate` keeps the same reference, which default equality would otherwise swallow.
1846
1908
  let cnt = 0;
1847
1909
  const baseEqual = opt?.equal;
1848
1910
  const equal = mutableSrc
@@ -1860,16 +1922,11 @@ function keepPrevious(src, opt) {
1860
1922
  if (isWritableSignal(src)) {
1861
1923
  persisted.set = src.set;
1862
1924
  persisted.update = src.update;
1863
- // NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
1864
- // source's readonly view would reintroduce the `undefined` flashes this wrapper exists
1865
- // to prevent.
1866
1925
  if (mutableSrc) {
1867
1926
  persisted.mutate = (updater) => {
1868
1927
  cnt++;
1869
1928
  try {
1870
1929
  src.mutate(updater);
1871
- // force the recompute while equality is suppressed, so the reference-stable
1872
- // mutation bumps the wrapper's version (see derived.ts for the same pattern)
1873
1930
  untracked(persisted);
1874
1931
  }
1875
1932
  finally {
@@ -1912,8 +1969,7 @@ function indexArray(source, map, opt = {}) {
1912
1969
  : toWritable(data, () => {
1913
1970
  // noop
1914
1971
  });
1915
- // copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
1916
- // (possibly shared/reused) options object
1972
+ // copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned (possibly shared/reused) options object
1917
1973
  if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
1918
1974
  opt = {
1919
1975
  ...opt,
@@ -2608,8 +2664,7 @@ function observerSupported$1() {
2608
2664
  */
2609
2665
  function elementSize(target, opt) {
2610
2666
  return runInSensorContext(opt?.injector, () =>
2611
- // the host-element default must resolve INSIDE the sensor context, not as a
2612
- // parameter default (which would run before the injector wrapper)
2667
+ // the host-element default must resolve INSIDE the sensor context
2613
2668
  createElementSize(target ?? inject(ElementRef), opt));
2614
2669
  }
2615
2670
  function createElementSize(target, opt) {
@@ -2746,10 +2801,7 @@ function observerSupported() {
2746
2801
  * ```
2747
2802
  */
2748
2803
  function elementVisibility(target, opt) {
2749
- return runInSensorContext(opt?.injector, () =>
2750
- // the host-element default must resolve INSIDE the sensor context, not as a
2751
- // parameter default (which would run before the injector wrapper)
2752
- createElementVisibility(target ?? inject(ElementRef), opt));
2804
+ return runInSensorContext(opt?.injector, () => createElementVisibility(target ?? inject(ElementRef), opt));
2753
2805
  }
2754
2806
  function createElementVisibility(target, opt) {
2755
2807
  if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
@@ -2820,10 +2872,7 @@ function unwrap$1(target) {
2820
2872
  * ```
2821
2873
  */
2822
2874
  function focusWithin(target, opt) {
2823
- return runInSensorContext(opt?.injector, () =>
2824
- // the host-element default must resolve INSIDE the sensor context, not as a
2825
- // parameter default (which would run before the injector wrapper)
2826
- createFocusWithin(target ?? inject(ElementRef), opt));
2875
+ return runInSensorContext(opt?.injector, () => createFocusWithin(target ?? inject(ElementRef), opt));
2827
2876
  }
2828
2877
  function createFocusWithin(target, opt) {
2829
2878
  const debugName = opt?.debugName ?? 'focusWithin';
@@ -3300,8 +3349,6 @@ function createMousePosition(opt) {
3300
3349
  }
3301
3350
  pos.set({ x, y });
3302
3351
  };
3303
- // passive: the handler never calls preventDefault, and a non-passive touchmove on
3304
- // window forces the browser to wait on JS before scrolling (scroll jank on touch)
3305
3352
  const attach = (el) => {
3306
3353
  const controller = new AbortController();
3307
3354
  el.addEventListener('mousemove', updatePosition, {
@@ -3317,7 +3364,7 @@ function createMousePosition(opt) {
3317
3364
  return () => controller.abort();
3318
3365
  };
3319
3366
  if (isSignal(target)) {
3320
- // re-attach whenever the signal resolves to a (new) element — covers viewChild
3367
+ // covers viewChild case
3321
3368
  effect((cleanup) => {
3322
3369
  const el = resolve(target());
3323
3370
  if (!el)
@@ -3770,7 +3817,6 @@ function createScrollPosition(opt) {
3770
3817
  ms: throttle,
3771
3818
  });
3772
3819
  if (isSignal(target)) {
3773
- // re-attach whenever the signal resolves to a (new) element — covers viewChild
3774
3820
  effect((cleanup) => {
3775
3821
  const el = resolve(target());
3776
3822
  if (!el)
@@ -3964,7 +4010,6 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
3964
4010
  state.set(event);
3965
4011
  };
3966
4012
  const { destroyRef: providedDestroyRef,
3967
- // strip non-listener keys so they don't leak into addEventListener options
3968
4013
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
3969
4014
  injector: _injector,
3970
4015
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -3978,8 +4023,7 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
3978
4023
  resolved.addEventListener(eventName, handler, listenerOpts);
3979
4024
  cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
3980
4025
  }, ...(ngDevMode ? [{ debugName: "effectRef", injector }] : [{ injector }]));
3981
- // honor an explicit destroyRef for signal targets too — the effect would otherwise
3982
- // only follow the injector's lifetime, contradicting the documented option
4026
+ // honor an explicit destroyRef for signal targets
3983
4027
  providedDestroyRef?.onDestroy(() => effectRef.destroy());
3984
4028
  }
3985
4029
  else {
@@ -4148,6 +4192,228 @@ function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
4148
4192
  : (newValue) => target.update(write(newValue));
4149
4193
  }
4150
4194
 
4195
+ function generateOrigin$1() {
4196
+ if (globalThis.crypto?.randomUUID)
4197
+ return globalThis.crypto.randomUUID();
4198
+ return Math.random().toString(36).substring(2);
4199
+ }
4200
+ const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4201
+ /**
4202
+ * Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
4203
+ * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4204
+ * descends only where refs differ. O(changed paths), not O(tree).
4205
+ */
4206
+ function diffNode(prev, next, path, ops) {
4207
+ if (Object.is(prev, next))
4208
+ return;
4209
+ if (isRecord(prev) && isRecord(next)) {
4210
+ for (const key of Object.keys(prev)) {
4211
+ if (!Object.hasOwn(next, key))
4212
+ ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
4213
+ }
4214
+ for (const key of Object.keys(next)) {
4215
+ if (!Object.hasOwn(prev, key)) {
4216
+ // added key: deliberately NO `prev` property (absent ≠ undefined)
4217
+ ops.push({ kind: 'set', path: [...path, key], next: next[key] });
4218
+ }
4219
+ else {
4220
+ diffNode(prev[key], next[key], [...path, key], ops);
4221
+ }
4222
+ }
4223
+ return;
4224
+ }
4225
+ if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4226
+ // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4227
+ // change is a whole unit — index attribution lies under insert/remove/reorder
4228
+ if (prev.length === next.length) {
4229
+ for (let i = 0; i < next.length; i++)
4230
+ diffNode(prev[i], next[i], [...path, i], ops);
4231
+ return;
4232
+ }
4233
+ ops.push({ kind: 'set', path, prev, next });
4234
+ return;
4235
+ }
4236
+ // leaf / type change / opaque — one unit, prev present (the slot existed)
4237
+ ops.push({ kind: 'set', path, prev, next });
4238
+ }
4239
+ /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4240
+ function applyAt(container, path, idx, op) {
4241
+ const seg = path[idx];
4242
+ const base = isPlainArray$1(container)
4243
+ ? container.slice()
4244
+ : isRecord(container)
4245
+ ? { ...container }
4246
+ : typeof seg === 'number'
4247
+ ? []
4248
+ : {};
4249
+ if (idx === path.length - 1) {
4250
+ if (op.kind === 'delete') {
4251
+ // arrays never receive deletes (length changes travel as whole-array sets)
4252
+ delete base[seg];
4253
+ }
4254
+ else {
4255
+ base[seg] = op.next;
4256
+ }
4257
+ return base;
4258
+ }
4259
+ base[seg] = applyAt(base[seg], path, idx + 1, op);
4260
+ return base;
4261
+ }
4262
+ /**
4263
+ * Pure, store-free application of ops onto a plain root value, returning the next immutable root
4264
+ * (structural-sharing along op paths, missing containers vivified `'auto'`-style). This is the
4265
+ * same transform {@link OpLog.apply} runs, extracted so a replica can fold a received batch into
4266
+ * a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
4267
+ * Accepts a batch or a bare op list.
4268
+ */
4269
+ function applyOps(root, ops) {
4270
+ const list = Array.isArray(ops) ? ops : ops.ops;
4271
+ let next = root;
4272
+ for (const op of list) {
4273
+ if (op.path.length === 0) {
4274
+ if (op.kind === 'set')
4275
+ next = op.next;
4276
+ continue; // a root delete is meaningless — ignore (mirrors OpLog.apply)
4277
+ }
4278
+ next = applyAt(next, op.path, 0, op);
4279
+ }
4280
+ return next;
4281
+ }
4282
+ /**
4283
+ * Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
4284
+ * {@link opLog}, exported so code outside a log can produce a batch — e.g. diffing a scratch
4285
+ * draft against a replica's current value to route a write to its owner). Trusts the
4286
+ * copy-on-write contract: an untouched subtree that kept its reference is skipped.
4287
+ */
4288
+ function diffOps(prev, next) {
4289
+ const ops = [];
4290
+ diffNode(prev, next, [], ops);
4291
+ return ops;
4292
+ }
4293
+ /**
4294
+ * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
4295
+ * `prev` — inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4296
+ * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4297
+ * carry — a wire-serialized batch that stripped them is not invertible.
4298
+ */
4299
+ function invertBatch(batch) {
4300
+ const ops = Array.isArray(batch) ? batch : batch.ops;
4301
+ const inverted = [];
4302
+ for (let i = ops.length - 1; i >= 0; i--) {
4303
+ const op = ops[i];
4304
+ if (op.kind === 'delete') {
4305
+ inverted.push({
4306
+ kind: 'set',
4307
+ path: op.path,
4308
+ next: op.prev,
4309
+ prev: undefined,
4310
+ });
4311
+ continue;
4312
+ }
4313
+ if (!Object.hasOwn(op, 'prev')) {
4314
+ inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4315
+ }
4316
+ else {
4317
+ inverted.push({
4318
+ kind: 'set',
4319
+ path: op.path,
4320
+ next: op.prev,
4321
+ prev: op.next,
4322
+ });
4323
+ }
4324
+ }
4325
+ return inverted;
4326
+ }
4327
+ /**
4328
+ * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4329
+ * immutably-updated objects) and emits its changes as minimal structural op batches — the
4330
+ * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4331
+ * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4332
+ *
4333
+ * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4334
+ * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4335
+ * effect. A batch therefore coalesces everything written in one tick — for coarser,
4336
+ * intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
4337
+ *
4338
+ * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4339
+ * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4340
+ * warning fires and nothing emits.
4341
+ *
4342
+ * ```ts
4343
+ * const s = store({ todos: [{ done: false }] });
4344
+ * const log = opLog(s, { origin: 'tab-a' });
4345
+ * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4346
+ * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4347
+ * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4348
+ * ```
4349
+ */
4350
+ function opLog(source, opt) {
4351
+ const origin = opt?.origin ?? generateOrigin$1();
4352
+ const storeKind = source[STORE_KIND];
4353
+ const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4354
+ if (isDevMode() && mutableSource) {
4355
+ 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.');
4356
+ }
4357
+ let prevRoot = untracked(source);
4358
+ let version = 0;
4359
+ let destroyed = false;
4360
+ const subscribers = new Set();
4361
+ const latest = signal(null, ...(ngDevMode ? [{ debugName: "latest" }] : []));
4362
+ /** Diff now, emit if there's a delta, advance the baseline. */
4363
+ const flush = () => {
4364
+ if (destroyed)
4365
+ return;
4366
+ const next = untracked(source);
4367
+ if (Object.is(prevRoot, next))
4368
+ return;
4369
+ const ops = [];
4370
+ diffNode(prevRoot, next, [], ops);
4371
+ prevRoot = next;
4372
+ if (!ops.length)
4373
+ return; // fresh refs, equal values — spurious-write tolerance
4374
+ const batch = { origin, version: ++version, ops };
4375
+ latest.set(batch);
4376
+ for (const cb of [...subscribers])
4377
+ cb(batch);
4378
+ };
4379
+ const run = () => {
4380
+ source(); // track every commit…
4381
+ untracked(flush); // …and emit the delta since the last flush
4382
+ };
4383
+ // default driver is an Angular effect (needs an injector); a supplied driver runs injector-free
4384
+ // (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
4385
+ const ref = opt?.driver
4386
+ ? opt.driver(run)
4387
+ : effect(run, { injector: opt?.injector ?? inject(Injector) });
4388
+ return {
4389
+ latest: latest.asReadonly(),
4390
+ subscribe: (cb) => {
4391
+ subscribers.add(cb);
4392
+ return () => subscribers.delete(cb);
4393
+ },
4394
+ // the emission core, callable on demand — reads the source untracked, so it never disturbs the
4395
+ // driver's subscription; a subsequent scheduled run just finds the baseline already advanced
4396
+ flush: () => flush(),
4397
+ apply: (batchOrOps) => {
4398
+ const ops = Array.isArray(batchOrOps)
4399
+ ? batchOrOps
4400
+ : batchOrOps.ops;
4401
+ if (!ops.length)
4402
+ return;
4403
+ // pending local writes must emit BEFORE the baseline advances past them
4404
+ flush();
4405
+ const root = applyOps(untracked(source), ops); // one atomic root, structural-shared
4406
+ source.set(root);
4407
+ prevRoot = root; // baseline advance: an applied batch never echoes
4408
+ },
4409
+ destroy: () => {
4410
+ destroyed = true;
4411
+ subscribers.clear();
4412
+ ref.destroy();
4413
+ },
4414
+ };
4415
+ }
4416
+
4151
4417
  /**
4152
4418
  * @internal Runtime brand carrying a store node's lazily-built leaf probe. Exported (like
4153
4419
  * {@link OPAQUE}) only so the `{ readonly [LEAF]: () => boolean }` brand on the store types is
@@ -4318,8 +4584,7 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4318
4584
  vivify,
4319
4585
  noUnionLeaves,
4320
4586
  [STORE_SHARED_GLOBALS]: {
4321
- // the `injector!` reads run only when a global is absent, which (per hasSharedGlobals) means
4322
- // an injector was resolved above
4587
+ // the `injector!` reads run only when a global is absent
4323
4588
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4324
4589
  cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
4325
4590
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -4399,8 +4664,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4399
4664
  injector,
4400
4665
  vivify,
4401
4666
  noUnionLeaves,
4402
- // forward the resolved globals — re-resolving from the injector both re-injects
4403
- // needlessly and breaks in DI-less (worker) mode where injector is undefined
4404
4667
  [STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
4405
4668
  }));
4406
4669
  };
@@ -4416,8 +4679,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4416
4679
  return arrayLength();
4417
4680
  if (prop === Symbol.iterator)
4418
4681
  return function* () {
4419
- // read length reactively: a spread/for-of inside a computed/effect must re-run
4420
- // when items are added or removed, not only when already-read elements change
4421
4682
  const len = arrayLength();
4422
4683
  for (let i = 0; i < len(); i++)
4423
4684
  yield receiver[i];
@@ -4664,9 +4925,6 @@ function forkStore(base, opt) {
4664
4925
  source: () => base(),
4665
4926
  computation: (theirs, prev) => prev === undefined ? theirs : merge(prev.source, prev.value, theirs),
4666
4927
  }]));
4667
- // Inherit the base's shared options (injector, vivify, noUnionLeaves + the
4668
- // proxy cache/registry), same as extendStore — a fork should vivify like its
4669
- // base and share its injector-scoped cache. `opt` overrides (advanced use).
4670
4928
  const store = toStore(staged, {
4671
4929
  ...base[STORE_SHARED_OPTIONS],
4672
4930
  ...opt,
@@ -4675,233 +4933,678 @@ function forkStore(base, opt) {
4675
4933
  store,
4676
4934
  commit: () => base.set(untracked(staged)),
4677
4935
  discard: () => staged.set(untracked(base)),
4936
+ ops: () => diffOps(untracked(base), untracked(staged)),
4678
4937
  };
4679
4938
  }
4680
4939
 
4681
- function generateOrigin() {
4682
- if (globalThis.crypto?.randomUUID)
4683
- return globalThis.crypto.randomUUID();
4684
- return Math.random().toString(36).substring(2);
4940
+ /** Total order over stamps alone; ties break on `writer` via {@link compareTotal}. */
4941
+ function compareHlc(a, b) {
4942
+ return a.p !== b.p ? a.p - b.p : a.l - b.l;
4943
+ }
4944
+ /** The protocol's total order: (hlc.p, hlc.l, writer). Never returns 0 for distinct writers. */
4945
+ function compareTotal(a, writerA, b, writerB) {
4946
+ const byClock = compareHlc(a, b);
4947
+ if (byClock !== 0)
4948
+ return byClock;
4949
+ return writerA < writerB ? -1 : writerA > writerB ? 1 : 0;
4950
+ }
4951
+ const SKEW_WARN_MS = 5 * 60_000;
4952
+ /**
4953
+ * HLC per Kulkarni et al.: convergence never depends on wall clocks, but LWW fairness
4954
+ * degrades under large skew, so observing a remote clock far ahead warns in dev mode.
4955
+ */
4956
+ function createHlcClock(now = Date.now) {
4957
+ let p = 0;
4958
+ let l = 0;
4959
+ const advance = (wall, observed) => {
4960
+ const nextP = Math.max(p, wall, observed?.p ?? 0);
4961
+ if (nextP === p) {
4962
+ l = Math.max(l, observed && observed.p === nextP ? observed.l : 0) + 1;
4963
+ }
4964
+ else {
4965
+ p = nextP;
4966
+ l = observed && observed.p === nextP ? observed.l + 1 : 0;
4967
+ }
4968
+ };
4969
+ return {
4970
+ next: () => {
4971
+ advance(now());
4972
+ return { p, l };
4973
+ },
4974
+ observe: (remote) => {
4975
+ const wall = now();
4976
+ if (isDevMode() && remote.p - wall > SKEW_WARN_MS) {
4977
+ 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`);
4978
+ }
4979
+ advance(wall, remote);
4980
+ },
4981
+ };
4685
4982
  }
4686
- const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4983
+
4984
+ const OP_PROTO_VERSION = 1;
4985
+ const CONFLICT_BRAND = '~mmstackConflict';
4986
+ function isConflicted(value) {
4987
+ return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
4988
+ }
4989
+ const lww = (_ancestor, mine) => mine;
4990
+ const mergeThree = (ancestor, mine, theirs) => merge3(ancestor, mine, theirs);
4991
+ const preserve = (ancestor, mine, theirs) => ({ [CONFLICT_BRAND]: true, mine, theirs, ancestor });
4687
4992
  /**
4688
- * Reference-identity-pruned structural diff the same short-circuit discipline as `merge3`:
4689
- * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4690
- * descends only where refs differ. O(changed paths), not O(tree).
4993
+ * Identity-aware array merge (op-protocol RFC §12 v0): reconciles two concurrent versions of
4994
+ * an array item-wise by a user-provided identity, instead of last-writer-wins on the whole
4995
+ * array. Items are matched by key; per-item fields merge via `merge3` against the ancestor
4996
+ * item; items added on either side survive; an item removed on either side and unedited on
4997
+ * the other stays removed. Item ORDER follows `mine` (the total-order winner), with `theirs`-
4998
+ * only additions appended — positional merging is out of scope (fractional indexing is the
4999
+ * known upgrade if dogfooding demands it). Arrays still TRAVEL as whole-value sets; identity
5000
+ * only shapes conflict resolution, so the wire format is untouched.
4691
5001
  */
4692
- function diffNode(prev, next, path, ops) {
4693
- if (Object.is(prev, next))
4694
- return;
4695
- if (isRecord(prev) && isRecord(next)) {
4696
- for (const key of Object.keys(prev)) {
4697
- if (!Object.hasOwn(next, key))
4698
- ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
5002
+ function keyedArray(identity, opt) {
5003
+ const mergeItem = opt?.item ?? mergeThree;
5004
+ return (ancestor, mine, theirs, ctx) => {
5005
+ if (!Array.isArray(mine) || !Array.isArray(theirs)) {
5006
+ return mine; // type conflict → total-order winner, like lww
4699
5007
  }
4700
- for (const key of Object.keys(next)) {
4701
- if (!Object.hasOwn(prev, key)) {
4702
- // added key: deliberately NO `prev` property (absent ≠ undefined)
4703
- ops.push({ kind: 'set', path: [...path, key], next: next[key] });
5008
+ const anc = Array.isArray(ancestor) ? ancestor : [];
5009
+ const byKey = (arr) => {
5010
+ const map = new Map();
5011
+ for (const item of arr)
5012
+ map.set(identity(item), item);
5013
+ return map;
5014
+ };
5015
+ const ancMap = byKey(anc);
5016
+ const mineMap = byKey(mine);
5017
+ const theirsMap = byKey(theirs);
5018
+ const out = [];
5019
+ for (const item of mine) {
5020
+ const key = identity(item);
5021
+ const other = theirsMap.get(key);
5022
+ const base = ancMap.get(key);
5023
+ if (theirsMap.has(key)) {
5024
+ out.push(structuralEq(item, other)
5025
+ ? item
5026
+ : mergeItem(base, item, other, ctx));
4704
5027
  }
4705
- else {
4706
- diffNode(prev[key], next[key], [...path, key], ops);
5028
+ else if (!ancMap.has(key) || !structuralEq(item, base)) {
5029
+ out.push(item); // added by mine, or edited by mine while theirs removed it → keep
4707
5030
  }
5031
+ // else: theirs removed it and mine left it untouched → stays removed
4708
5032
  }
4709
- return;
4710
- }
4711
- if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4712
- // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4713
- // change is a whole unit — index attribution lies under insert/remove/reorder
4714
- if (prev.length === next.length) {
4715
- for (let i = 0; i < next.length; i++)
4716
- diffNode(prev[i], next[i], [...path, i], ops);
4717
- return;
5033
+ for (const item of theirs) {
5034
+ const key = identity(item);
5035
+ if (mineMap.has(key))
5036
+ continue;
5037
+ if (!ancMap.has(key) || !structuralEq(item, ancMap.get(key))) {
5038
+ out.push(item); // added by theirs, or edited by theirs while mine removed it → keep
5039
+ }
4718
5040
  }
4719
- ops.push({ kind: 'set', path, prev, next });
4720
- return;
4721
- }
4722
- // leaf / type change / opaque — one unit, prev present (the slot existed)
4723
- ops.push({ kind: 'set', path, prev, next });
5041
+ return out;
5042
+ };
4724
5043
  }
4725
- /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4726
- function applyAt(container, path, idx, op) {
4727
- const seg = path[idx];
4728
- const base = isPlainArray$1(container)
4729
- ? container.slice()
4730
- : isRecord(container)
4731
- ? { ...container }
4732
- : typeof seg === 'number'
4733
- ? []
4734
- : {};
4735
- if (idx === path.length - 1) {
4736
- if (op.kind === 'delete') {
4737
- // arrays never receive deletes (length changes travel as whole-array sets)
4738
- delete base[seg];
5044
+ function compilePolicies(entries) {
5045
+ return entries.map((e) => ({
5046
+ segments: typeof e.path === 'string' ? e.path.split('.') : e.path.map(String),
5047
+ merge: e.merge,
5048
+ }));
5049
+ }
5050
+ function policyFor(policies, path) {
5051
+ outer: for (const p of policies) {
5052
+ if (p.segments.length !== path.length)
5053
+ continue;
5054
+ for (let i = 0; i < path.length; i++) {
5055
+ if (p.segments[i] !== '*' && p.segments[i] !== String(path[i]))
5056
+ continue outer;
4739
5057
  }
4740
- else {
4741
- base[seg] = op.next;
5058
+ return p.merge;
5059
+ }
5060
+ return lww;
5061
+ }
5062
+ const SEP = '';
5063
+ const keyOf$1 = (path) => path.map(String).join(SEP);
5064
+ function structuralEq(a, b) {
5065
+ if (Object.is(a, b))
5066
+ return true;
5067
+ if (typeof a !== 'object' ||
5068
+ typeof b !== 'object' ||
5069
+ a === null ||
5070
+ b === null ||
5071
+ Array.isArray(a) !== Array.isArray(b)) {
5072
+ return false;
5073
+ }
5074
+ const ka = Object.keys(a);
5075
+ const kb = Object.keys(b);
5076
+ if (ka.length !== kb.length)
5077
+ return false;
5078
+ for (const k of ka) {
5079
+ if (!Object.hasOwn(b, k))
5080
+ return false;
5081
+ if (!structuralEq(a[k], b[k])) {
5082
+ return false;
4742
5083
  }
4743
- return base;
4744
5084
  }
4745
- base[seg] = applyAt(base[seg], path, idx + 1, op);
4746
- return base;
5085
+ return true;
4747
5086
  }
5087
+ // total order (hlc, writer, origin): two origins can share a writer AND a stamp
5088
+ // (independent clocks, same ms), so only origin makes the order strict
5089
+ const compareStamp = (a, b) => {
5090
+ const byTotal = compareTotal(a.hlc, a.writer, b.hlc, b.writer);
5091
+ if (byTotal !== 0)
5092
+ return byTotal;
5093
+ return a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0;
5094
+ };
5095
+ const beats = (a, b) => compareStamp(a, b) > 0;
4748
5096
  /**
4749
- * Pure, store-free application of ops onto a plain root value, returning the next immutable root
4750
- * (structural-sharing along op paths, missing containers vivified `'auto'`-style). This is the
4751
- * same transform {@link OpLog.apply} runs, extracted so a replica can fold a received batch into
4752
- * a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
4753
- * Accepts a batch or a bare op list.
5097
+ * The unsequenced-topology convergence core (op-protocol RFC §4): a per-path last-writer-wins
5098
+ * register map over the total order (hlc, writer), with subtree dominance. Order-independent:
5099
+ * any arrival order of the same envelope set yields the same state.
4754
5100
  */
4755
- function applyOps(root, ops) {
4756
- const list = Array.isArray(ops) ? ops : ops.ops;
4757
- let next = root;
4758
- for (const op of list) {
4759
- if (op.path.length === 0) {
4760
- if (op.kind === 'set')
4761
- next = op.next;
4762
- continue; // a root delete is meaningless — ignore (mirrors OpLog.apply)
5101
+ function createConvergingApply(opt) {
5102
+ const registers = new Map();
5103
+ const policies = compilePolicies(opt?.policies ?? []);
5104
+ const resolveConcurrent = (winner, loser, path) => {
5105
+ const merge = policyFor(policies, path);
5106
+ if (merge === lww || winner.kind === 'delete' || loser.kind === 'delete') {
5107
+ return winner;
4763
5108
  }
4764
- next = applyAt(next, op.path, 0, op);
5109
+ const resolved = merge(loser.prev, winner.next, loser.next, { path });
5110
+ if (Object.is(resolved, winner.next))
5111
+ return winner;
5112
+ return { kind: 'set', path, next: resolved, prev: winner.next };
5113
+ };
5114
+ // a sequential edit carries the value it overwrote; a mismatch means neither saw the other.
5115
+ // Structural, not referential: identity never survives the wire, so a peer that built on
5116
+ // the replicated copy of a value must still count as sequential.
5117
+ const concurrentWith = (incoming, registered) => {
5118
+ if (incoming.kind === 'delete' || registered.kind === 'delete')
5119
+ return false;
5120
+ if (!Object.hasOwn(incoming, 'prev'))
5121
+ return true;
5122
+ return !structuralEq(incoming.prev, registered.next);
5123
+ };
5124
+ return {
5125
+ ingest: (env, o) => {
5126
+ const stamp = { hlc: env.hlc, writer: env.writer, origin: env.origin };
5127
+ const out = [];
5128
+ for (const op of env.ops) {
5129
+ const key = keyOf$1(op.path);
5130
+ let dominated = false;
5131
+ let exact;
5132
+ for (let len = 0; len <= op.path.length; len++) {
5133
+ const reg = registers.get(keyOf$1(op.path.slice(0, len)));
5134
+ if (!reg)
5135
+ continue;
5136
+ if (len === op.path.length)
5137
+ exact = reg;
5138
+ else if (beats(reg, stamp)) {
5139
+ dominated = true;
5140
+ break;
5141
+ }
5142
+ }
5143
+ if (dominated)
5144
+ continue;
5145
+ if (exact && beats(exact, stamp)) {
5146
+ if (concurrentWith(op, exact.op)) {
5147
+ const resolved = resolveConcurrent(exact.op, op, op.path);
5148
+ if (resolved !== exact.op) {
5149
+ exact.op = resolved;
5150
+ if (!o?.local)
5151
+ out.push(resolved);
5152
+ }
5153
+ }
5154
+ continue;
5155
+ }
5156
+ let accepted = op;
5157
+ if (exact && concurrentWith(op, exact.op)) {
5158
+ accepted = resolveConcurrent(op, exact.op, op.path);
5159
+ }
5160
+ const isDescendant = key === ''
5161
+ ? (k) => k !== ''
5162
+ : (k) => k.startsWith(key + SEP);
5163
+ const replays = [];
5164
+ for (const [k, reg] of registers) {
5165
+ if (!isDescendant(k))
5166
+ continue;
5167
+ if (beats(stamp, reg))
5168
+ registers.delete(k);
5169
+ else
5170
+ replays.push(reg);
5171
+ }
5172
+ replays.sort(compareStamp);
5173
+ registers.set(key, { hlc: env.hlc, writer: env.writer, origin: env.origin, op: accepted });
5174
+ if (!o?.local) {
5175
+ out.push(accepted);
5176
+ for (const r of replays)
5177
+ out.push(r.op);
5178
+ }
5179
+ }
5180
+ return out;
5181
+ },
5182
+ reset: () => registers.clear(),
5183
+ };
5184
+ }
5185
+ function getAtPath(root, path) {
5186
+ let cur = root;
5187
+ for (const seg of path) {
5188
+ if (cur === null || typeof cur !== 'object')
5189
+ return undefined;
5190
+ cur = cur[seg];
4765
5191
  }
4766
- return next;
5192
+ return cur;
4767
5193
  }
4768
5194
  /**
4769
- * Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
4770
- * {@link opLog}, exported so code outside a log can produce a batch — e.g. diffing a scratch
4771
- * draft against a replica's current value to route a write to its owner). Trusts the
4772
- * copy-on-write contract: an untouched subtree that kept its reference is skipped.
5195
+ * The shared rebase routine (op-protocol RFC §5): invert pending, apply remote, re-apply
5196
+ * pending through the merge policies. Pure branching's `rebase()` and the sequenced relay
5197
+ * client both call this.
4773
5198
  */
4774
- function diffOps(prev, next) {
4775
- const ops = [];
4776
- diffNode(prev, next, [], ops);
4777
- return ops;
5199
+ function rebaseOps(root, pending, remote, policies) {
5200
+ const compiled = compilePolicies(policies ?? []);
5201
+ let base = root;
5202
+ for (let i = pending.length - 1; i >= 0; i--) {
5203
+ base = applyOps(base, invertBatch(pending[i]));
5204
+ }
5205
+ base = applyOps(base, remote);
5206
+ const rebased = [];
5207
+ for (const batch of pending) {
5208
+ const next = [];
5209
+ for (const op of batch) {
5210
+ const cur = getAtPath(base, op.path);
5211
+ if (op.kind === 'delete') {
5212
+ next.push({ kind: 'delete', path: op.path, prev: cur });
5213
+ }
5214
+ else if (cur === undefined) {
5215
+ next.push({ kind: 'set', path: op.path, next: op.next });
5216
+ }
5217
+ else if (Object.hasOwn(op, 'prev') && !structuralEq(op.prev, cur)) {
5218
+ const merge = policyFor(compiled, op.path);
5219
+ const resolved = merge(op.prev, op.next, cur, { path: op.path });
5220
+ next.push({ kind: 'set', path: op.path, next: resolved, prev: cur });
5221
+ }
5222
+ else {
5223
+ next.push({ kind: 'set', path: op.path, next: op.next, prev: cur });
5224
+ }
5225
+ }
5226
+ base = applyOps(base, next);
5227
+ rebased.push(next);
5228
+ }
5229
+ return { root: base, pending: rebased };
4778
5230
  }
4779
5231
  /**
4780
- * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add a `set` with no
4781
- * `prev` inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4782
- * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4783
- * carry a wire-serialized batch that stripped them is not invertible.
5232
+ * A per-path-policy `ForkStrategy` for `forkStore`: a three-way reconcile built from the
5233
+ * shared rebase (invert mine apply theirs' delta re-apply mine through the policies).
5234
+ * Paths only one side touched resolve like `merge3`; paths BOTH touched go through the
5235
+ * matching {@link MergePolicyEntry} (`lww` default fork wins, matching `'fine'`; or
5236
+ * `mergeThree` / `preserve` / custom). Same copy-on-write contract as `'fine'`.
4784
5237
  */
4785
- function invertBatch(batch) {
4786
- const ops = Array.isArray(batch) ? batch : batch.ops;
4787
- const inverted = [];
4788
- for (let i = ops.length - 1; i >= 0; i--) {
4789
- const op = ops[i];
4790
- if (op.kind === 'delete') {
4791
- inverted.push({
4792
- kind: 'set',
4793
- path: op.path,
4794
- next: op.prev,
4795
- prev: undefined,
4796
- });
4797
- continue;
4798
- }
4799
- if (!Object.hasOwn(op, 'prev')) {
4800
- inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4801
- }
4802
- else {
4803
- inverted.push({
4804
- kind: 'set',
4805
- path: op.path,
4806
- next: op.prev,
4807
- prev: op.next,
4808
- });
4809
- }
4810
- }
4811
- return inverted;
5238
+ function policyStrategy(policies) {
5239
+ return (ancestor, mine, theirs) => rebaseOps(mine, [diffOps(ancestor, mine)], diffOps(ancestor, theirs), policies).root;
5240
+ }
5241
+ function generateOrigin() {
5242
+ if (globalThis.crypto?.randomUUID)
5243
+ return globalThis.crypto.randomUUID();
5244
+ return Math.random().toString(36).substring(2);
4812
5245
  }
4813
5246
  /**
4814
- * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4815
- * immutably-updated objects) and emits its changes as minimal structural op batches — the
4816
- * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4817
- * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4818
- *
4819
- * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4820
- * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4821
- * effect. A batch therefore coalesces everything written in one tick — for coarser,
4822
- * intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
4823
- *
4824
- * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4825
- * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4826
- * warning fires and nothing emits.
4827
- *
4828
- * ```ts
4829
- * const s = store({ todos: [{ done: false }] });
4830
- * const log = opLog(s, { origin: 'tab-a' });
4831
- * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4832
- * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4833
- * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4834
- * ```
5247
+ * Wires a copy-on-write signal (a `store` root) to the op protocol: local writes emit
5248
+ * stamped envelopes, received envelopes fold in through the converging apply. The
5249
+ * unsequenced-topology client core that `tabSync(store)` and P2P transports build on.
4835
5250
  */
4836
- function opLog(source, opt) {
4837
- const origin = opt?.origin ?? generateOrigin();
4838
- // a store proxy's `has` trap answers for the VALUE's keys, so `isMutable`'s `'mutate' in`
4839
- // probe can't see the brand — ask the store's own kind symbol first
4840
- const storeKind = source[STORE_KIND];
4841
- const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4842
- if (isDevMode() && mutableSource) {
4843
- 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.');
4844
- }
4845
- let prevRoot = untracked(source);
4846
- let version = 0;
4847
- let destroyed = false;
5251
+ const RECENT_LOCAL_CAP = 64;
5252
+ function opSync(source, opt) {
5253
+ const origin = opt.origin ?? generateOrigin();
5254
+ const clock = opt.clock ?? createHlcClock();
5255
+ const conv = createConvergingApply({ policies: opt.policies });
4848
5256
  const subscribers = new Set();
4849
- const latest = signal(null, ...(ngDevMode ? [{ debugName: "latest" }] : []));
4850
- /** Diff now, emit if there's a delta, advance the baseline. */
4851
- const flush = () => {
4852
- if (destroyed)
4853
- return;
4854
- const next = untracked(source);
4855
- if (Object.is(prevRoot, next))
4856
- return;
4857
- const ops = [];
4858
- diffNode(prevRoot, next, [], ops);
4859
- prevRoot = next;
4860
- if (!ops.length)
4861
- return; // fresh refs, equal values — spurious-write tolerance
4862
- const batch = { origin, version: ++version, ops };
4863
- latest.set(batch);
5257
+ const versions = new Map();
5258
+ const recentLocal = [];
5259
+ let version = 0;
5260
+ const log = opLog(source, opt.driver
5261
+ ? { origin, driver: opt.driver }
5262
+ : { origin, injector: opt.injector ?? inject(Injector) });
5263
+ const emitLocal = (ops) => {
5264
+ const env = {
5265
+ proto: OP_PROTO_VERSION,
5266
+ origin,
5267
+ writer: opt.writer,
5268
+ version: ++version,
5269
+ hlc: clock.next(),
5270
+ policyVersion: opt.policyVersion ?? 0,
5271
+ ops,
5272
+ };
5273
+ versions.set(origin, env.version);
5274
+ conv.ingest(env, { local: true });
5275
+ recentLocal.push(env);
5276
+ if (recentLocal.length > RECENT_LOCAL_CAP)
5277
+ recentLocal.shift();
4864
5278
  for (const cb of [...subscribers])
4865
- cb(batch);
4866
- };
4867
- const run = () => {
4868
- source(); // track every commit…
4869
- untracked(flush); // …and emit the delta since the last flush
5279
+ cb(env);
4870
5280
  };
4871
- // default driver is an Angular effect (needs an injector); a supplied driver runs injector-free
4872
- // (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
4873
- const ref = opt?.driver
4874
- ? opt.driver(run)
4875
- : effect(run, { injector: opt?.injector ?? inject(Injector) });
5281
+ const unsub = log.subscribe((batch) => emitLocal(batch.ops));
4876
5282
  return {
4877
- latest: latest.asReadonly(),
5283
+ origin,
4878
5284
  subscribe: (cb) => {
4879
5285
  subscribers.add(cb);
4880
5286
  return () => subscribers.delete(cb);
4881
5287
  },
4882
- // the emission core, callable on demand — reads the source untracked, so it never disturbs the
4883
- // driver's subscription; a subsequent scheduled run just finds the baseline already advanced
4884
- flush: () => flush(),
4885
- apply: (batchOrOps) => {
4886
- const ops = Array.isArray(batchOrOps)
4887
- ? batchOrOps
4888
- : batchOrOps.ops;
4889
- if (!ops.length)
5288
+ receive: (env) => {
5289
+ if (env.origin === origin)
4890
5290
  return;
4891
- // pending local writes must emit BEFORE the baseline advances past them
4892
- flush();
4893
- const root = applyOps(untracked(source), ops); // one atomic root, structural-shared
4894
- source.set(root);
4895
- prevRoot = root; // baseline advance: an applied batch never echoes
5291
+ if (env.proto !== OP_PROTO_VERSION) {
5292
+ if (isDevMode()) {
5293
+ console.warn(`[@mmstack/primitives] dropped envelope with proto ${env.proto} (expected ${OP_PROTO_VERSION})`);
5294
+ }
5295
+ return;
5296
+ }
5297
+ clock.observe(env.hlc);
5298
+ const known = versions.get(env.origin);
5299
+ if (known !== undefined && env.version <= known)
5300
+ return; // duplicate/covered — idempotent
5301
+ if (known !== undefined && env.version !== known + 1) {
5302
+ opt.onGap?.(env.origin, known + 1, env.version);
5303
+ }
5304
+ versions.set(env.origin, env.version);
5305
+ log.flush();
5306
+ const ops = conv.ingest(env);
5307
+ if (ops.length)
5308
+ log.apply(ops);
5309
+ },
5310
+ flush: () => log.flush(),
5311
+ watermark: () => Object.fromEntries(versions),
5312
+ snapshot: () => {
5313
+ log.flush();
5314
+ return { root: untracked(source), wm: Object.fromEntries(versions) };
5315
+ },
5316
+ seed: () => {
5317
+ log.flush();
5318
+ emitLocal([{ kind: 'set', path: [], next: untracked(source) }]);
5319
+ },
5320
+ hydrate: (root, wm) => {
5321
+ log.flush();
5322
+ const covered = wm?.[origin] ?? 0;
5323
+ const pending = recentLocal.filter((e) => e.version > covered);
5324
+ conv.reset();
5325
+ let next = root;
5326
+ for (const e of pending)
5327
+ next = applyOps(next, e.ops);
5328
+ log.apply([{ kind: 'set', path: [], next }]);
5329
+ for (const [o, v] of Object.entries(wm ?? {})) {
5330
+ versions.set(o, Math.max(versions.get(o) ?? 0, v));
5331
+ }
5332
+ for (const e of pending)
5333
+ conv.ingest(e, { local: true });
4896
5334
  },
4897
5335
  destroy: () => {
4898
- destroyed = true;
5336
+ unsub();
4899
5337
  subscribers.clear();
4900
- ref.destroy();
5338
+ log.destroy();
4901
5339
  },
4902
5340
  };
4903
5341
  }
4904
5342
 
5343
+ /**
5344
+ * Undo/redo for a copy-on-write store, built on the op-log: each tracked change is stored as
5345
+ * its inverse batch, so `undo()` is one `apply` and history costs only the diffs, not full
5346
+ * snapshots. Redoing is invert-of-the-inverse. A new edit made after an undo clears the redo
5347
+ * stack (linear history). Applying a redo/undo does not itself re-enter history.
5348
+ *
5349
+ * Composes with sync for collaborative undo: pass `track: syncClient` so only YOUR writes are
5350
+ * undoable, while `undo()` emits a normal op that propagates to peers (it writes through the
5351
+ * store, which the sync client picks up).
5352
+ */
5353
+ function storeHistory(source, opt) {
5354
+ const limit = opt?.limit ?? 100;
5355
+ const logOpt = { origin: opt?.origin };
5356
+ if (opt?.driver)
5357
+ logOpt.driver = opt.driver;
5358
+ else
5359
+ logOpt.injector = opt?.injector ?? inject(Injector);
5360
+ const log = opLog(source, logOpt);
5361
+ const undoStack = [];
5362
+ const redoStack = [];
5363
+ const version = signal(0, ...(ngDevMode ? [{ debugName: "version" }] : [])); // monotonic: bumps on every mutation so the computeds recompute
5364
+ let applying = false;
5365
+ const push = (stack, inverse) => {
5366
+ stack.push(inverse);
5367
+ if (stack.length > limit)
5368
+ stack.shift();
5369
+ };
5370
+ const record = (batch) => {
5371
+ if (applying)
5372
+ return; // an undo/redo's own emission must not re-enter history
5373
+ if (!batch.ops.length)
5374
+ return;
5375
+ push(undoStack, invertBatch(batch));
5376
+ redoStack.length = 0; // a fresh edit forks the timeline
5377
+ version.update((v) => v + 1);
5378
+ };
5379
+ // track the sync client's local stream when given, else self-diff every store change
5380
+ const unsub = (opt?.track ?? log).subscribe(record);
5381
+ const run = (from, to) => {
5382
+ const inverse = from.pop();
5383
+ if (!inverse)
5384
+ return;
5385
+ log.flush(); // settle pending local writes before applying
5386
+ applying = true;
5387
+ try {
5388
+ log.apply(inverse);
5389
+ }
5390
+ finally {
5391
+ applying = false;
5392
+ }
5393
+ push(to, invertBatch(inverse)); // the inverse of what we applied restores the other direction
5394
+ version.update((v) => v + 1);
5395
+ };
5396
+ return {
5397
+ canUndo: computed(() => (version(), undoStack.length > 0)),
5398
+ canRedo: computed(() => (version(), redoStack.length > 0)),
5399
+ undo: () => run(undoStack, redoStack),
5400
+ redo: () => run(redoStack, undoStack),
5401
+ clear: () => {
5402
+ undoStack.length = 0;
5403
+ redoStack.length = 0;
5404
+ version.update((v) => v + 1);
5405
+ },
5406
+ destroy: () => {
5407
+ unsub();
5408
+ log.destroy();
5409
+ },
5410
+ };
5411
+ }
5412
+
5413
+ const PERSISTED_STORE_OPTIONS = new InjectionToken('@mmstack/primitives:persisted-store-options');
5414
+ /**
5415
+ * Wire the {@link AsyncStore} backend (and any shared debounce) once, override per call. The
5416
+ * typical use is to install idb-keyval at bootstrap so every `persist`/`persistedStore` persists
5417
+ * without re-passing the backend.
5418
+ *
5419
+ * @example
5420
+ * import * as idbKeyval from 'idb-keyval';
5421
+ * providePersistedStoreOptions({ store: idbKeyval });
5422
+ */
5423
+ function providePersistedStoreOptions(opt) {
5424
+ return { provide: PERSISTED_STORE_OPTIONS, useValue: opt };
5425
+ }
5426
+ /**
5427
+ * Attach durable local persistence to an EXISTING store: its whole-value snapshot is written to an
5428
+ * async backend (IndexedDB via idb-keyval or Dexie) and restored on boot. A reader over the store,
5429
+ * so it composes with the other op-log readers (`tabSync`, `@mmstack/mesh`) on the same store — a
5430
+ * persisted, synced graph is just two readers. Local durability, not sync.
5431
+ *
5432
+ * Because the backend is async, hydration cannot precede the first read: the store keeps its current
5433
+ * value, then adopts the persisted snapshot once the backend answers, UNLESS a write happened first
5434
+ * (an explicit boot-time write wins over stale disk). Writes are coalesced and flushed on teardown
5435
+ * and on page hide, so the last change is never lost. On the server it is a no-op.
5436
+ *
5437
+ * When the persisted shape evolves, pass `version` and a `migrate` hook: an older snapshot is
5438
+ * brought forward on boot before it is adopted, then re-persisted in the new shape. Because boot is
5439
+ * already async, `migrate` may be async, so the migration ladder can be lazy-imported.
5440
+ */
5441
+ function persist(source, opt) {
5442
+ const injector = opt.injector ?? inject(Injector);
5443
+ const defaults = injector.get(PERSISTED_STORE_OPTIONS, null);
5444
+ const key = opt.key;
5445
+ const backend = opt.store ?? defaults?.store;
5446
+ const serialize = opt.serialize ?? ((v) => v);
5447
+ const deserialize = opt.deserialize ?? ((r) => r);
5448
+ const version = opt.version;
5449
+ const debounceMs = opt.writeDebounceMs ?? defaults?.writeDebounceMs ?? 300;
5450
+ const read = source;
5451
+ const setRoot = (value) => source.set(value);
5452
+ const VERSION_KEY = '__mmstack_pv';
5453
+ const encode = (value) => version === undefined
5454
+ ? serialize(value)
5455
+ : { [VERSION_KEY]: version, data: serialize(value) };
5456
+ const isServer = isPlatformServer(injector.get(PLATFORM_ID));
5457
+ const initialRef = untracked(read); // copy-on-write: an untouched store keeps this reference
5458
+ const hydrated = signal(false, ...(ngDevMode ? [{ debugName: "hydrated" }] : []));
5459
+ if (isServer || !backend) {
5460
+ if (!backend && !isServer && isDevMode()) {
5461
+ console.warn(`[@mmstack/primitives] persist("${key}"): no AsyncStore backend (pass { store } or providePersistedStoreOptions). Running in-memory, not persisted.`);
5462
+ }
5463
+ hydrated.set(true);
5464
+ return {
5465
+ hydrated: hydrated.asReadonly(),
5466
+ flush: () => Promise.resolve(),
5467
+ clear: () => {
5468
+ setRoot(initialRef);
5469
+ return Promise.resolve();
5470
+ },
5471
+ };
5472
+ }
5473
+ let persistedRef = initialRef;
5474
+ void (async () => {
5475
+ try {
5476
+ const raw = await backend.get(key);
5477
+ // apply the snapshot only if nothing wrote in the boot window (explicit write wins)
5478
+ if (raw !== undefined && raw !== null && untracked(read) === initialRef) {
5479
+ let fromVersion = 0;
5480
+ let payload = raw;
5481
+ if (typeof raw === 'object' &&
5482
+ raw !== null &&
5483
+ VERSION_KEY in raw) {
5484
+ const env = raw;
5485
+ fromVersion =
5486
+ typeof env[VERSION_KEY] === 'number'
5487
+ ? env[VERSION_KEY]
5488
+ : 0;
5489
+ payload = env['data'];
5490
+ }
5491
+ const target = version ?? 0;
5492
+ if (fromVersion > target) {
5493
+ if (isDevMode()) {
5494
+ console.warn(`[@mmstack/primitives] persist("${key}"): stored snapshot is version ${fromVersion} but this build is ${target}; leaving it untouched (a newer build wrote it).`);
5495
+ }
5496
+ }
5497
+ else {
5498
+ const migrated = !!(opt.migrate && fromVersion < target);
5499
+ let value = deserialize(payload);
5500
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
5501
+ if (migrated)
5502
+ value = await opt.migrate(value, fromVersion);
5503
+ if (untracked(read) === initialRef) {
5504
+ setRoot(value);
5505
+ if (!migrated)
5506
+ persistedRef = value;
5507
+ }
5508
+ }
5509
+ }
5510
+ }
5511
+ catch (err) {
5512
+ if (isDevMode()) {
5513
+ console.warn(`[@mmstack/primitives] persist("${key}") hydrate failed`, err);
5514
+ }
5515
+ }
5516
+ finally {
5517
+ hydrated.set(true);
5518
+ }
5519
+ })();
5520
+ let timer;
5521
+ const write = async (value) => {
5522
+ try {
5523
+ await backend.set(key, encode(value));
5524
+ persistedRef = value;
5525
+ }
5526
+ catch (err) {
5527
+ if (isDevMode()) {
5528
+ console.warn(`[@mmstack/primitives] persist("${key}") write failed`, err);
5529
+ }
5530
+ }
5531
+ };
5532
+ const cancelTimer = () => {
5533
+ if (timer !== undefined) {
5534
+ clearTimeout(timer);
5535
+ timer = undefined;
5536
+ }
5537
+ };
5538
+ const flush = async () => {
5539
+ cancelTimer();
5540
+ const current = untracked(read);
5541
+ if (!untracked(hydrated) ||
5542
+ current === initialRef ||
5543
+ current === persistedRef)
5544
+ return;
5545
+ await write(current);
5546
+ };
5547
+ effect(() => {
5548
+ if (!hydrated())
5549
+ return;
5550
+ const value = read();
5551
+ untracked(() => {
5552
+ cancelTimer();
5553
+ // untouched / reset-to-initial, or already the value on disk (e.g. just hydrated): skip
5554
+ if (value === initialRef || value === persistedRef)
5555
+ return;
5556
+ timer = setTimeout(() => {
5557
+ timer = undefined;
5558
+ void write(value);
5559
+ }, debounceMs);
5560
+ });
5561
+ }, { injector });
5562
+ const onHide = () => {
5563
+ void flush();
5564
+ };
5565
+ if (typeof document !== 'undefined') {
5566
+ document.addEventListener('visibilitychange', onHide);
5567
+ window.addEventListener('pagehide', onHide);
5568
+ }
5569
+ injector.get(DestroyRef).onDestroy(() => {
5570
+ void flush();
5571
+ if (typeof document !== 'undefined') {
5572
+ document.removeEventListener('visibilitychange', onHide);
5573
+ window.removeEventListener('pagehide', onHide);
5574
+ }
5575
+ });
5576
+ return {
5577
+ hydrated: hydrated.asReadonly(),
5578
+ flush,
5579
+ clear: async () => {
5580
+ cancelTimer();
5581
+ setRoot(initialRef); // back to initialRef, so the persist effect skips (no re-write over the delete)
5582
+ persistedRef = initialRef; // disk is now empty
5583
+ try {
5584
+ await backend.del(key);
5585
+ }
5586
+ catch (err) {
5587
+ if (isDevMode()) {
5588
+ console.warn(`[@mmstack/primitives] persist("${key}") clear failed`, err);
5589
+ }
5590
+ }
5591
+ },
5592
+ };
5593
+ }
5594
+ /**
5595
+ * A `store` with {@link persist} already attached: a whole-value snapshot persisted to an async
5596
+ * backend and restored on boot. Equivalent to `const s = store(initial); persist(s, opt)` — reach
5597
+ * for `persist` directly when you want persistence on a store you already have (e.g. to also
5598
+ * `meshSync` it).
5599
+ */
5600
+ function persistedStore(initial, opt) {
5601
+ const injector = opt.injector ?? inject(Injector);
5602
+ // store() reads only the signal/store opts it knows; the persistence keys ride along harmlessly
5603
+ const s = store(initial, { ...opt, injector });
5604
+ const handle = persist(s, { ...opt, injector });
5605
+ return { store: s, ...handle };
5606
+ }
5607
+
4905
5608
  const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
4906
5609
  function keyOf(item, key) {
4907
5610
  if (typeof key === 'function')
@@ -4979,13 +5682,9 @@ function reconcileValue(prev, next, key) {
4979
5682
  */
4980
5683
  function projection(fn, seed, opt) {
4981
5684
  const { key = 'id', ...storeOpt } = opt ?? {};
4982
- // linkedSignal rather than an effect-driven signal: the computation runs in the tracked
4983
- // context (fn's reads are dependencies) and `previous` hands back the last emitted value for
4984
- // the reconcile, so the projection is glitch-free, lazy, and needs no effect scheduler.
4985
5685
  const root = linkedSignal(...(ngDevMode ? [{ debugName: "root", source: () => undefined,
4986
5686
  computation: (_, previous) => {
4987
5687
  const base = previous ? previous.value : seed;
4988
- // a plain mutable scratch seeded with the current value; fn mutates it or returns new data
4989
5688
  const draft = structuredClone(base);
4990
5689
  const returned = fn(draft);
4991
5690
  const next = (returned === undefined ? draft : returned);
@@ -4994,7 +5693,6 @@ function projection(fn, seed, opt) {
4994
5693
  source: () => undefined,
4995
5694
  computation: (_, previous) => {
4996
5695
  const base = previous ? previous.value : seed;
4997
- // a plain mutable scratch seeded with the current value; fn mutates it or returns new data
4998
5696
  const draft = structuredClone(base);
4999
5697
  const returned = fn(draft);
5000
5698
  const next = (returned === undefined ? draft : returned);
@@ -5200,6 +5898,85 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
5200
5898
  return writable;
5201
5899
  }
5202
5900
 
5901
+ /** Op-mode sync for a writable store: hello exchange, then live envelopes (RFC §6 tab flavor). */
5902
+ function storeTabSync(sig, opt, bus, injector) {
5903
+ const sync = opSync(sig, {
5904
+ writer: opt.writer ?? 'local',
5905
+ policies: opt.policies,
5906
+ injector,
5907
+ });
5908
+ const helloTimeoutMs = opt.helloTimeoutMs ?? 250;
5909
+ const jitterMs = opt.jitterMs ?? 25;
5910
+ let phase = 'joining';
5911
+ const joinBuffer = [];
5912
+ const responseTimers = new Map();
5913
+ let helloTimer;
5914
+ function goLive() {
5915
+ if (phase === 'live')
5916
+ return;
5917
+ phase = 'live';
5918
+ if (helloTimer !== undefined) {
5919
+ clearTimeout(helloTimer);
5920
+ helloTimer = undefined;
5921
+ }
5922
+ for (const env of joinBuffer.splice(0))
5923
+ sync.receive(env);
5924
+ }
5925
+ const { unsub, post } = bus.subscribe(opt.id, (msg) => {
5926
+ if (!msg || typeof msg !== 'object')
5927
+ return;
5928
+ switch (msg.t) {
5929
+ case 'env':
5930
+ if (phase === 'joining')
5931
+ joinBuffer.push(msg.env);
5932
+ else
5933
+ sync.receive(msg.env);
5934
+ return;
5935
+ case 'hello': {
5936
+ if (phase !== 'live' || msg.from === sync.origin)
5937
+ return;
5938
+ // first responder wins: jittered answer, cancelled when someone else answers first
5939
+ const timer = setTimeout(() => {
5940
+ responseTimers.delete(msg.from);
5941
+ const snap = sync.snapshot();
5942
+ const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
5943
+ post(covered
5944
+ ? { t: 'uptodate', to: msg.from }
5945
+ : { t: 'state', to: msg.from, root: snap.root, wm: snap.wm });
5946
+ }, Math.random() * jitterMs);
5947
+ responseTimers.set(msg.from, timer);
5948
+ return;
5949
+ }
5950
+ case 'state':
5951
+ case 'uptodate': {
5952
+ const scheduled = responseTimers.get(msg.to);
5953
+ if (scheduled !== undefined) {
5954
+ clearTimeout(scheduled);
5955
+ responseTimers.delete(msg.to);
5956
+ }
5957
+ if (msg.to !== sync.origin || phase !== 'joining')
5958
+ return;
5959
+ if (msg.t === 'state')
5960
+ sync.hydrate(msg.root, msg.wm);
5961
+ goLive();
5962
+ return;
5963
+ }
5964
+ }
5965
+ });
5966
+ const unsubEnv = sync.subscribe((env) => post({ t: 'env', env }));
5967
+ post({ t: 'hello', from: sync.origin, wm: sync.watermark() });
5968
+ helloTimer = setTimeout(goLive, helloTimeoutMs);
5969
+ injector.get(DestroyRef).onDestroy(() => {
5970
+ if (helloTimer !== undefined)
5971
+ clearTimeout(helloTimer);
5972
+ for (const timer of responseTimers.values())
5973
+ clearTimeout(timer);
5974
+ responseTimers.clear();
5975
+ unsubEnv();
5976
+ unsub();
5977
+ sync.destroy();
5978
+ });
5979
+ }
5203
5980
  class MessageBus {
5204
5981
  channel = new BroadcastChannel('mmstack-tab-sync-bus');
5205
5982
  listeners = new Map();
@@ -5311,6 +6088,20 @@ function tabSync(sig, opt) {
5311
6088
  return sig;
5312
6089
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
5313
6090
  const bus = injector.get(MessageBus);
6091
+ const storeKind = sig[STORE_KIND];
6092
+ if (storeKind === 'writable') {
6093
+ storeTabSync(sig, { ...optObj, id }, bus, injector);
6094
+ return sig;
6095
+ }
6096
+ if (storeKind === 'readonly') {
6097
+ if (isDevMode()) {
6098
+ console.warn('[@mmstack/primitives] tabSync: a readonly store cannot receive remote ops — not synced.');
6099
+ }
6100
+ return sig;
6101
+ }
6102
+ if (storeKind === 'mutable' && isDevMode()) {
6103
+ console.warn('[@mmstack/primitives] tabSync: mutable stores fall back to whole-value sync (op diffing needs copy-on-write).');
6104
+ }
5314
6105
  const NONE = Symbol();
5315
6106
  let received = NONE;
5316
6107
  const { unsub, post } = bus.subscribe(id, (next) => {
@@ -5538,5 +6329,5 @@ function withHistory(sourceOrValue, opt) {
5538
6329
  * Generated bundle index. Do not edit.
5539
6330
  */
5540
6331
 
5541
- 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 };
6332
+ 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 };
5542
6333
  //# sourceMappingURL=mmstack-primitives.mjs.map