@mmstack/primitives 20.10.0 → 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
@@ -4290,7 +4556,11 @@ function buildChildNode(target, prop, isMutableSource, options) {
4290
4556
  function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...rest } = {}) {
4291
4557
  if (isStore(source))
4292
4558
  return source;
4293
- if (!injector)
4559
+ // injector is needed ONLY to resolve the two proxy-globals tokens; if a caller supplies the
4560
+ // globals directly (createStoreContext — the worker-side seam with no DI), skip inject entirely
4561
+ const sharedGlobals = rest[STORE_SHARED_GLOBALS];
4562
+ const hasSharedGlobals = !!(sharedGlobals?.cache && sharedGlobals?.registry);
4563
+ if (!injector && !hasSharedGlobals)
4294
4564
  injector = inject(Injector);
4295
4565
  const writableSource = isWritableSignal(source)
4296
4566
  ? source
@@ -4308,13 +4578,17 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4308
4578
  return 'primitive';
4309
4579
  }, ...(ngDevMode ? [{ debugName: "kind" }] : []));
4310
4580
  const STORE_OPTIONS = {
4311
- injector,
4581
+ // may be undefined in worker/DI-less mode; unused downstream once globals are resolved
4582
+ // (children thread the resolved globals via STORE_SHARED_OPTIONS, derived needs no injector)
4583
+ injector: injector,
4312
4584
  vivify,
4313
4585
  noUnionLeaves,
4314
4586
  [STORE_SHARED_GLOBALS]: {
4315
- cache: rest[STORE_SHARED_GLOBALS]?.cache ?? injector.get(PROXY_CACHE_TOKEN),
4316
- registry: rest[STORE_SHARED_GLOBALS]?.registry ??
4317
- injector.get(PROXY_CLEANUP_TOKEN),
4587
+ // the `injector!` reads run only when a global is absent
4588
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4589
+ cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
4590
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4591
+ registry: sharedGlobals?.registry ?? injector.get(PROXY_CLEANUP_TOKEN),
4318
4592
  },
4319
4593
  };
4320
4594
  // built lazily so non-array nodes never allocate it
@@ -4386,7 +4660,12 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4386
4660
  return () => {
4387
4661
  if (!isWritableSource)
4388
4662
  return s;
4389
- return untracked(() => toStore(source.asReadonly(), { injector, vivify, noUnionLeaves }));
4663
+ return untracked(() => toStore(source.asReadonly(), {
4664
+ injector,
4665
+ vivify,
4666
+ noUnionLeaves,
4667
+ [STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
4668
+ }));
4390
4669
  };
4391
4670
  const k = untracked(kind);
4392
4671
  if (prop === 'extend' && k !== 'array')
@@ -4400,8 +4679,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4400
4679
  return arrayLength();
4401
4680
  if (prop === Symbol.iterator)
4402
4681
  return function* () {
4403
- // read length reactively: a spread/for-of inside a computed/effect must re-run
4404
- // when items are added or removed, not only when already-read elements change
4405
4682
  const len = arrayLength();
4406
4683
  for (let i = 0; i < len(); i++)
4407
4684
  yield receiver[i];
@@ -4551,6 +4828,40 @@ function mutableStore(value, opt) {
4551
4828
  ...opt,
4552
4829
  });
4553
4830
  }
