@mmstack/primitives 21.5.0 → 21.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -2
- package/fesm2022/mmstack-primitives.mjs +1204 -230
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-primitives.d.ts +552 -79
|
@@ -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)
|
|
@@ -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
|
|
@@ -4263,7 +4529,11 @@ function buildChildNode(target, prop, isMutableSource, options) {
|
|
|
4263
4529
|
function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...rest } = {}) {
|
|
4264
4530
|
if (isStore(source))
|
|
4265
4531
|
return source;
|
|
4266
|
-
if
|
|
4532
|
+
// injector is needed ONLY to resolve the two proxy-globals tokens; if a caller supplies the
|
|
4533
|
+
// globals directly (createStoreContext — the worker-side seam with no DI), skip inject entirely
|
|
4534
|
+
const sharedGlobals = rest[STORE_SHARED_GLOBALS];
|
|
4535
|
+
const hasSharedGlobals = !!(sharedGlobals?.cache && sharedGlobals?.registry);
|
|
4536
|
+
if (!injector && !hasSharedGlobals)
|
|
4267
4537
|
injector = inject(Injector);
|
|
4268
4538
|
const writableSource = isWritableSignal(source)
|
|
4269
4539
|
? source
|
|
@@ -4281,13 +4551,17 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4281
4551
|
return 'primitive';
|
|
4282
4552
|
}, ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
|
|
4283
4553
|
const STORE_OPTIONS = {
|
|
4284
|
-
|
|
4554
|
+
// may be undefined in worker/DI-less mode; unused downstream once globals are resolved
|
|
4555
|
+
// (children thread the resolved globals via STORE_SHARED_OPTIONS, derived needs no injector)
|
|
4556
|
+
injector: injector,
|
|
4285
4557
|
vivify,
|
|
4286
4558
|
noUnionLeaves,
|
|
4287
4559
|
[STORE_SHARED_GLOBALS]: {
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4560
|
+
// the `injector!` reads run only when a global is absent
|
|
4561
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
4562
|
+
cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
|
|
4563
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
4564
|
+
registry: sharedGlobals?.registry ?? injector.get(PROXY_CLEANUP_TOKEN),
|
|
4291
4565
|
},
|
|
4292
4566
|
};
|
|
4293
4567
|
// built lazily so non-array nodes never allocate it
|
|
@@ -4359,7 +4633,12 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4359
4633
|
return () => {
|
|
4360
4634
|
if (!isWritableSource)
|
|
4361
4635
|
return s;
|
|
4362
|
-
return untracked(() => toStore(source.asReadonly(), {
|
|
4636
|
+
return untracked(() => toStore(source.asReadonly(), {
|
|
4637
|
+
injector,
|
|
4638
|
+
vivify,
|
|
4639
|
+
noUnionLeaves,
|
|
4640
|
+
[STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
|
|
4641
|
+
}));
|
|
4363
4642
|
};
|
|
4364
4643
|
const k = untracked(kind);
|
|
4365
4644
|
if (prop === 'extend' && k !== 'array')
|
|
@@ -4373,8 +4652,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4373
4652
|
return arrayLength();
|
|
4374
4653
|
if (prop === Symbol.iterator)
|
|
4375
4654
|
return function* () {
|
|
4376
|
-
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
4377
|
-
// when items are added or removed, not only when already-read elements change
|
|
4378
4655
|
const len = arrayLength();
|
|
4379
4656
|
for (let i = 0; i < len(); i++)
|
|
4380
4657
|
yield receiver[i];
|
|
@@ -4524,6 +4801,40 @@ function mutableStore(value, opt) {
|
|
|
4524
4801
|
...opt,
|
|
4525
4802
|
});
|
|
4526
4803
|
}
|
|
4804
|
+
/**
|
|
4805
|
+
* Builds a DI-less store context — the shared proxy-cache and cleanup registry that {@link toStore}
|
|
4806
|
+
* normally resolves from the injector — so a `store`/`toStore`/`opLog` graph can run with NO Angular
|
|
4807
|
+
* injection context. Spread the result into the options:
|
|
4808
|
+
*
|
|
4809
|
+
* ```ts
|
|
4810
|
+
* import { microtaskOpLogDriver } from '@mmstack/worker/host';
|
|
4811
|
+
* const ctx = createStoreContext();
|
|
4812
|
+
* const s = store({ todos: [] }, ctx);
|
|
4813
|
+
* const log = opLog(s, { driver: microtaskOpLogDriver(), origin: 'worker' }); // no injector anywhere
|
|
4814
|
+
* ```
|
|
4815
|
+
*
|
|
4816
|
+
* **This is a worker-only fallback — do NOT use it on the main thread.** DI is the default and
|
|
4817
|
+
* correct path in an app: the injector scopes the proxy-cache/cleanup singletons per app instance,
|
|
4818
|
+
* which on the SERVER keeps one request's store identity from bleeding into another's (the exact
|
|
4819
|
+
* hazard a module-scope singleton would reintroduce). A Web Worker is safe because it is a single
|
|
4820
|
+
* store graph per thread and never runs during SSR (spawn is a `PLATFORM_ID === 'server'` no-op),
|
|
4821
|
+
* so there is no cross-request scope to contaminate. Never hoist a `createStoreContext()` to module
|
|
4822
|
+
* scope on a shared/main thread.
|
|
4823
|
+
*
|
|
4824
|
+
* **Share ONE context across every store in a worker** — the same way `providedIn: 'root'` shares
|
|
4825
|
+
* one cache across all of an app's stores. `@mmstack/worker/host` memoizes this per worker
|
|
4826
|
+
* (`workerStoreContext()`); reach for `createStoreContext()` directly only in a bare
|
|
4827
|
+
* (non-worker-host) DI-less setup, and hold the single instance yourself.
|
|
4828
|
+
*/
|
|
4829
|
+
function createStoreContext() {
|
|
4830
|
+
const cache = new WeakMap();
|
|
4831
|
+
const registry = new FinalizationRegistry(({ target, prop }) => {
|
|
4832
|
+
const entry = cache.get(target);
|
|
4833
|
+
if (entry)
|
|
4834
|
+
entry.delete(prop);
|
|
4835
|
+
});
|
|
4836
|
+
return { [STORE_SHARED_GLOBALS]: { cache, registry } };
|
|
4837
|
+
}
|
|
4527
4838
|
|
|
4528
4839
|
function isPlainRecord(value) {
|
|
4529
4840
|
if (value === null || typeof value !== 'object')
|
|
@@ -4584,9 +4895,6 @@ function forkStore(base, opt) {
|
|
|
4584
4895
|
const merge = reconcile;
|
|
4585
4896
|
const staged = linkedSignal({ ...(ngDevMode ? { debugName: "staged" } : /* istanbul ignore next */ {}), source: () => base(),
|
|
4586
4897
|
computation: (theirs, prev) => prev === undefined ? theirs : merge(prev.source, prev.value, theirs) });
|
|
4587
|
-
// Inherit the base's shared options (injector, vivify, noUnionLeaves + the
|
|
4588
|
-
// proxy cache/registry), same as extendStore — a fork should vivify like its
|
|
4589
|
-
// base and share its injector-scoped cache. `opt` overrides (advanced use).
|
|
4590
4898
|
const store = toStore(staged, {
|
|
4591
4899
|
...base[STORE_SHARED_OPTIONS],
|
|
4592
4900
|
...opt,
|
|
@@ -4595,193 +4903,766 @@ function forkStore(base, opt) {
|
|
|
4595
4903
|
store,
|
|
4596
4904
|
commit: () => base.set(untracked(staged)),
|
|
4597
4905
|
discard: () => staged.set(untracked(base)),
|
|
4906
|
+
ops: () => diffOps(untracked(base), untracked(staged)),
|
|
4598
4907
|
};
|
|
4599
4908
|
}
|
|
4600
4909
|
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
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
|
+
};
|
|
4605
4952
|
}
|
|
4606
|
-
|
|
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 });
|
|
4607
4962
|
/**
|
|
4608
|
-
*
|
|
4609
|
-
* an
|
|
4610
|
-
*
|
|
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.
|
|
4611
4971
|
*/
|
|
4612
|
-
function
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
if (!Object.hasOwn(next, key))
|
|
4618
|
-
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
|
|
4619
4977
|
}
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
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));
|
|
4624
4997
|
}
|
|
4625
|
-
else {
|
|
4626
|
-
|
|
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
|
|
4627
5000
|
}
|
|
5001
|
+
// else: theirs removed it and mine left it untouched → stays removed
|
|
4628
5002
|
}
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
diffNode(prev[i], next[i], [...path, i], ops);
|
|
4637
|
-
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
|
+
}
|
|
4638
5010
|
}
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
}
|
|
4642
|
-
// leaf / type change / opaque — one unit, prev present (the slot existed)
|
|
4643
|
-
ops.push({ kind: 'set', path, prev, next });
|
|
5011
|
+
return out;
|
|
5012
|
+
};
|
|
4644
5013
|
}
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
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;
|
|
4659
5027
|
}
|
|
4660
|
-
|
|
4661
|
-
|
|
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;
|
|
4662
5053
|
}
|
|
4663
|
-
return base;
|
|
4664
5054
|
}
|
|
4665
|
-
|
|
4666
|
-
return base;
|
|
5055
|
+
return true;
|
|
4667
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;
|
|
4668
5066
|
/**
|
|
4669
|
-
*
|
|
4670
|
-
*
|
|
4671
|
-
*
|
|
4672
|
-
* carry — a wire-serialized batch that stripped them is not invertible.
|
|
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.
|
|
4673
5070
|
*/
|
|
4674
|
-
function
|
|
4675
|
-
const
|
|
4676
|
-
const
|
|
4677
|
-
|
|
4678
|
-
const
|
|
4679
|
-
if (
|
|
4680
|
-
|
|
4681
|
-
continue;
|
|
4682
|
-
}
|
|
4683
|
-
if (!Object.hasOwn(op, 'prev')) {
|
|
4684
|
-
inverted.push({ kind: 'delete', path: op.path, prev: op.next });
|
|
4685
|
-
}
|
|
4686
|
-
else {
|
|
4687
|
-
inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: op.next });
|
|
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;
|
|
4688
5078
|
}
|
|
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];
|
|
4689
5161
|
}
|
|
4690
|
-
return
|
|
5162
|
+
return cur;
|
|
4691
5163
|
}
|
|
4692
5164
|
/**
|
|
4693
|
-
*
|
|
4694
|
-
*
|
|
4695
|
-
*
|
|
4696
|
-
* batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
|
|
4697
|
-
*
|
|
4698
|
-
* Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
|
|
4699
|
-
* of the root value per tick (structural sharing makes it O(changed paths)), driven by one
|
|
4700
|
-
* effect. A batch therefore coalesces everything written in one tick — for coarser,
|
|
4701
|
-
* intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
|
|
4702
|
-
*
|
|
4703
|
-
* NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
|
|
4704
|
-
* defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
|
|
4705
|
-
* warning fires and nothing emits.
|
|
4706
|
-
*
|
|
4707
|
-
* ```ts
|
|
4708
|
-
* const s = store({ todos: [{ done: false }] });
|
|
4709
|
-
* const log = opLog(s, { origin: 'tab-a' });
|
|
4710
|
-
* log.subscribe((b) => channel.postMessage(encode(b))); // ship
|
|
4711
|
-
* channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
|
|
4712
|
-
* s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
|
|
4713
|
-
* ```
|
|
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.
|
|
4714
5168
|
*/
|
|
4715
|
-
function
|
|
4716
|
-
const
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
const storeKind = source[STORE_KIND];
|
|
4721
|
-
const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
|
|
4722
|
-
if (isDevMode() && mutableSource) {
|
|
4723
|
-
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.');
|
|
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]));
|
|
4724
5174
|
}
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
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 };
|
|
5200
|
+
}
|
|
5201
|
+
/**
|
|
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'`.
|
|
5207
|
+
*/
|
|
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);
|
|
5215
|
+
}
|
|
5216
|
+
/**
|
|
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.
|
|
5220
|
+
*/
|
|
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 });
|
|
4728
5226
|
const subscribers = new Set();
|
|
4729
|
-
const
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
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();
|
|
4744
5248
|
for (const cb of [...subscribers])
|
|
4745
|
-
cb(
|
|
5249
|
+
cb(env);
|
|
4746
5250
|
};
|
|
4747
|
-
const
|
|
4748
|
-
source(); // track every commit…
|
|
4749
|
-
untracked(flush); // …and emit the delta since the last flush
|
|
4750
|
-
}, { ...(ngDevMode ? { debugName: "ref" } : /* istanbul ignore next */ {}), injector: opt?.injector });
|
|
5251
|
+
const unsub = log.subscribe((batch) => emitLocal(batch.ops));
|
|
4751
5252
|
return {
|
|
4752
|
-
|
|
5253
|
+
origin,
|
|
4753
5254
|
subscribe: (cb) => {
|
|
4754
5255
|
subscribers.add(cb);
|
|
4755
5256
|
return () => subscribers.delete(cb);
|
|
4756
5257
|
},
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
? batchOrOps
|
|
4760
|
-
: batchOrOps.ops;
|
|
4761
|
-
if (!ops.length)
|
|
5258
|
+
receive: (env) => {
|
|
5259
|
+
if (env.origin === origin)
|
|
4762
5260
|
return;
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
for (const op of ops) {
|
|
4767
|
-
if (op.path.length === 0) {
|
|
4768
|
-
if (op.kind === 'set')
|
|
4769
|
-
root = op.next;
|
|
4770
|
-
continue; // a root delete is meaningless — ignore
|
|
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})`);
|
|
4771
5264
|
}
|
|
4772
|
-
|
|
5265
|
+
return;
|
|
4773
5266
|
}
|
|
4774
|
-
|
|
4775
|
-
|
|
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 });
|
|
4776
5304
|
},
|
|
4777
5305
|
destroy: () => {
|
|
4778
|
-
|
|
5306
|
+
unsub();
|
|
4779
5307
|
subscribers.clear();
|
|
4780
|
-
|
|
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();
|
|
4781
5379
|
},
|
|
4782
5380
|
};
|
|
4783
5381
|
}
|
|
4784
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
|
+
|
|
5578
|
+
const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
|
|
5579
|
+
function keyOf(item, key) {
|
|
5580
|
+
if (typeof key === 'function')
|
|
5581
|
+
return key(item);
|
|
5582
|
+
return isRecord(item) ? item[key] : item;
|
|
5583
|
+
}
|
|
5584
|
+
/**
|
|
5585
|
+
* Produces a value equal to `next` but sharing as much of `prev`'s reference structure as possible:
|
|
5586
|
+
* an object subtree that did not change keeps its `prev` reference, and array items are matched by
|
|
5587
|
+
* `key` so a surviving item keeps its identity across a reorder/insert/remove (only added items are
|
|
5588
|
+
* new, only removed items are dropped). This is what lets a derived store recompute without tearing
|
|
5589
|
+
* down every downstream `computed` that reads an unchanged part of it.
|
|
5590
|
+
*/
|
|
5591
|
+
function reconcile(prev, next, key = 'id') {
|
|
5592
|
+
return reconcileValue(prev, next, key);
|
|
5593
|
+
}
|
|
5594
|
+
function reconcileValue(prev, next, key) {
|
|
5595
|
+
if (Object.is(prev, next))
|
|
5596
|
+
return prev;
|
|
5597
|
+
if (isPlainArray(prev) && isPlainArray(next)) {
|
|
5598
|
+
const byKey = new Map();
|
|
5599
|
+
for (const item of prev)
|
|
5600
|
+
byKey.set(keyOf(item, key), item);
|
|
5601
|
+
let changed = prev.length !== next.length;
|
|
5602
|
+
const out = next.map((item, i) => {
|
|
5603
|
+
const match = byKey.get(keyOf(item, key));
|
|
5604
|
+
const rv = match !== undefined ? reconcileValue(match, item, key) : item;
|
|
5605
|
+
if (rv !== prev[i])
|
|
5606
|
+
changed = true;
|
|
5607
|
+
return rv;
|
|
5608
|
+
});
|
|
5609
|
+
return changed ? out : prev;
|
|
5610
|
+
}
|
|
5611
|
+
if (isRecord(prev) && isRecord(next)) {
|
|
5612
|
+
const nextKeys = Object.keys(next);
|
|
5613
|
+
let changed = Object.keys(prev).length !== nextKeys.length;
|
|
5614
|
+
const out = {};
|
|
5615
|
+
for (const k of nextKeys) {
|
|
5616
|
+
const rv = Object.hasOwn(prev, k)
|
|
5617
|
+
? reconcileValue(prev[k], next[k], key)
|
|
5618
|
+
: next[k];
|
|
5619
|
+
out[k] = rv;
|
|
5620
|
+
if (rv !== prev[k])
|
|
5621
|
+
changed = true;
|
|
5622
|
+
}
|
|
5623
|
+
return changed ? out : prev;
|
|
5624
|
+
}
|
|
5625
|
+
return next;
|
|
5626
|
+
}
|
|
5627
|
+
/**
|
|
5628
|
+
* A derived STORE, the store-shaped counterpart to `computed`. `fn` receives a mutable draft seeded
|
|
5629
|
+
* with the current value and either mutates it in place or returns a new value; whichever it does,
|
|
5630
|
+
* the result is reconciled against the previous value (see {@link reconcile}) so unchanged subtrees
|
|
5631
|
+
* keep reference identity and keyed array items keep their proxy identity. Reading through the
|
|
5632
|
+
* returned store is fine-grained: a `computed` over one field only recomputes when that field
|
|
5633
|
+
* actually changes, even though the whole projection re-ran.
|
|
5634
|
+
*
|
|
5635
|
+
* Recompute is pull-based, exactly like `computed`: the projection is memoized and re-runs on the
|
|
5636
|
+
* first read after a signal `fn` depends on changes, so reads are always coherent (no waiting on an
|
|
5637
|
+
* effect flush) and nothing recomputes while nobody reads. `fn` must be pure, it runs inside the
|
|
5638
|
+
* reactive computation. Prefer `computed` for a plain value; reach for `projection` when you want
|
|
5639
|
+
* the per-property tracking of a store on top of a derivation.
|
|
5640
|
+
*
|
|
5641
|
+
* ```ts
|
|
5642
|
+
* const active = projection<User[]>(() => users().filter((u) => u.active), [], { key: 'id' });
|
|
5643
|
+
* // active[0].name(); — surviving users keep identity across recomputes
|
|
5644
|
+
* ```
|
|
5645
|
+
*
|
|
5646
|
+
* Needs an injection context (or an explicit `injector`) for the store layer's cleanup on the main
|
|
5647
|
+
* thread; with an explicit store context (`createStoreContext()`) it is injector-free, so it also
|
|
5648
|
+
* runs on a worker host.
|
|
5649
|
+
*
|
|
5650
|
+
* @param fn receives the current draft; mutate it, or return new data.
|
|
5651
|
+
* @param seed the initial value, held before the first run.
|
|
5652
|
+
*/
|
|
5653
|
+
function projection(fn, seed, opt) {
|
|
5654
|
+
const { key = 'id', ...storeOpt } = opt ?? {};
|
|
5655
|
+
const root = linkedSignal({ ...(ngDevMode ? { debugName: "root" } : /* istanbul ignore next */ {}), source: () => undefined,
|
|
5656
|
+
computation: (_, previous) => {
|
|
5657
|
+
const base = previous ? previous.value : seed;
|
|
5658
|
+
const draft = structuredClone(base);
|
|
5659
|
+
const returned = fn(draft);
|
|
5660
|
+
const next = (returned === undefined ? draft : returned);
|
|
5661
|
+
return reconcile(base, next, key);
|
|
5662
|
+
} });
|
|
5663
|
+
return toStore(root, storeOpt).asReadonlyStore();
|
|
5664
|
+
}
|
|
5665
|
+
|
|
4785
5666
|
/**
|
|
4786
5667
|
* @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
|
|
4787
5668
|
* `nestedEffect`). For infra utilities that own a single top-level effect/subscription and don't
|
|
@@ -4969,6 +5850,85 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
4969
5850
|
return writable;
|
|
4970
5851
|
}
|
|
4971
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
|
+
}
|
|
4972
5932
|
class MessageBus {
|
|
4973
5933
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
4974
5934
|
listeners = new Map();
|
|
@@ -5080,6 +6040,20 @@ function tabSync(sig, opt) {
|
|
|
5080
6040
|
return sig;
|
|
5081
6041
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
5082
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
|
+
}
|
|
5083
6057
|
const NONE = Symbol();
|
|
5084
6058
|
let received = NONE;
|
|
5085
6059
|
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
@@ -5307,5 +6281,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
5307
6281
|
* Generated bundle index. Do not edit.
|
|
5308
6282
|
*/
|
|
5309
6283
|
|
|
5310
|
-
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 };
|
|
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 };
|
|
5311
6285
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|