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