4831
+ /**
4832
+ * Builds a DI-less store context — the shared proxy-cache and cleanup registry that {@link toStore}
4833
+ * normally resolves from the injector — so a `store`/`toStore`/`opLog` graph can run with NO Angular
4834
+ * injection context. Spread the result into the options:
4835
+ *
4836
+ * ```ts
4837
+ * import { microtaskOpLogDriver } from '@mmstack/worker/host';
4838
+ * const ctx = createStoreContext();
4839
+ * const s = store({ todos: [] }, ctx);
4840
+ * const log = opLog(s, { driver: microtaskOpLogDriver(), origin: 'worker' }); // no injector anywhere
4841
+ * ```
4842
+ *
4843
+ * **This is a worker-only fallback — do NOT use it on the main thread.** DI is the default and
4844
+ * correct path in an app: the injector scopes the proxy-cache/cleanup singletons per app instance,
4845
+ * which on the SERVER keeps one request's store identity from bleeding into another's (the exact
4846
+ * hazard a module-scope singleton would reintroduce). A Web Worker is safe because it is a single
4847
+ * store graph per thread and never runs during SSR (spawn is a `PLATFORM_ID === 'server'` no-op),
4848
+ * so there is no cross-request scope to contaminate. Never hoist a `createStoreContext()` to module
4849
+ * scope on a shared/main thread.
4850
+ *
4851
+ * **Share ONE context across every store in a worker** — the same way `providedIn: 'root'` shares
4852
+ * one cache across all of an app's stores. `@mmstack/worker/host` memoizes this per worker
4853
+ * (`workerStoreContext()`); reach for `createStoreContext()` directly only in a bare
4854
+ * (non-worker-host) DI-less setup, and hold the single instance yourself.
4855
+ */
4856
+ function createStoreContext() {
4857
+ const cache = new WeakMap();
4858
+ const registry = new FinalizationRegistry(({ target, prop }) => {
4859
+ const entry = cache.get(target);
4860
+ if (entry)
4861
+ entry.delete(prop);
4862
+ });
4863
+ return { [STORE_SHARED_GLOBALS]: { cache, registry } };
4864
+ }
4554
4865
 
4555
4866
  function isPlainRecord(value) {
4556
4867
  if (value === null || typeof value !== 'object')
@@ -4614,9 +4925,6 @@ function forkStore(base, opt) {
4614
4925
  source: () => base(),
4615
4926
  computation: (theirs, prev) => prev === undefined ? theirs : merge(prev.source, prev.value, theirs),
4616
4927
  }]));
4617
- // Inherit the base's shared options (injector, vivify, noUnionLeaves + the
4618
- // proxy cache/registry), same as extendStore — a fork should vivify like its
4619
- // base and share its injector-scoped cache. `opt` overrides (advanced use).
4620
4928
  const store = toStore(staged, {
4621
4929
  ...base[STORE_SHARED_OPTIONS],
4622
4930
  ...opt,
@@ -4625,192 +4933,774 @@ function forkStore(base, opt) {
4625
4933
  store,
4626
4934
  commit: () => base.set(untracked(staged)),
4627
4935
  discard: () => staged.set(untracked(base)),
4936
+ ops: () => diffOps(untracked(base), untracked(staged)),
4628
4937
  };
4629
4938
  }
4630
4939
 
4631
- function generateOrigin() {
4632
- if (globalThis.crypto?.randomUUID)
4633
- return globalThis.crypto.randomUUID();
4634
- 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
+ };
4635
4982
  }
4636
- const isPlainArray = (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 });
4637
4992
  /**
4638
- * Reference-identity-pruned structural diff the same short-circuit discipline as `merge3`:
4639
- * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4640
- * 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.
4641
5001
  */
4642
- function diffNode(prev, next, path, ops) {
4643
- if (Object.is(prev, next))
4644
- return;
4645
- if (isRecord(prev) && isRecord(next)) {
4646
- for (const key of Object.keys(prev)) {
4647
- if (!Object.hasOwn(next, key))
4648
- 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
4649
5007
  }
4650
- for (const key of Object.keys(next)) {
4651
- if (!Object.hasOwn(prev, key)) {
4652
- // added key: deliberately NO `prev` property (absent ≠ undefined)
4653
- 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));
4654
5027
  }
4655
- else {
4656
- 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
4657
5030
  }
5031
+ // else: theirs removed it and mine left it untouched → stays removed
4658
5032
  }
4659
- return;
4660
- }
4661
- if (isPlainArray(prev) && isPlainArray(next)) {
4662
- // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4663
- // change is a whole unit — index attribution lies under insert/remove/reorder
4664
- if (prev.length === next.length) {
4665
- for (let i = 0; i < next.length; i++)
4666
- diffNode(prev[i], next[i], [...path, i], ops);
4667
- 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
+ }
4668
5040
  }
4669
- ops.push({ kind: 'set', path, prev, next });
4670
- return;
4671
- }
4672
- // leaf / type change / opaque — one unit, prev present (the slot existed)
4673
- ops.push({ kind: 'set', path, prev, next });
5041
+ return out;
5042
+ };
4674
5043
  }
4675
- /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4676
- function applyAt(container, path, idx, op) {
4677
- const seg = path[idx];
4678
- const base = isPlainArray(container)
4679
- ? container.slice()
4680
- : isRecord(container)
4681
- ? { ...container }
4682
- : typeof seg === 'number'
4683
- ? []
4684
- : {};
4685
- if (idx === path.length - 1) {
4686
- if (op.kind === 'delete') {
4687
- // arrays never receive deletes (length changes travel as whole-array sets)
4688
- 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;
4689
5057
  }
4690
- else {
4691
- 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;
4692
5083
  }
4693
- return base;
4694
5084
  }
4695
- base[seg] = applyAt(base[seg], path, idx + 1, op);
4696
- return base;
5085
+ return true;
4697
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;
4698
5096
  /**
4699
- * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add a `set` with no
4700
- * `prev` inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4701
- * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4702
- * carry — a wire-serialized batch that stripped them is not invertible.
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.
4703
5100
  */
4704
- function invertBatch(batch) {
4705
- const ops = Array.isArray(batch) ? batch : batch.ops;
4706
- const inverted = [];
4707
- for (let i = ops.length - 1; i >= 0; i--) {
4708
- const op = ops[i];
4709
- if (op.kind === 'delete') {
4710
- inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: undefined });
4711
- continue;
4712
- }
4713
- if (!Object.hasOwn(op, 'prev')) {
4714
- inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4715
- }
4716
- else {
4717
- inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: op.next });
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;
4718
5108
  }
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];
4719
5191
  }
4720
- return inverted;
5192
+ return cur;
4721
5193
  }
4722
5194
  /**
4723
- * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4724
- * immutably-updated objects) and emits its changes as minimal structural op batches the
4725
- * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4726
- * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4727
- *
4728
- * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4729
- * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4730
- * effect. A batch therefore coalesces everything written in one tick — for coarser,
4731
- * intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
4732
- *
4733
- * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4734
- * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4735
- * warning fires and nothing emits.
4736
- *
4737
- * ```ts
4738
- * const s = store({ todos: [{ done: false }] });
4739
- * const log = opLog(s, { origin: 'tab-a' });
4740
- * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4741
- * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4742
- * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4743
- * ```
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.
4744
5198
  */
4745
- function opLog(source, opt) {
4746
- const injector = opt?.injector ?? inject(Injector);
4747
- const origin = opt?.origin ?? generateOrigin();
4748
- // a store proxy's `has` trap answers for the VALUE's keys, so `isMutable`'s `'mutate' in`
4749
- // probe can't see the brand — ask the store's own kind symbol first
4750
- const storeKind = source[STORE_KIND];
4751
- const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4752
- if (isDevMode() && mutableSource) {
4753
- 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.');
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]));
4754
5204
  }
4755
- let prevRoot = untracked(source);
4756
- let version = 0;
4757
- let destroyed = false;
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 };
5230
+ }
5231
+ /**
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'`.
5237
+ */
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);
5245
+ }
5246
+ /**
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.
5250
+ */
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 });
4758
5256
  const subscribers = new Set();
4759
- const latest = signal(null, ...(ngDevMode ? [{ debugName: "latest" }] : []));
4760
- /** Diff now, emit if there's a delta, advance the baseline. */
4761
- const flush = () => {
4762
- if (destroyed)
4763
- return;
4764
- const next = untracked(source);
4765
- if (Object.is(prevRoot, next))
4766
- return;
4767
- const ops = [];
4768
- diffNode(prevRoot, next, [], ops);
4769
- prevRoot = next;
4770
- if (!ops.length)
4771
- return; // fresh refs, equal values — spurious-write tolerance
4772
- const batch = { origin, version: ++version, ops };
4773
- 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();
4774
5278
  for (const cb of [...subscribers])
4775
- cb(batch);
5279
+ cb(env);
4776
5280
  };
4777
- const ref = effect(() => {
4778
- source(); // track every commit…
4779
- untracked(flush); // …and emit the delta since the last flush
4780
- }, ...(ngDevMode ? [{ debugName: "ref", injector: opt?.injector }] : [{ injector: opt?.injector }]));
5281
+ const unsub = log.subscribe((batch) => emitLocal(batch.ops));
4781
5282
  return {
4782
- latest: latest.asReadonly(),
5283
+ origin,
4783
5284
  subscribe: (cb) => {
4784
5285
  subscribers.add(cb);
4785
5286
  return () => subscribers.delete(cb);
4786
5287
  },
4787
- apply: (batchOrOps) => {
4788
- const ops = Array.isArray(batchOrOps)
4789
- ? batchOrOps
4790
- : batchOrOps.ops;
4791
- if (!ops.length)
5288
+ receive: (env) => {
5289
+ if (env.origin === origin)
4792
5290
  return;
4793
- // pending local writes must emit BEFORE the baseline advances past them
4794
- flush();
4795
- let root = untracked(source);
4796
- for (const op of ops) {
4797
- if (op.path.length === 0) {
4798
- if (op.kind === 'set')
4799
- root = op.next;
4800
- continue; // a root delete is meaningless — ignore
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})`);
4801
5294
  }
4802
- root = applyAt(root, op.path, 0, op);
5295
+ return;
4803
5296
  }
4804
- source.set(root);
4805
- prevRoot = root; // baseline advance: an applied batch never echoes
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 });
4806
5334
  },
4807
5335
  destroy: () => {
4808
- destroyed = true;
5336
+ unsub();
4809
5337
  subscribers.clear();
4810
- ref.destroy();
5338
+ log.destroy();
5339
+ },
5340
+ };
5341
+ }
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
+ }
4811
5591
  },
4812
5592
  };
4813
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
+
5608
+ const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
5609
+ function keyOf(item, key) {
5610
+ if (typeof key === 'function')
5611
+ return key(item);
5612
+ return isRecord(item) ? item[key] : item;
5613
+ }
5614
+ /**
5615
+ * Produces a value equal to `next` but sharing as much of `prev`'s reference structure as possible:
5616
+ * an object subtree that did not change keeps its `prev` reference, and array items are matched by
5617
+ * `key` so a surviving item keeps its identity across a reorder/insert/remove (only added items are
5618
+ * new, only removed items are dropped). This is what lets a derived store recompute without tearing
5619
+ * down every downstream `computed` that reads an unchanged part of it.
5620
+ */
5621
+ function reconcile(prev, next, key = 'id') {
5622
+ return reconcileValue(prev, next, key);
5623
+ }
5624
+ function reconcileValue(prev, next, key) {
5625
+ if (Object.is(prev, next))
5626
+ return prev;
5627
+ if (isPlainArray(prev) && isPlainArray(next)) {
5628
+ const byKey = new Map();
5629
+ for (const item of prev)
5630
+ byKey.set(keyOf(item, key), item);
5631
+ let changed = prev.length !== next.length;
5632
+ const out = next.map((item, i) => {
5633
+ const match = byKey.get(keyOf(item, key));
5634
+ const rv = match !== undefined ? reconcileValue(match, item, key) : item;
5635
+ if (rv !== prev[i])
5636
+ changed = true;
5637
+ return rv;
5638
+ });
5639
+ return changed ? out : prev;
5640
+ }
5641
+ if (isRecord(prev) && isRecord(next)) {
5642
+ const nextKeys = Object.keys(next);
5643
+ let changed = Object.keys(prev).length !== nextKeys.length;
5644
+ const out = {};
5645
+ for (const k of nextKeys) {
5646
+ const rv = Object.hasOwn(prev, k)
5647
+ ? reconcileValue(prev[k], next[k], key)
5648
+ : next[k];
5649
+ out[k] = rv;
5650
+ if (rv !== prev[k])
5651
+ changed = true;
5652
+ }
5653
+ return changed ? out : prev;
5654
+ }
5655
+ return next;
5656
+ }
5657
+ /**
5658
+ * A derived STORE, the store-shaped counterpart to `computed`. `fn` receives a mutable draft seeded
5659
+ * with the current value and either mutates it in place or returns a new value; whichever it does,
5660
+ * the result is reconciled against the previous value (see {@link reconcile}) so unchanged subtrees
5661
+ * keep reference identity and keyed array items keep their proxy identity. Reading through the
5662
+ * returned store is fine-grained: a `computed` over one field only recomputes when that field
5663
+ * actually changes, even though the whole projection re-ran.
5664
+ *
5665
+ * Recompute is pull-based, exactly like `computed`: the projection is memoized and re-runs on the
5666
+ * first read after a signal `fn` depends on changes, so reads are always coherent (no waiting on an
5667
+ * effect flush) and nothing recomputes while nobody reads. `fn` must be pure, it runs inside the
5668
+ * reactive computation. Prefer `computed` for a plain value; reach for `projection` when you want
5669
+ * the per-property tracking of a store on top of a derivation.
5670
+ *
5671
+ * ```ts
5672
+ * const active = projection<User[]>(() => users().filter((u) => u.active), [], { key: 'id' });
5673
+ * // active[0].name(); — surviving users keep identity across recomputes
5674
+ * ```
5675
+ *
5676
+ * Needs an injection context (or an explicit `injector`) for the store layer's cleanup on the main
5677
+ * thread; with an explicit store context (`createStoreContext()`) it is injector-free, so it also
5678
+ * runs on a worker host.
5679
+ *
5680
+ * @param fn receives the current draft; mutate it, or return new data.
5681
+ * @param seed the initial value, held before the first run.
5682
+ */
5683
+ function projection(fn, seed, opt) {
5684
+ const { key = 'id', ...storeOpt } = opt ?? {};
5685
+ const root = linkedSignal(...(ngDevMode ? [{ debugName: "root", source: () => undefined,
5686
+ computation: (_, previous) => {
5687
+ const base = previous ? previous.value : seed;
5688
+ const draft = structuredClone(base);
5689
+ const returned = fn(draft);
5690
+ const next = (returned === undefined ? draft : returned);
5691
+ return reconcile(base, next, key);
5692
+ } }] : [{
5693
+ source: () => undefined,
5694
+ computation: (_, previous) => {
5695
+ const base = previous ? previous.value : seed;
5696
+ const draft = structuredClone(base);
5697
+ const returned = fn(draft);
5698
+ const next = (returned === undefined ? draft : returned);
5699
+ return reconcile(base, next, key);
5700
+ },
5701
+ }]));
5702
+ return toStore(root, storeOpt).asReadonlyStore();
5703
+ }
4814
5704
 
4815
5705
  /**
4816
5706
  * @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
@@ -5008,6 +5898,85 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
5008
5898
  return writable;
5009
5899
  }
5010
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
+ }
5011
5980
  class MessageBus {
5012
5981
  channel = new BroadcastChannel('mmstack-tab-sync-bus');
5013
5982
  listeners = new Map();
@@ -5119,6 +6088,20 @@ function tabSync(sig, opt) {
5119
6088
  return sig;
5120
6089
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
5121
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
+ }
5122
6105
  const NONE = Symbol();
5123
6106
  let received = NONE;
5124
6107
  const { unsub, post } = bus.subscribe(id, (next) => {
@@ -5346,5 +6329,5 @@ function withHistory(sourceOrValue, opt) {
5346
6329
  * Generated bundle index. Do not edit.
5347
6330
  */
5348
6331
 
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 };
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 };
5350
6333
  //# sourceMappingURL=mmstack-primitives.mjs.map