@mmstack/primitives 22.5.1 → 22.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 +1157 -359
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-primitives.d.ts +502 -114
|
@@ -209,8 +209,7 @@ class MmActivity {
|
|
|
209
209
|
if (this.onServer)
|
|
210
210
|
return;
|
|
211
211
|
for (const node of this.view.rootNodes) {
|
|
212
|
-
// covers HTML and SVG roots; text/comment roots can't be styled
|
|
213
|
-
// detached, but prefer an element root for true visual hiding
|
|
212
|
+
// covers HTML and SVG roots; text/comment roots can't be styled, their CD is still detached
|
|
214
213
|
if (node instanceof HTMLElement || node instanceof SVGElement)
|
|
215
214
|
node.style.display = visible ? '' : 'none';
|
|
216
215
|
}
|
|
@@ -228,8 +227,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
|
|
|
228
227
|
selector: '[mmActivity]',
|
|
229
228
|
}]
|
|
230
229
|
}], ctorParameters: () => [], propDecorators: { visible: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmActivity", required: true }] }] } });
|
|
231
|
-
// Shared never-paused signal returned outside a boundary / on the server (SSR renders the full tree,
|
|
232
|
-
// nothing is paused). Readonly so a consumer can't cast-and-`.set()` the shared default for everyone.
|
|
233
230
|
const NEVER_PAUSED = signal(false).asReadonly();
|
|
234
231
|
/**
|
|
235
232
|
* Inject the nearest paused-state signal — `true` while the surrounding subtree is paused (hidden by
|
|
@@ -483,7 +480,7 @@ function deferredValue(source, opt) {
|
|
|
483
480
|
let cancel = null;
|
|
484
481
|
const watch = effect(() => {
|
|
485
482
|
const v = source();
|
|
486
|
-
cancel?.();
|
|
483
|
+
cancel?.();
|
|
487
484
|
cancel = schedule(() => {
|
|
488
485
|
cancel = null;
|
|
489
486
|
out.set(v);
|
|
@@ -495,8 +492,6 @@ function deferredValue(source, opt) {
|
|
|
495
492
|
cancel = null;
|
|
496
493
|
});
|
|
497
494
|
const result = computed(() => out());
|
|
498
|
-
// "behind" is a value comparison, not a schedule flag: an equal-valued catch-up
|
|
499
|
-
// (e.g. type a char, delete it before the deferred view caught up) is not pending
|
|
500
495
|
result.pending = computed(() => !equal(out(), source()), /* @ts-ignore */
|
|
501
496
|
...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
|
|
502
497
|
return result;
|
|
@@ -521,6 +516,46 @@ function resolveScheduler(strategy, injector) {
|
|
|
521
516
|
};
|
|
522
517
|
}
|
|
523
518
|
|
|
519
|
+
const CONCURRENCY_INSTRUMENTATION = new InjectionToken('@mmstack/primitives:concurrency-instrumentation');
|
|
520
|
+
function provideConcurrencyInstrumentation(listener) {
|
|
521
|
+
return { provide: CONCURRENCY_INSTRUMENTATION, useValue: listener };
|
|
522
|
+
}
|
|
523
|
+
const now = () => typeof globalThis.performance !== 'undefined'
|
|
524
|
+
? globalThis.performance.now()
|
|
525
|
+
: Date.now();
|
|
526
|
+
/**
|
|
527
|
+
* Chrome DevTools "Performance" custom-tracks preset (idea/concurrency-devtools.md): writes a
|
|
528
|
+
* `performance.measure` for each pending/transaction window onto an "mmstack" extension track,
|
|
529
|
+
* so reactive coordination shows up on the Performance panel timeline. Dev-only, zero backend,
|
|
530
|
+
* no dependencies. Give each measure the scope name for readability.
|
|
531
|
+
*/
|
|
532
|
+
function perfCustomTracks(track = 'mmstack concurrency') {
|
|
533
|
+
const canMeasure = typeof globalThis.performance !== 'undefined' &&
|
|
534
|
+
typeof globalThis.performance.measure === 'function';
|
|
535
|
+
const span = (name, start) => {
|
|
536
|
+
if (!canMeasure)
|
|
537
|
+
return;
|
|
538
|
+
try {
|
|
539
|
+
globalThis.performance.measure(name, {
|
|
540
|
+
start,
|
|
541
|
+
end: now(),
|
|
542
|
+
detail: {
|
|
543
|
+
devtools: { dataType: 'track-entry', track, color: 'primary' },
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// measure options with detail are unsupported on this engine — skip silently
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
return {
|
|
552
|
+
pendingStart: (e) => e.at,
|
|
553
|
+
pendingEnd: (handle, e) => span(`pending`, handle ?? e.at),
|
|
554
|
+
transactionStart: (e) => e.at,
|
|
555
|
+
transactionEnd: (handle, e) => span(`transaction`, handle ?? e.at),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
524
559
|
/**
|
|
525
560
|
* Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
|
|
526
561
|
* subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
|
|
@@ -595,8 +630,13 @@ function isMutable(value) {
|
|
|
595
630
|
return 'mutate' in value && typeof value.mutate === 'function';
|
|
596
631
|
}
|
|
597
632
|
|
|
598
|
-
function createTransitionScope() {
|
|
633
|
+
function createTransitionScope(opt) {
|
|
599
634
|
const list = mutable([]);
|
|
635
|
+
const inst = opt?.instrumentation;
|
|
636
|
+
const name = opt?.name ?? 'scope';
|
|
637
|
+
const at = () => typeof globalThis.performance !== 'undefined'
|
|
638
|
+
? globalThis.performance.now()
|
|
639
|
+
: Date.now();
|
|
600
640
|
const pending = computed(() => list().some(({ ref }) => {
|
|
601
641
|
const s = ref.status();
|
|
602
642
|
return s === 'loading' || s === 'reloading';
|
|
@@ -610,11 +650,17 @@ function createTransitionScope() {
|
|
|
610
650
|
resources: computed(() => list().map((e) => e.ref)),
|
|
611
651
|
pending,
|
|
612
652
|
suspended: (type) => list().some(({ ref, suspends }) => suspends && (type === 'loading' ? ref.isLoading() : !ref.hasValue())),
|
|
613
|
-
add: (ref,
|
|
653
|
+
add: (ref, o) => untracked(() => {
|
|
654
|
+
const suspends = o?.suspends ?? true;
|
|
655
|
+
list.inline((c) => c.push({ ref, suspends }));
|
|
656
|
+
inst?.resourceRegistered?.({ scope: name, suspends });
|
|
657
|
+
}),
|
|
614
658
|
remove: (ref) => untracked(() => list.inline((c) => {
|
|
615
659
|
const i = c.findIndex((e) => e.ref === ref);
|
|
616
|
-
if (i !== -1)
|
|
660
|
+
if (i !== -1) {
|
|
617
661
|
c.splice(i, 1);
|
|
662
|
+
inst?.resourceRemoved?.({ scope: name });
|
|
663
|
+
}
|
|
618
664
|
})),
|
|
619
665
|
commit: (value) => linkedSignal({
|
|
620
666
|
source: () => ({ v: value(), settled: !pending() }),
|
|
@@ -629,6 +675,8 @@ function createTransitionScope() {
|
|
|
629
675
|
aborted++;
|
|
630
676
|
}
|
|
631
677
|
}
|
|
678
|
+
if (aborted > 0)
|
|
679
|
+
inst?.abortPending?.({ scope: name, aborted, at: at() });
|
|
632
680
|
return aborted;
|
|
633
681
|
}),
|
|
634
682
|
holding,
|
|
@@ -697,13 +745,59 @@ function bridgeScopeToPendingTasks(scope, injector) {
|
|
|
697
745
|
});
|
|
698
746
|
});
|
|
699
747
|
}
|
|
748
|
+
/**
|
|
749
|
+
* While a listener is installed, bracket each pending window of `scope` with a
|
|
750
|
+
* `pendingStart`/`pendingEnd` span (the reactive tap that needs an injection context). No-op
|
|
751
|
+
* when no listener is provided, so it stays zero-cost by default.
|
|
752
|
+
*/
|
|
753
|
+
function bridgeScopeToInstrumentation(scope, name, injector) {
|
|
754
|
+
const run = (fn) => injector ? runInInjectionContext(injector, fn) : fn();
|
|
755
|
+
run(() => {
|
|
756
|
+
const inst = inject(CONCURRENCY_INSTRUMENTATION, { optional: true });
|
|
757
|
+
if (!inst?.pendingStart && !inst?.pendingEnd)
|
|
758
|
+
return;
|
|
759
|
+
const at = () => typeof globalThis.performance !== 'undefined'
|
|
760
|
+
? globalThis.performance.now()
|
|
761
|
+
: Date.now();
|
|
762
|
+
let handle;
|
|
763
|
+
let open = false;
|
|
764
|
+
effect(() => {
|
|
765
|
+
const pending = scope.pending();
|
|
766
|
+
untracked(() => {
|
|
767
|
+
if (pending && !open) {
|
|
768
|
+
open = true;
|
|
769
|
+
handle = inst.pendingStart?.({
|
|
770
|
+
scope: name,
|
|
771
|
+
resources: scope.resources().length,
|
|
772
|
+
at: at(),
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
else if (!pending && open) {
|
|
776
|
+
open = false;
|
|
777
|
+
inst.pendingEnd?.(handle, { at: at() });
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
inject(DestroyRef).onDestroy(() => {
|
|
782
|
+
if (open)
|
|
783
|
+
inst.pendingEnd?.(handle, { at: at() });
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
}
|
|
700
787
|
/** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
|
|
701
|
-
function provideTransitionScope() {
|
|
788
|
+
function provideTransitionScope(opt) {
|
|
702
789
|
return {
|
|
703
790
|
provide: TRANSITION_SCOPE,
|
|
704
791
|
useFactory: () => {
|
|
705
|
-
const
|
|
792
|
+
const listener = opt?.instrumentation ??
|
|
793
|
+
inject(CONCURRENCY_INSTRUMENTATION, { optional: true }) ??
|
|
794
|
+
undefined;
|
|
795
|
+
const scope = createTransitionScope({
|
|
796
|
+
name: opt?.name,
|
|
797
|
+
instrumentation: listener,
|
|
798
|
+
});
|
|
706
799
|
bridgeScopeToPendingTasks(scope);
|
|
800
|
+
bridgeScopeToInstrumentation(scope, opt?.name ?? 'scope');
|
|
707
801
|
return scope;
|
|
708
802
|
},
|
|
709
803
|
};
|
|
@@ -723,7 +817,11 @@ function createForwardingScope() {
|
|
|
723
817
|
const target = signal(null, /* @ts-ignore */
|
|
724
818
|
...(ngDevMode ? [{ debugName: "target" }] : /* istanbul ignore next */ []));
|
|
725
819
|
const eff = () => target() ?? own;
|
|
726
|
-
|
|
820
|
+
// WeakMap, deliberately: the forwarder usually outlives its targets (an outlet
|
|
821
|
+
// re-pointing at per-route scopes). If a registrant ever misses its `remove`,
|
|
822
|
+
// ephemeron semantics let the ref↔dead-target cycle collect once the registrant
|
|
823
|
+
// drops the ref, instead of this map pinning every stranded pair forever.
|
|
824
|
+
const owners = new WeakMap();
|
|
727
825
|
return {
|
|
728
826
|
setTarget: (t) => target.set(t),
|
|
729
827
|
resources: computed(() => eff().resources()),
|
|
@@ -852,8 +950,6 @@ function use(res) {
|
|
|
852
950
|
frame.seen.add(res);
|
|
853
951
|
frame.deps.push(res);
|
|
854
952
|
}
|
|
855
|
-
// status() is read tracked even on the short-circuit paths, so the owning computed
|
|
856
|
-
// re-evaluates when the load settles / the error clears.
|
|
857
953
|
if (res.status() === 'error') {
|
|
858
954
|
frame.errors.push(res.error?.());
|
|
859
955
|
throw BLOCKED;
|
|
@@ -901,9 +997,6 @@ function latest(fn, opt) {
|
|
|
901
997
|
}
|
|
902
998
|
}, opt?.debugName ? { debugName: `${opt.debugName}:evaluation` } : undefined);
|
|
903
999
|
const equal = opt?.equal ?? Object.is;
|
|
904
|
-
// The stale-while-revalidate atom: holds the last successful result through blocked /
|
|
905
|
-
// errored rounds. `equal` gates notification, so an in-flight cycle that lands on an
|
|
906
|
-
// equal value never ripples to consumers — while `pending` (independent) still cycles.
|
|
907
1000
|
const held = linkedSignal({ ...(ngDevMode ? { debugName: "held" } : /* istanbul ignore next */ {}), source: evaluation,
|
|
908
1001
|
computation: (ev, prev) => ev.kind === 'value'
|
|
909
1002
|
? { has: true, v: ev.value }
|
|
@@ -972,8 +1065,7 @@ function injectStartTransition() {
|
|
|
972
1065
|
const destroyRef = inject(DestroyRef);
|
|
973
1066
|
const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
|
|
974
1067
|
return (fn) => {
|
|
975
|
-
// attributed: loads already in flight when the transition starts are not ours
|
|
976
|
-
// they can neither settle this transition early nor block it forever
|
|
1068
|
+
// attributed: loads already in flight when the transition starts are not ours
|
|
977
1069
|
const pending = createAttributedPending(scope);
|
|
978
1070
|
untracked(fn);
|
|
979
1071
|
let sawPending = false;
|
|
@@ -998,8 +1090,7 @@ function injectStartTransition() {
|
|
|
998
1090
|
settle();
|
|
999
1091
|
return;
|
|
1000
1092
|
}
|
|
1001
|
-
// no-async fallback: once the reactive system has processed the writes
|
|
1002
|
-
// if nothing ever went in flight, the transition is already complete.
|
|
1093
|
+
// no-async fallback: once the reactive system has processed the writes,
|
|
1003
1094
|
afterNextRender(() => {
|
|
1004
1095
|
if (!sawPending && !untracked(pending))
|
|
1005
1096
|
settle();
|
|
@@ -1106,9 +1197,6 @@ function createTransaction() {
|
|
|
1106
1197
|
clear: () => log.clear(),
|
|
1107
1198
|
};
|
|
1108
1199
|
}
|
|
1109
|
-
// The currently-active transaction, set only for the synchronous duration of a `startTransaction`
|
|
1110
|
-
// body (so stateful actions running inside it can record their writes). Module-level + sync
|
|
1111
|
-
// set/reset is the honest shape: a transaction is call-scoped, not structural-per-injector.
|
|
1112
1200
|
let active = null;
|
|
1113
1201
|
/** The transaction in effect right now, or `null`. Stateful actions consult this to record undo. */
|
|
1114
1202
|
function activeTransaction() {
|
|
@@ -1147,10 +1235,7 @@ function injectStartTransaction() {
|
|
|
1147
1235
|
const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
|
|
1148
1236
|
return (fn) => {
|
|
1149
1237
|
const txn = createTransaction();
|
|
1150
|
-
// attributed: loads already in flight when the transaction starts are not ours —
|
|
1151
|
-
// they can neither commit this transaction early nor block its settle forever
|
|
1152
1238
|
const pending = createAttributedPending(scope);
|
|
1153
|
-
// Hold BEFORE the writes, so the display freezes at pre-transaction values.
|
|
1154
1239
|
scope.beginHold();
|
|
1155
1240
|
let finished = false;
|
|
1156
1241
|
// eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
|
|
@@ -1159,9 +1244,6 @@ function injectStartTransaction() {
|
|
|
1159
1244
|
const done = new Promise((resolve) => {
|
|
1160
1245
|
resolveDone = resolve;
|
|
1161
1246
|
});
|
|
1162
|
-
// Every exit path funnels through here, so `done` always settles — including `abort()`
|
|
1163
|
-
// and a throwing transaction body (which would otherwise leak the hold forever and
|
|
1164
|
-
// freeze the boundary with no recovery).
|
|
1165
1247
|
const finish = (restore) => {
|
|
1166
1248
|
if (finished)
|
|
1167
1249
|
return;
|
|
@@ -1175,9 +1257,6 @@ function injectStartTransaction() {
|
|
|
1175
1257
|
scope.endHold();
|
|
1176
1258
|
resolveDone();
|
|
1177
1259
|
};
|
|
1178
|
-
// The scope may outlive the calling context (a component transacting on an ancestor
|
|
1179
|
-
// boundary): a destroy mid-flight kills the settle watcher, so without this the hold
|
|
1180
|
-
// would leak and freeze the surviving scope forever. Keep the writes — they landed live.
|
|
1181
1260
|
const releaseDestroy = destroyRef.onDestroy(() => finish(false));
|
|
1182
1261
|
try {
|
|
1183
1262
|
runInTransaction(txn, fn);
|
|
@@ -1199,7 +1278,7 @@ function injectStartTransaction() {
|
|
|
1199
1278
|
finish(false);
|
|
1200
1279
|
}
|
|
1201
1280
|
else {
|
|
1202
|
-
// no-async fallback
|
|
1281
|
+
// no-async fallback
|
|
1203
1282
|
afterNextRender(() => {
|
|
1204
1283
|
if (!sawPending && !untracked(pending))
|
|
1205
1284
|
finish(false);
|
|
@@ -1276,7 +1355,6 @@ class MmTransition {
|
|
|
1276
1355
|
}
|
|
1277
1356
|
onValue(v) {
|
|
1278
1357
|
if (!this.current) {
|
|
1279
|
-
// first render: nothing to hold yet — show immediately (also what SSR serializes)
|
|
1280
1358
|
this.current = this.createView(v).view;
|
|
1281
1359
|
return;
|
|
1282
1360
|
}
|
|
@@ -1290,8 +1368,7 @@ class MmTransition {
|
|
|
1290
1368
|
const { view, scope } = this.createView(v);
|
|
1291
1369
|
this.setHidden(view, true);
|
|
1292
1370
|
this.holding.set(true);
|
|
1293
|
-
// Registration happens synchronously during view creation, so a resource already
|
|
1294
|
-
// flight counts from the start; later kickoffs are caught by the watcher.
|
|
1371
|
+
// Registration happens synchronously during view creation, so a resource already incl. later kickoffs are caught by the watcher.
|
|
1295
1372
|
let sawPending = untracked(scope.pending);
|
|
1296
1373
|
const watcher = effect(() => {
|
|
1297
1374
|
const pending = scope.pending();
|
|
@@ -1348,8 +1425,6 @@ class MmTransition {
|
|
|
1348
1425
|
this.holding.set(false);
|
|
1349
1426
|
}
|
|
1350
1427
|
createView(v) {
|
|
1351
|
-
// Each view gets its own scope, so its subtree's resources register here by existing —
|
|
1352
|
-
// and the outgoing view's background work can't block the swap (per-view isolation).
|
|
1353
1428
|
const injector = Injector.create({
|
|
1354
1429
|
parent: this.parent,
|
|
1355
1430
|
providers: [provideTransitionScope()],
|
|
@@ -1436,7 +1511,7 @@ function getSignalEquality(sig) {
|
|
|
1436
1511
|
if (internal && typeof internal.equal === 'function') {
|
|
1437
1512
|
return internal.equal;
|
|
1438
1513
|
}
|
|
1439
|
-
return Object.is;
|
|
1514
|
+
return Object.is;
|
|
1440
1515
|
}
|
|
1441
1516
|
|
|
1442
1517
|
/**
|
|
@@ -1585,8 +1660,6 @@ function isIndexProp(prop) {
|
|
|
1585
1660
|
return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
|
|
1586
1661
|
}
|
|
1587
1662
|
|
|
1588
|
-
// Container resolvers used by createVivify: each returns the current value when present and
|
|
1589
|
-
// only creates a new container when it is null/undefined.
|
|
1590
1663
|
function identity(x) {
|
|
1591
1664
|
return x;
|
|
1592
1665
|
}
|
|
@@ -1744,11 +1817,6 @@ function derived(source, optOrKey, opt) {
|
|
|
1744
1817
|
cnt++;
|
|
1745
1818
|
try {
|
|
1746
1819
|
sig.update(updater);
|
|
1747
|
-
// The wrapped computed evaluates its `equal` lazily — at the next read, which would
|
|
1748
|
-
// normally happen after `cnt` has already dropped back to 0. For a reference-stable
|
|
1749
|
-
// mutation that read compares the same object to itself and the version never bumps,
|
|
1750
|
-
// so dependents are never notified. Reading here, while equality is still suppressed,
|
|
1751
|
-
// forces the recompute (and version bump) inside the mutate window.
|
|
1752
1820
|
untracked(sig);
|
|
1753
1821
|
}
|
|
1754
1822
|
finally {
|
|
@@ -1810,8 +1878,6 @@ function isDerivation(sig) {
|
|
|
1810
1878
|
|
|
1811
1879
|
function keepPrevious(src, opt) {
|
|
1812
1880
|
const mutableSrc = isWritableSignal$2(src) && isMutable(src);
|
|
1813
|
-
// For a mutable source the linkedSignal's equality must be suppressible: a forwarded
|
|
1814
|
-
// `mutate` keeps the same reference, which default equality would otherwise swallow.
|
|
1815
1881
|
let cnt = 0;
|
|
1816
1882
|
const baseEqual = opt?.equal;
|
|
1817
1883
|
const equal = mutableSrc
|
|
@@ -1824,16 +1890,11 @@ function keepPrevious(src, opt) {
|
|
|
1824
1890
|
if (isWritableSignal$2(src)) {
|
|
1825
1891
|
persisted.set = src.set;
|
|
1826
1892
|
persisted.update = src.update;
|
|
1827
|
-
// NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
|
|
1828
|
-
// source's readonly view would reintroduce the `undefined` flashes this wrapper exists
|
|
1829
|
-
// to prevent.
|
|
1830
1893
|
if (mutableSrc) {
|
|
1831
1894
|
persisted.mutate = (updater) => {
|
|
1832
1895
|
cnt++;
|
|
1833
1896
|
try {
|
|
1834
1897
|
src.mutate(updater);
|
|
1835
|
-
// force the recompute while equality is suppressed, so the reference-stable
|
|
1836
|
-
// mutation bumps the wrapper's version (see derived.ts for the same pattern)
|
|
1837
1898
|
untracked(persisted);
|
|
1838
1899
|
}
|
|
1839
1900
|
finally {
|
|
@@ -1903,8 +1964,7 @@ function indexArray(source, map, opt = {}) {
|
|
|
1903
1964
|
: toWritable(data, () => {
|
|
1904
1965
|
// noop
|
|
1905
1966
|
});
|
|
1906
|
-
// copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
|
|
1907
|
-
// (possibly shared/reused) options object
|
|
1967
|
+
// copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned (possibly shared/reused) options object
|
|
1908
1968
|
if (isWritableSignal$1(data) && isMutable(data) && !opt.equal) {
|
|
1909
1969
|
opt = {
|
|
1910
1970
|
...opt,
|
|
@@ -2603,8 +2663,7 @@ function observerSupported$1() {
|
|
|
2603
2663
|
*/
|
|
2604
2664
|
function elementSize(target, opt) {
|
|
2605
2665
|
return runInSensorContext(opt?.injector, () =>
|
|
2606
|
-
// the host-element default must resolve INSIDE the sensor context
|
|
2607
|
-
// parameter default (which would run before the injector wrapper)
|
|
2666
|
+
// the host-element default must resolve INSIDE the sensor context
|
|
2608
2667
|
createElementSize(target ?? inject(ElementRef), opt));
|
|
2609
2668
|
}
|
|
2610
2669
|
function createElementSize(target, opt) {
|
|
@@ -2743,10 +2802,7 @@ function observerSupported() {
|
|
|
2743
2802
|
* ```
|
|
2744
2803
|
*/
|
|
2745
2804
|
function elementVisibility(target, opt) {
|
|
2746
|
-
return runInSensorContext(opt?.injector, () =>
|
|
2747
|
-
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2748
|
-
// parameter default (which would run before the injector wrapper)
|
|
2749
|
-
createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2805
|
+
return runInSensorContext(opt?.injector, () => createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2750
2806
|
}
|
|
2751
2807
|
function createElementVisibility(target, opt) {
|
|
2752
2808
|
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
@@ -2819,10 +2875,7 @@ function unwrap$1(target) {
|
|
|
2819
2875
|
* ```
|
|
2820
2876
|
*/
|
|
2821
2877
|
function focusWithin(target, opt) {
|
|
2822
|
-
return runInSensorContext(opt?.injector, () =>
|
|
2823
|
-
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2824
|
-
// parameter default (which would run before the injector wrapper)
|
|
2825
|
-
createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2878
|
+
return runInSensorContext(opt?.injector, () => createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2826
2879
|
}
|
|
2827
2880
|
function createFocusWithin(target, opt) {
|
|
2828
2881
|
const debugName = opt?.debugName ?? 'focusWithin';
|
|
@@ -3306,8 +3359,6 @@ function createMousePosition(opt) {
|
|
|
3306
3359
|
}
|
|
3307
3360
|
pos.set({ x, y });
|
|
3308
3361
|
};
|
|
3309
|
-
// passive: the handler never calls preventDefault, and a non-passive touchmove on
|
|
3310
|
-
// window forces the browser to wait on JS before scrolling (scroll jank on touch)
|
|
3311
3362
|
const attach = (el) => {
|
|
3312
3363
|
const controller = new AbortController();
|
|
3313
3364
|
el.addEventListener('mousemove', updatePosition, {
|
|
@@ -3323,7 +3374,7 @@ function createMousePosition(opt) {
|
|
|
3323
3374
|
return () => controller.abort();
|
|
3324
3375
|
};
|
|
3325
3376
|
if (isSignal(target)) {
|
|
3326
|
-
//
|
|
3377
|
+
// covers viewChild case
|
|
3327
3378
|
effect((cleanup) => {
|
|
3328
3379
|
const el = resolve(target());
|
|
3329
3380
|
if (!el)
|
|
@@ -3583,7 +3634,7 @@ function createPointerDrag(opt) {
|
|
|
3583
3634
|
return;
|
|
3584
3635
|
const matched = handleSelector
|
|
3585
3636
|
? e.target?.closest?.(handleSelector)
|
|
3586
|
-
: el;
|
|
3637
|
+
: (e.target ?? el); // no selector: the pressed element itself
|
|
3587
3638
|
if (!matched)
|
|
3588
3639
|
return; // handleSelector set but pointerdown landed outside a handle
|
|
3589
3640
|
if (stopPropagation)
|
|
@@ -3773,7 +3824,6 @@ function createScrollPosition(opt) {
|
|
|
3773
3824
|
ms: throttle,
|
|
3774
3825
|
});
|
|
3775
3826
|
if (isSignal(target)) {
|
|
3776
|
-
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
3777
3827
|
effect((cleanup) => {
|
|
3778
3828
|
const el = resolve(target());
|
|
3779
3829
|
if (!el)
|
|
@@ -3967,7 +4017,6 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
3967
4017
|
state.set(event);
|
|
3968
4018
|
};
|
|
3969
4019
|
const { destroyRef: providedDestroyRef,
|
|
3970
|
-
// strip non-listener keys so they don't leak into addEventListener options
|
|
3971
4020
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3972
4021
|
injector: _injector,
|
|
3973
4022
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
@@ -3981,8 +4030,7 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
3981
4030
|
resolved.addEventListener(eventName, handler, listenerOpts);
|
|
3982
4031
|
cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
|
|
3983
4032
|
}, { ...(ngDevMode ? { debugName: "effectRef" } : /* istanbul ignore next */ {}), injector });
|
|
3984
|
-
// honor an explicit destroyRef for signal targets
|
|
3985
|
-
// only follow the injector's lifetime, contradicting the documented option
|
|
4033
|
+
// honor an explicit destroyRef for signal targets
|
|
3986
4034
|
providedDestroyRef?.onDestroy(() => effectRef.destroy());
|
|
3987
4035
|
}
|
|
3988
4036
|
else {
|
|
@@ -4218,102 +4266,325 @@ function isStore(value) {
|
|
|
4218
4266
|
value[IS_STORE] === true);
|
|
4219
4267
|
}
|
|
4220
4268
|
|
|
4269
|
+
function generateOrigin$1() {
|
|
4270
|
+
if (globalThis.crypto?.randomUUID)
|
|
4271
|
+
return globalThis.crypto.randomUUID();
|
|
4272
|
+
return Math.random().toString(36).substring(2);
|
|
4273
|
+
}
|
|
4274
|
+
const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
|
|
4221
4275
|
/**
|
|
4222
|
-
*
|
|
4223
|
-
*
|
|
4224
|
-
*
|
|
4276
|
+
* Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
|
|
4277
|
+
* an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
|
|
4278
|
+
* descends only where refs differ. O(changed paths), not O(tree).
|
|
4225
4279
|
*/
|
|
4226
|
-
function
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4280
|
+
function diffNode(prev, next, path, ops) {
|
|
4281
|
+
if (Object.is(prev, next))
|
|
4282
|
+
return;
|
|
4283
|
+
if (isRecord(prev) && isRecord(next)) {
|
|
4284
|
+
for (const key of Object.keys(prev)) {
|
|
4285
|
+
if (!Object.hasOwn(next, key))
|
|
4286
|
+
ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
|
|
4287
|
+
}
|
|
4288
|
+
for (const key of Object.keys(next)) {
|
|
4289
|
+
if (!Object.hasOwn(prev, key)) {
|
|
4290
|
+
// added key: deliberately NO `prev` property (absent ≠ undefined)
|
|
4291
|
+
ops.push({ kind: 'set', path: [...path, key], next: next[key] });
|
|
4292
|
+
}
|
|
4293
|
+
else {
|
|
4294
|
+
diffNode(prev[key], next[key], [...path, key], ops);
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
return;
|
|
4231
4298
|
}
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
if (
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4299
|
+
if (isPlainArray$1(prev) && isPlainArray$1(next)) {
|
|
4300
|
+
// same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
|
|
4301
|
+
// change is a whole unit — index attribution lies under insert/remove/reorder
|
|
4302
|
+
if (prev.length === next.length) {
|
|
4303
|
+
for (let i = 0; i < next.length; i++)
|
|
4304
|
+
diffNode(prev[i], next[i], [...path, i], ops);
|
|
4305
|
+
return;
|
|
4306
|
+
}
|
|
4307
|
+
ops.push({ kind: 'set', path, prev, next });
|
|
4308
|
+
return;
|
|
4239
4309
|
}
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4310
|
+
// leaf / type change / opaque — one unit, prev present (the slot existed)
|
|
4311
|
+
ops.push({ kind: 'set', path, prev, next });
|
|
4312
|
+
}
|
|
4313
|
+
/** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
|
|
4314
|
+
function applyAt(container, path, idx, op) {
|
|
4315
|
+
const seg = path[idx];
|
|
4316
|
+
const base = isPlainArray$1(container)
|
|
4317
|
+
? container.slice()
|
|
4318
|
+
: isRecord(container)
|
|
4319
|
+
? { ...container }
|
|
4320
|
+
: typeof seg === 'number'
|
|
4321
|
+
? []
|
|
4322
|
+
: {};
|
|
4323
|
+
if (idx === path.length - 1) {
|
|
4324
|
+
if (op.kind === 'delete') {
|
|
4325
|
+
// arrays never receive deletes (length changes travel as whole-array sets)
|
|
4326
|
+
delete base[seg];
|
|
4327
|
+
}
|
|
4328
|
+
else {
|
|
4329
|
+
base[seg] = op.next;
|
|
4330
|
+
}
|
|
4331
|
+
return base;
|
|
4332
|
+
}
|
|
4333
|
+
base[seg] = applyAt(base[seg], path, idx + 1, op);
|
|
4334
|
+
return base;
|
|
4245
4335
|
}
|
|
4246
4336
|
/**
|
|
4247
|
-
*
|
|
4248
|
-
*
|
|
4249
|
-
*
|
|
4250
|
-
*
|
|
4337
|
+
* Pure, store-free application of ops onto a plain root value, returning the next immutable root
|
|
4338
|
+
* (structural-sharing along op paths, missing containers vivified `'auto'`-style). This is the
|
|
4339
|
+
* same transform {@link OpLog.apply} runs, extracted so a replica can fold a received batch into
|
|
4340
|
+
* a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
|
|
4341
|
+
* Accepts a batch or a bare op list.
|
|
4251
4342
|
*/
|
|
4252
|
-
function
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4343
|
+
function applyOps(root, ops) {
|
|
4344
|
+
const list = Array.isArray(ops) ? ops : ops.ops;
|
|
4345
|
+
let next = root;
|
|
4346
|
+
for (const op of list) {
|
|
4347
|
+
if (op.path.length === 0) {
|
|
4348
|
+
if (op.kind === 'set')
|
|
4349
|
+
next = op.next;
|
|
4350
|
+
continue; // a root delete is meaningless — ignore (mirrors OpLog.apply)
|
|
4351
|
+
}
|
|
4352
|
+
next = applyAt(next, op.path, 0, op);
|
|
4353
|
+
}
|
|
4354
|
+
return next;
|
|
4256
4355
|
}
|
|
4257
4356
|
/**
|
|
4258
|
-
*
|
|
4259
|
-
*
|
|
4260
|
-
*
|
|
4261
|
-
*
|
|
4357
|
+
* Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
|
|
4358
|
+
* {@link opLog}, exported so code outside a log can produce a batch — e.g. diffing a scratch
|
|
4359
|
+
* draft against a replica's current value to route a write to its owner). Trusts the
|
|
4360
|
+
* copy-on-write contract: an untouched subtree that kept its reference is skipped.
|
|
4262
4361
|
*/
|
|
4263
|
-
function
|
|
4264
|
-
const
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
const equalFn = isMutableSource && (isRecord(value) || Array.isArray(value))
|
|
4268
|
-
? mutableChildEqual
|
|
4269
|
-
: undefined;
|
|
4270
|
-
const computation = derived(target, {
|
|
4271
|
-
from: (v) => v?.[prop],
|
|
4272
|
-
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
4273
|
-
equal: equalFn,
|
|
4274
|
-
});
|
|
4275
|
-
const childSample = untracked(computation);
|
|
4276
|
-
const childVivify = resolveVivify(childSample, options.vivify);
|
|
4277
|
-
const proxy = toStore(computation, options);
|
|
4278
|
-
markAsLeaf(proxy, computation, childVivify !== false, options.noUnionLeaves);
|
|
4279
|
-
return proxy;
|
|
4362
|
+
function diffOps(prev, next) {
|
|
4363
|
+
const ops = [];
|
|
4364
|
+
diffNode(prev, next, [], ops);
|
|
4365
|
+
return ops;
|
|
4280
4366
|
}
|
|
4281
4367
|
/**
|
|
4282
|
-
*
|
|
4283
|
-
*
|
|
4368
|
+
* Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
|
|
4369
|
+
* `prev` — inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
|
|
4370
|
+
* result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
|
|
4371
|
+
* carry — a wire-serialized batch that stripped them is not invertible.
|
|
4372
|
+
*/
|
|
4373
|
+
function invertBatch(batch) {
|
|
4374
|
+
const ops = Array.isArray(batch) ? batch : batch.ops;
|
|
4375
|
+
const inverted = [];
|
|
4376
|
+
for (let i = ops.length - 1; i >= 0; i--) {
|
|
4377
|
+
const op = ops[i];
|
|
4378
|
+
if (op.kind === 'delete') {
|
|
4379
|
+
inverted.push({
|
|
4380
|
+
kind: 'set',
|
|
4381
|
+
path: op.path,
|
|
4382
|
+
next: op.prev,
|
|
4383
|
+
prev: undefined,
|
|
4384
|
+
});
|
|
4385
|
+
continue;
|
|
4386
|
+
}
|
|
4387
|
+
if (!Object.hasOwn(op, 'prev')) {
|
|
4388
|
+
inverted.push({ kind: 'delete', path: op.path, prev: op.next });
|
|
4389
|
+
}
|
|
4390
|
+
else {
|
|
4391
|
+
inverted.push({
|
|
4392
|
+
kind: 'set',
|
|
4393
|
+
path: op.path,
|
|
4394
|
+
next: op.prev,
|
|
4395
|
+
prev: op.next,
|
|
4396
|
+
});
|
|
4397
|
+
}
|
|
4398
|
+
}
|
|
4399
|
+
return inverted;
|
|
4400
|
+
}
|
|
4401
|
+
/**
|
|
4402
|
+
* Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
|
|
4403
|
+
* immutably-updated objects) and emits its changes as minimal structural op batches — the
|
|
4404
|
+
* shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
|
|
4405
|
+
* batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
|
|
4284
4406
|
*
|
|
4285
|
-
*
|
|
4286
|
-
*
|
|
4287
|
-
*
|
|
4288
|
-
*
|
|
4289
|
-
* its new kind on the next access, while child proxies cached under the old shape go stale and
|
|
4290
|
-
* are pruned by the GC.
|
|
4407
|
+
* Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
|
|
4408
|
+
* of the root value per tick (structural sharing makes it O(changed paths)), driven by one
|
|
4409
|
+
* effect. A batch therefore coalesces everything written in one tick — for coarser,
|
|
4410
|
+
* intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
|
|
4291
4411
|
*
|
|
4292
|
-
*
|
|
4293
|
-
*
|
|
4294
|
-
*
|
|
4412
|
+
* NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
|
|
4413
|
+
* defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
|
|
4414
|
+
* warning fires and nothing emits.
|
|
4415
|
+
*
|
|
4416
|
+
* ```ts
|
|
4417
|
+
* const s = store({ todos: [{ done: false }] });
|
|
4418
|
+
* const log = opLog(s, { origin: 'tab-a' });
|
|
4419
|
+
* log.subscribe((b) => channel.postMessage(encode(b))); // ship
|
|
4420
|
+
* channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
|
|
4421
|
+
* s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
|
|
4422
|
+
* ```
|
|
4295
4423
|
*/
|
|
4296
|
-
function
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
const
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
const
|
|
4314
|
-
if (
|
|
4315
|
-
return
|
|
4316
|
-
|
|
4424
|
+
function opLog(source, opt) {
|
|
4425
|
+
const origin = opt?.origin ?? generateOrigin$1();
|
|
4426
|
+
const storeKind = source[STORE_KIND];
|
|
4427
|
+
const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
|
|
4428
|
+
if (isDevMode() && mutableSource) {
|
|
4429
|
+
console.warn('[@mmstack/primitives] opLog observes copy-on-write updates via reference identity — a MUTABLE store/signal mutates in place, so changes are invisible to it. Use an immutable store, or set whole values.');
|
|
4430
|
+
}
|
|
4431
|
+
let prevRoot = untracked(source);
|
|
4432
|
+
let version = 0;
|
|
4433
|
+
let destroyed = false;
|
|
4434
|
+
const subscribers = new Set();
|
|
4435
|
+
const latest = signal(null, /* @ts-ignore */
|
|
4436
|
+
...(ngDevMode ? [{ debugName: "latest" }] : /* istanbul ignore next */ []));
|
|
4437
|
+
/** Diff now, emit if there's a delta, advance the baseline. */
|
|
4438
|
+
const flush = () => {
|
|
4439
|
+
if (destroyed)
|
|
4440
|
+
return;
|
|
4441
|
+
const next = untracked(source);
|
|
4442
|
+
if (Object.is(prevRoot, next))
|
|
4443
|
+
return;
|
|
4444
|
+
const ops = [];
|
|
4445
|
+
diffNode(prevRoot, next, [], ops);
|
|
4446
|
+
prevRoot = next;
|
|
4447
|
+
if (!ops.length)
|
|
4448
|
+
return; // fresh refs, equal values — spurious-write tolerance
|
|
4449
|
+
const batch = { origin, version: ++version, ops };
|
|
4450
|
+
latest.set(batch);
|
|
4451
|
+
for (const cb of [...subscribers])
|
|
4452
|
+
cb(batch);
|
|
4453
|
+
};
|
|
4454
|
+
const run = () => {
|
|
4455
|
+
source(); // track every commit…
|
|
4456
|
+
untracked(flush); // …and emit the delta since the last flush
|
|
4457
|
+
};
|
|
4458
|
+
// default driver is an Angular effect (needs an injector); a supplied driver runs injector-free
|
|
4459
|
+
// (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
|
|
4460
|
+
const ref = opt?.driver
|
|
4461
|
+
? opt.driver(run)
|
|
4462
|
+
: effect(run, { injector: opt?.injector ?? inject(Injector) });
|
|
4463
|
+
return {
|
|
4464
|
+
latest: latest.asReadonly(),
|
|
4465
|
+
subscribe: (cb) => {
|
|
4466
|
+
subscribers.add(cb);
|
|
4467
|
+
return () => subscribers.delete(cb);
|
|
4468
|
+
},
|
|
4469
|
+
// the emission core, callable on demand — reads the source untracked, so it never disturbs the
|
|
4470
|
+
// driver's subscription; a subsequent scheduled run just finds the baseline already advanced
|
|
4471
|
+
flush: () => flush(),
|
|
4472
|
+
apply: (batchOrOps) => {
|
|
4473
|
+
const ops = Array.isArray(batchOrOps)
|
|
4474
|
+
? batchOrOps
|
|
4475
|
+
: batchOrOps.ops;
|
|
4476
|
+
if (!ops.length)
|
|
4477
|
+
return;
|
|
4478
|
+
// pending local writes must emit BEFORE the baseline advances past them
|
|
4479
|
+
flush();
|
|
4480
|
+
const root = applyOps(untracked(source), ops); // one atomic root, structural-shared
|
|
4481
|
+
source.set(root);
|
|
4482
|
+
prevRoot = root; // baseline advance: an applied batch never echoes
|
|
4483
|
+
},
|
|
4484
|
+
destroy: () => {
|
|
4485
|
+
destroyed = true;
|
|
4486
|
+
subscribers.clear();
|
|
4487
|
+
ref.destroy();
|
|
4488
|
+
},
|
|
4489
|
+
};
|
|
4490
|
+
}
|
|
4491
|
+
|
|
4492
|
+
/**
|
|
4493
|
+
* @internal Reads (or lazily builds + caches) the child node proxy for `prop` on `target`,
|
|
4494
|
+
* holding it via a `WeakRef` and registering it for finalizer-driven cache pruning. The cache
|
|
4495
|
+
* is keyed per backing signal, so child identity is stable across repeat reads.
|
|
4496
|
+
*/
|
|
4497
|
+
function getCachedChild(target, prop, build, cache, cleanupRegistry) {
|
|
4498
|
+
let storeCache = cache.get(target);
|
|
4499
|
+
if (!storeCache) {
|
|
4500
|
+
storeCache = new Map();
|
|
4501
|
+
cache.set(target, storeCache);
|
|
4502
|
+
}
|
|
4503
|
+
const cachedRef = storeCache.get(prop);
|
|
4504
|
+
if (cachedRef) {
|
|
4505
|
+
const cached = cachedRef.deref();
|
|
4506
|
+
if (cached)
|
|
4507
|
+
return cached;
|
|
4508
|
+
storeCache.delete(prop);
|
|
4509
|
+
cleanupRegistry.unregister(cachedRef);
|
|
4510
|
+
}
|
|
4511
|
+
const proxy = build();
|
|
4512
|
+
const ref = new WeakRef(proxy);
|
|
4513
|
+
storeCache.set(prop, ref);
|
|
4514
|
+
cleanupRegistry.register(proxy, { target, prop }, ref);
|
|
4515
|
+
return proxy;
|
|
4516
|
+
}
|
|
4517
|
+
/**
|
|
4518
|
+
* @internal Whether a mutable parent's child value must always re-notify: in-place mutation
|
|
4519
|
+
* keeps an object child's reference stable, so `Object.is` would swallow the change. Decided
|
|
4520
|
+
* per-VALUE (not snapshotted at build) so a union child that becomes an object later still
|
|
4521
|
+
* propagates parent-level mutations.
|
|
4522
|
+
*/
|
|
4523
|
+
function mutableChildEqual(a, b) {
|
|
4524
|
+
if (typeof a === 'object' && a !== null)
|
|
4525
|
+
return false;
|
|
4526
|
+
return Object.is(a, b);
|
|
4527
|
+
}
|
|
4528
|
+
/**
|
|
4529
|
+
* @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
|
|
4530
|
+
* Both the read (`v?.[prop]`) and the write (`createFallbackOnChange` copies by the container's
|
|
4531
|
+
* LIVE shape) are shape-adaptive, so a child cached before an array↔record↔null union flip stays
|
|
4532
|
+
* correct after it. The only place a child node is constructed — shared by every container kind.
|
|
4533
|
+
*/
|
|
4534
|
+
function buildChildNode(target, prop, isMutableSource, options) {
|
|
4535
|
+
const value = untracked(target);
|
|
4536
|
+
const nodeVivify = resolveVivify(value, options.vivify);
|
|
4537
|
+
const vivifyFn = createVivify(nodeVivify);
|
|
4538
|
+
const equalFn = isMutableSource && (isRecord(value) || Array.isArray(value))
|
|
4539
|
+
? mutableChildEqual
|
|
4540
|
+
: undefined;
|
|
4541
|
+
const computation = derived(target, {
|
|
4542
|
+
from: (v) => v?.[prop],
|
|
4543
|
+
onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
|
|
4544
|
+
equal: equalFn,
|
|
4545
|
+
});
|
|
4546
|
+
const childSample = untracked(computation);
|
|
4547
|
+
const childVivify = resolveVivify(childSample, options.vivify);
|
|
4548
|
+
const proxy = toStore(computation, options);
|
|
4549
|
+
markAsLeaf(proxy, computation, childVivify !== false, options.noUnionLeaves);
|
|
4550
|
+
return proxy;
|
|
4551
|
+
}
|
|
4552
|
+
/**
|
|
4553
|
+
* Converts a Signal into a deep-observable Store.
|
|
4554
|
+
* Accessing nested properties returns a derived Signal of that path.
|
|
4555
|
+
*
|
|
4556
|
+
* @remarks
|
|
4557
|
+
* A node's *container kind* (array / record / primitive) is tracked reactively via a per-node
|
|
4558
|
+
* `kind` computed, so the same proxy serves all three and a union node that flips between an
|
|
4559
|
+
* array and a record keeps working. Flips are route-forward: after a flip the node behaves as
|
|
4560
|
+
* its new kind on the next access, while child proxies cached under the old shape go stale and
|
|
4561
|
+
* are pruned by the GC.
|
|
4562
|
+
*
|
|
4563
|
+
* @example
|
|
4564
|
+
* const state = store({ user: { name: 'John' } });
|
|
4565
|
+
* const nameSignal = state.user.name; // WritableSignal<string>
|
|
4566
|
+
*/
|
|
4567
|
+
function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...rest } = {}) {
|
|
4568
|
+
if (isStore(source))
|
|
4569
|
+
return source;
|
|
4570
|
+
// injector is needed ONLY to resolve the two proxy-globals tokens; if a caller supplies the
|
|
4571
|
+
// globals directly (createStoreContext — the worker-side seam with no DI), skip inject entirely
|
|
4572
|
+
const sharedGlobals = rest[STORE_SHARED_GLOBALS];
|
|
4573
|
+
const hasSharedGlobals = !!(sharedGlobals?.cache && sharedGlobals?.registry);
|
|
4574
|
+
if (!injector && !hasSharedGlobals)
|
|
4575
|
+
injector = inject(Injector);
|
|
4576
|
+
const writableSource = isWritableSignal(source)
|
|
4577
|
+
? source
|
|
4578
|
+
: toWritable(source, () => {
|
|
4579
|
+
// noop
|
|
4580
|
+
});
|
|
4581
|
+
const isWritableSource = isWritableSignal(source);
|
|
4582
|
+
const isMutableSource = isWritableSource && isMutable(writableSource);
|
|
4583
|
+
const kind = computed(() => {
|
|
4584
|
+
const v = source();
|
|
4585
|
+
if (Array.isArray(v) && !isOpaque(v))
|
|
4586
|
+
return 'array';
|
|
4587
|
+
if (isRecord(v))
|
|
4317
4588
|
return 'record';
|
|
4318
4589
|
return 'primitive';
|
|
4319
4590
|
}, /* @ts-ignore */
|
|
@@ -4325,8 +4596,7 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4325
4596
|
vivify,
|
|
4326
4597
|
noUnionLeaves,
|
|
4327
4598
|
[STORE_SHARED_GLOBALS]: {
|
|
4328
|
-
// the `injector!` reads run only when a global is absent
|
|
4329
|
-
// an injector was resolved above
|
|
4599
|
+
// the `injector!` reads run only when a global is absent
|
|
4330
4600
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
4331
4601
|
cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
|
|
4332
4602
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
@@ -4406,8 +4676,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4406
4676
|
injector,
|
|
4407
4677
|
vivify,
|
|
4408
4678
|
noUnionLeaves,
|
|
4409
|
-
// forward the resolved globals — re-resolving from the injector both re-injects
|
|
4410
|
-
// needlessly and breaks in DI-less (worker) mode where injector is undefined
|
|
4411
4679
|
[STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
|
|
4412
4680
|
}));
|
|
4413
4681
|
};
|
|
@@ -4423,8 +4691,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4423
4691
|
return arrayLength();
|
|
4424
4692
|
if (prop === Symbol.iterator)
|
|
4425
4693
|
return function* () {
|
|
4426
|
-
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
4427
|
-
// when items are added or removed, not only when already-read elements change
|
|
4428
4694
|
const len = arrayLength();
|
|
4429
4695
|
for (let i = 0; i < len(); i++)
|
|
4430
4696
|
yield receiver[i];
|
|
@@ -4669,9 +4935,6 @@ function forkStore(base, opt) {
|
|
|
4669
4935
|
const merge = reconcile;
|
|
4670
4936
|
const staged = linkedSignal({ ...(ngDevMode ? { debugName: "staged" } : /* istanbul ignore next */ {}), source: () => base(),
|
|
4671
4937
|
computation: (theirs, prev) => prev === undefined ? theirs : merge(prev.source, prev.value, theirs) });
|
|
4672
|
-
// Inherit the base's shared options (injector, vivify, noUnionLeaves + the
|
|
4673
|
-
// proxy cache/registry), same as extendStore — a fork should vivify like its
|
|
4674
|
-
// base and share its injector-scoped cache. `opt` overrides (advanced use).
|
|
4675
4938
|
const store = toStore(staged, {
|
|
4676
4939
|
...base[STORE_SHARED_OPTIONS],
|
|
4677
4940
|
...opt,
|
|
@@ -4680,233 +4943,679 @@ function forkStore(base, opt) {
|
|
|
4680
4943
|
store,
|
|
4681
4944
|
commit: () => base.set(untracked(staged)),
|
|
4682
4945
|
discard: () => staged.set(untracked(base)),
|
|
4946
|
+
ops: () => diffOps(untracked(base), untracked(staged)),
|
|
4683
4947
|
};
|
|
4684
4948
|
}
|
|
4685
4949
|
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4950
|
+
/** Total order over stamps alone; ties break on `writer` via {@link compareTotal}. */
|
|
4951
|
+
function compareHlc(a, b) {
|
|
4952
|
+
return a.p !== b.p ? a.p - b.p : a.l - b.l;
|
|
4953
|
+
}
|
|
4954
|
+
/** The protocol's total order: (hlc.p, hlc.l, writer). Never returns 0 for distinct writers. */
|
|
4955
|
+
function compareTotal(a, writerA, b, writerB) {
|
|
4956
|
+
const byClock = compareHlc(a, b);
|
|
4957
|
+
if (byClock !== 0)
|
|
4958
|
+
return byClock;
|
|
4959
|
+
return writerA < writerB ? -1 : writerA > writerB ? 1 : 0;
|
|
4960
|
+
}
|
|
4961
|
+
const SKEW_WARN_MS = 5 * 60_000;
|
|
4962
|
+
/**
|
|
4963
|
+
* HLC per Kulkarni et al.: convergence never depends on wall clocks, but LWW fairness
|
|
4964
|
+
* degrades under large skew, so observing a remote clock far ahead warns in dev mode.
|
|
4965
|
+
*/
|
|
4966
|
+
function createHlcClock(now = Date.now) {
|
|
4967
|
+
let p = 0;
|
|
4968
|
+
let l = 0;
|
|
4969
|
+
const advance = (wall, observed) => {
|
|
4970
|
+
const nextP = Math.max(p, wall, observed?.p ?? 0);
|
|
4971
|
+
if (nextP === p) {
|
|
4972
|
+
l = Math.max(l, observed && observed.p === nextP ? observed.l : 0) + 1;
|
|
4973
|
+
}
|
|
4974
|
+
else {
|
|
4975
|
+
p = nextP;
|
|
4976
|
+
l = observed && observed.p === nextP ? observed.l + 1 : 0;
|
|
4977
|
+
}
|
|
4978
|
+
};
|
|
4979
|
+
return {
|
|
4980
|
+
next: () => {
|
|
4981
|
+
advance(now());
|
|
4982
|
+
return { p, l };
|
|
4983
|
+
},
|
|
4984
|
+
observe: (remote) => {
|
|
4985
|
+
const wall = now();
|
|
4986
|
+
if (isDevMode() && remote.p - wall > SKEW_WARN_MS) {
|
|
4987
|
+
console.warn(`[@mmstack/primitives] observed remote clock ${Math.round((remote.p - wall) / 1000)}s ahead — convergence holds, but last-writer-wins fairness degrades under clock skew`);
|
|
4988
|
+
}
|
|
4989
|
+
advance(wall, remote);
|
|
4990
|
+
},
|
|
4991
|
+
};
|
|
4690
4992
|
}
|
|
4691
|
-
|
|
4993
|
+
|
|
4994
|
+
const OP_PROTO_VERSION = 1;
|
|
4995
|
+
const CONFLICT_BRAND = '~mmstackConflict';
|
|
4996
|
+
function isConflicted(value) {
|
|
4997
|
+
return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
|
|
4998
|
+
}
|
|
4999
|
+
const lww = (_ancestor, mine) => mine;
|
|
5000
|
+
const mergeThree = (ancestor, mine, theirs) => merge3(ancestor, mine, theirs);
|
|
5001
|
+
const preserve = (ancestor, mine, theirs) => ({ [CONFLICT_BRAND]: true, mine, theirs, ancestor });
|
|
4692
5002
|
/**
|
|
4693
|
-
*
|
|
4694
|
-
* an
|
|
4695
|
-
*
|
|
5003
|
+
* Identity-aware array merge (op-protocol RFC §12 v0): reconciles two concurrent versions of
|
|
5004
|
+
* an array item-wise by a user-provided identity, instead of last-writer-wins on the whole
|
|
5005
|
+
* array. Items are matched by key; per-item fields merge via `merge3` against the ancestor
|
|
5006
|
+
* item; items added on either side survive; an item removed on either side and unedited on
|
|
5007
|
+
* the other stays removed. Item ORDER follows `mine` (the total-order winner), with `theirs`-
|
|
5008
|
+
* only additions appended — positional merging is out of scope (fractional indexing is the
|
|
5009
|
+
* known upgrade if dogfooding demands it). Arrays still TRAVEL as whole-value sets; identity
|
|
5010
|
+
* only shapes conflict resolution, so the wire format is untouched.
|
|
4696
5011
|
*/
|
|
4697
|
-
function
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
if (!Object.hasOwn(next, key))
|
|
4703
|
-
ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
|
|
5012
|
+
function keyedArray(identity, opt) {
|
|
5013
|
+
const mergeItem = opt?.item ?? mergeThree;
|
|
5014
|
+
return (ancestor, mine, theirs, ctx) => {
|
|
5015
|
+
if (!Array.isArray(mine) || !Array.isArray(theirs)) {
|
|
5016
|
+
return mine; // type conflict → total-order winner, like lww
|
|
4704
5017
|
}
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
5018
|
+
const anc = Array.isArray(ancestor) ? ancestor : [];
|
|
5019
|
+
const byKey = (arr) => {
|
|
5020
|
+
const map = new Map();
|
|
5021
|
+
for (const item of arr)
|
|
5022
|
+
map.set(identity(item), item);
|
|
5023
|
+
return map;
|
|
5024
|
+
};
|
|
5025
|
+
const ancMap = byKey(anc);
|
|
5026
|
+
const mineMap = byKey(mine);
|
|
5027
|
+
const theirsMap = byKey(theirs);
|
|
5028
|
+
const out = [];
|
|
5029
|
+
for (const item of mine) {
|
|
5030
|
+
const key = identity(item);
|
|
5031
|
+
const other = theirsMap.get(key);
|
|
5032
|
+
const base = ancMap.get(key);
|
|
5033
|
+
if (theirsMap.has(key)) {
|
|
5034
|
+
out.push(structuralEq(item, other)
|
|
5035
|
+
? item
|
|
5036
|
+
: mergeItem(base, item, other, ctx));
|
|
4709
5037
|
}
|
|
4710
|
-
else {
|
|
4711
|
-
|
|
5038
|
+
else if (!ancMap.has(key) || !structuralEq(item, base)) {
|
|
5039
|
+
out.push(item); // added by mine, or edited by mine while theirs removed it → keep
|
|
4712
5040
|
}
|
|
5041
|
+
// else: theirs removed it and mine left it untouched → stays removed
|
|
4713
5042
|
}
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
diffNode(prev[i], next[i], [...path, i], ops);
|
|
4722
|
-
return;
|
|
5043
|
+
for (const item of theirs) {
|
|
5044
|
+
const key = identity(item);
|
|
5045
|
+
if (mineMap.has(key))
|
|
5046
|
+
continue;
|
|
5047
|
+
if (!ancMap.has(key) || !structuralEq(item, ancMap.get(key))) {
|
|
5048
|
+
out.push(item); // added by theirs, or edited by theirs while mine removed it → keep
|
|
5049
|
+
}
|
|
4723
5050
|
}
|
|
4724
|
-
|
|
4725
|
-
|
|
5051
|
+
return out;
|
|
5052
|
+
};
|
|
5053
|
+
}
|
|
5054
|
+
function compilePolicies(entries) {
|
|
5055
|
+
return entries.map((e) => ({
|
|
5056
|
+
segments: typeof e.path === 'string' ? e.path.split('.') : e.path.map(String),
|
|
5057
|
+
merge: e.merge,
|
|
5058
|
+
}));
|
|
5059
|
+
}
|
|
5060
|
+
function policyFor(policies, path) {
|
|
5061
|
+
outer: for (const p of policies) {
|
|
5062
|
+
if (p.segments.length !== path.length)
|
|
5063
|
+
continue;
|
|
5064
|
+
for (let i = 0; i < path.length; i++) {
|
|
5065
|
+
if (p.segments[i] !== '*' && p.segments[i] !== String(path[i]))
|
|
5066
|
+
continue outer;
|
|
5067
|
+
}
|
|
5068
|
+
return p.merge;
|
|
4726
5069
|
}
|
|
4727
|
-
|
|
4728
|
-
ops.push({ kind: 'set', path, prev, next });
|
|
5070
|
+
return lww;
|
|
4729
5071
|
}
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
5072
|
+
const SEP = '';
|
|
5073
|
+
const keyOf$1 = (path) => path.map(String).join(SEP);
|
|
5074
|
+
function structuralEq(a, b) {
|
|
5075
|
+
if (Object.is(a, b))
|
|
5076
|
+
return true;
|
|
5077
|
+
if (typeof a !== 'object' ||
|
|
5078
|
+
typeof b !== 'object' ||
|
|
5079
|
+
a === null ||
|
|
5080
|
+
b === null ||
|
|
5081
|
+
Array.isArray(a) !== Array.isArray(b)) {
|
|
5082
|
+
return false;
|
|
5083
|
+
}
|
|
5084
|
+
const ka = Object.keys(a);
|
|
5085
|
+
const kb = Object.keys(b);
|
|
5086
|
+
if (ka.length !== kb.length)
|
|
5087
|
+
return false;
|
|
5088
|
+
for (const k of ka) {
|
|
5089
|
+
if (!Object.hasOwn(b, k))
|
|
5090
|
+
return false;
|
|
5091
|
+
if (!structuralEq(a[k], b[k])) {
|
|
5092
|
+
return false;
|
|
4744
5093
|
}
|
|
4745
|
-
|
|
4746
|
-
|
|
5094
|
+
}
|
|
5095
|
+
return true;
|
|
5096
|
+
}
|
|
5097
|
+
// total order (hlc, writer, origin): two origins can share a writer AND a stamp
|
|
5098
|
+
// (independent clocks, same ms), so only origin makes the order strict
|
|
5099
|
+
const compareStamp = (a, b) => {
|
|
5100
|
+
const byTotal = compareTotal(a.hlc, a.writer, b.hlc, b.writer);
|
|
5101
|
+
if (byTotal !== 0)
|
|
5102
|
+
return byTotal;
|
|
5103
|
+
return a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0;
|
|
5104
|
+
};
|
|
5105
|
+
const beats = (a, b) => compareStamp(a, b) > 0;
|
|
5106
|
+
/**
|
|
5107
|
+
* The unsequenced-topology convergence core (op-protocol RFC §4): a per-path last-writer-wins
|
|
5108
|
+
* register map over the total order (hlc, writer), with subtree dominance. Order-independent:
|
|
5109
|
+
* any arrival order of the same envelope set yields the same state.
|
|
5110
|
+
*/
|
|
5111
|
+
function createConvergingApply(opt) {
|
|
5112
|
+
const registers = new Map();
|
|
5113
|
+
const policies = compilePolicies(opt?.policies ?? []);
|
|
5114
|
+
const resolveConcurrent = (winner, loser, path) => {
|
|
5115
|
+
const merge = policyFor(policies, path);
|
|
5116
|
+
if (merge === lww || winner.kind === 'delete' || loser.kind === 'delete') {
|
|
5117
|
+
return winner;
|
|
4747
5118
|
}
|
|
4748
|
-
|
|
5119
|
+
const resolved = merge(loser.prev, winner.next, loser.next, { path });
|
|
5120
|
+
if (Object.is(resolved, winner.next))
|
|
5121
|
+
return winner;
|
|
5122
|
+
return { kind: 'set', path, next: resolved, prev: winner.next };
|
|
5123
|
+
};
|
|
5124
|
+
// a sequential edit carries the value it overwrote; a mismatch means neither saw the other.
|
|
5125
|
+
// Structural, not referential: identity never survives the wire, so a peer that built on
|
|
5126
|
+
// the replicated copy of a value must still count as sequential.
|
|
5127
|
+
const concurrentWith = (incoming, registered) => {
|
|
5128
|
+
if (incoming.kind === 'delete' || registered.kind === 'delete')
|
|
5129
|
+
return false;
|
|
5130
|
+
if (!Object.hasOwn(incoming, 'prev'))
|
|
5131
|
+
return true;
|
|
5132
|
+
return !structuralEq(incoming.prev, registered.next);
|
|
5133
|
+
};
|
|
5134
|
+
return {
|
|
5135
|
+
ingest: (env, o) => {
|
|
5136
|
+
const stamp = { hlc: env.hlc, writer: env.writer, origin: env.origin };
|
|
5137
|
+
const out = [];
|
|
5138
|
+
for (const op of env.ops) {
|
|
5139
|
+
const key = keyOf$1(op.path);
|
|
5140
|
+
let dominated = false;
|
|
5141
|
+
let exact;
|
|
5142
|
+
for (let len = 0; len <= op.path.length; len++) {
|
|
5143
|
+
const reg = registers.get(keyOf$1(op.path.slice(0, len)));
|
|
5144
|
+
if (!reg)
|
|
5145
|
+
continue;
|
|
5146
|
+
if (len === op.path.length)
|
|
5147
|
+
exact = reg;
|
|
5148
|
+
else if (beats(reg, stamp)) {
|
|
5149
|
+
dominated = true;
|
|
5150
|
+
break;
|
|
5151
|
+
}
|
|
5152
|
+
}
|
|
5153
|
+
if (dominated)
|
|
5154
|
+
continue;
|
|
5155
|
+
if (exact && beats(exact, stamp)) {
|
|
5156
|
+
if (concurrentWith(op, exact.op)) {
|
|
5157
|
+
const resolved = resolveConcurrent(exact.op, op, op.path);
|
|
5158
|
+
if (resolved !== exact.op) {
|
|
5159
|
+
exact.op = resolved;
|
|
5160
|
+
if (!o?.local)
|
|
5161
|
+
out.push(resolved);
|
|
5162
|
+
}
|
|
5163
|
+
}
|
|
5164
|
+
continue;
|
|
5165
|
+
}
|
|
5166
|
+
let accepted = op;
|
|
5167
|
+
if (exact && concurrentWith(op, exact.op)) {
|
|
5168
|
+
accepted = resolveConcurrent(op, exact.op, op.path);
|
|
5169
|
+
}
|
|
5170
|
+
const isDescendant = key === ''
|
|
5171
|
+
? (k) => k !== ''
|
|
5172
|
+
: (k) => k.startsWith(key + SEP);
|
|
5173
|
+
const replays = [];
|
|
5174
|
+
for (const [k, reg] of registers) {
|
|
5175
|
+
if (!isDescendant(k))
|
|
5176
|
+
continue;
|
|
5177
|
+
if (beats(stamp, reg))
|
|
5178
|
+
registers.delete(k);
|
|
5179
|
+
else
|
|
5180
|
+
replays.push(reg);
|
|
5181
|
+
}
|
|
5182
|
+
replays.sort(compareStamp);
|
|
5183
|
+
registers.set(key, { hlc: env.hlc, writer: env.writer, origin: env.origin, op: accepted });
|
|
5184
|
+
if (!o?.local) {
|
|
5185
|
+
out.push(accepted);
|
|
5186
|
+
for (const r of replays)
|
|
5187
|
+
out.push(r.op);
|
|
5188
|
+
}
|
|
5189
|
+
}
|
|
5190
|
+
return out;
|
|
5191
|
+
},
|
|
5192
|
+
reset: () => registers.clear(),
|
|
5193
|
+
};
|
|
5194
|
+
}
|
|
5195
|
+
function getAtPath(root, path) {
|
|
5196
|
+
let cur = root;
|
|
5197
|
+
for (const seg of path) {
|
|
5198
|
+
if (cur === null || typeof cur !== 'object')
|
|
5199
|
+
return undefined;
|
|
5200
|
+
cur = cur[seg];
|
|
4749
5201
|
}
|
|
4750
|
-
|
|
4751
|
-
return base;
|
|
5202
|
+
return cur;
|
|
4752
5203
|
}
|
|
4753
5204
|
/**
|
|
4754
|
-
*
|
|
4755
|
-
*
|
|
4756
|
-
*
|
|
4757
|
-
* a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
|
|
4758
|
-
* Accepts a batch or a bare op list.
|
|
5205
|
+
* The shared rebase routine (op-protocol RFC §5): invert pending, apply remote, re-apply
|
|
5206
|
+
* pending through the merge policies. Pure — branching's `rebase()` and the sequenced relay
|
|
5207
|
+
* client both call this.
|
|
4759
5208
|
*/
|
|
4760
|
-
function
|
|
4761
|
-
const
|
|
4762
|
-
let
|
|
4763
|
-
for (
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
5209
|
+
function rebaseOps(root, pending, remote, policies) {
|
|
5210
|
+
const compiled = compilePolicies(policies ?? []);
|
|
5211
|
+
let base = root;
|
|
5212
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
5213
|
+
base = applyOps(base, invertBatch(pending[i]));
|
|
5214
|
+
}
|
|
5215
|
+
base = applyOps(base, remote);
|
|
5216
|
+
const rebased = [];
|
|
5217
|
+
for (const batch of pending) {
|
|
5218
|
+
const next = [];
|
|
5219
|
+
for (const op of batch) {
|
|
5220
|
+
const cur = getAtPath(base, op.path);
|
|
5221
|
+
if (op.kind === 'delete') {
|
|
5222
|
+
next.push({ kind: 'delete', path: op.path, prev: cur });
|
|
5223
|
+
}
|
|
5224
|
+
else if (cur === undefined) {
|
|
5225
|
+
next.push({ kind: 'set', path: op.path, next: op.next });
|
|
5226
|
+
}
|
|
5227
|
+
else if (Object.hasOwn(op, 'prev') && !structuralEq(op.prev, cur)) {
|
|
5228
|
+
const merge = policyFor(compiled, op.path);
|
|
5229
|
+
const resolved = merge(op.prev, op.next, cur, { path: op.path });
|
|
5230
|
+
next.push({ kind: 'set', path: op.path, next: resolved, prev: cur });
|
|
5231
|
+
}
|
|
5232
|
+
else {
|
|
5233
|
+
next.push({ kind: 'set', path: op.path, next: op.next, prev: cur });
|
|
5234
|
+
}
|
|
4768
5235
|
}
|
|
4769
|
-
|
|
5236
|
+
base = applyOps(base, next);
|
|
5237
|
+
rebased.push(next);
|
|
4770
5238
|
}
|
|
4771
|
-
return
|
|
5239
|
+
return { root: base, pending: rebased };
|
|
4772
5240
|
}
|
|
4773
5241
|
/**
|
|
4774
|
-
*
|
|
4775
|
-
*
|
|
4776
|
-
*
|
|
4777
|
-
*
|
|
5242
|
+
* A per-path-policy `ForkStrategy` for `forkStore`: a three-way reconcile built from the
|
|
5243
|
+
* shared rebase (invert mine → apply theirs' delta → re-apply mine through the policies).
|
|
5244
|
+
* Paths only one side touched resolve like `merge3`; paths BOTH touched go through the
|
|
5245
|
+
* matching {@link MergePolicyEntry} (`lww` default — fork wins, matching `'fine'`; or
|
|
5246
|
+
* `mergeThree` / `preserve` / custom). Same copy-on-write contract as `'fine'`.
|
|
4778
5247
|
*/
|
|
4779
|
-
function
|
|
4780
|
-
|
|
4781
|
-
diffNode(prev, next, [], ops);
|
|
4782
|
-
return ops;
|
|
5248
|
+
function policyStrategy(policies) {
|
|
5249
|
+
return (ancestor, mine, theirs) => rebaseOps(mine, [diffOps(ancestor, mine)], diffOps(ancestor, theirs), policies).root;
|
|
4783
5250
|
}
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
* carry — a wire-serialized batch that stripped them is not invertible.
|
|
4789
|
-
*/
|
|
4790
|
-
function invertBatch(batch) {
|
|
4791
|
-
const ops = Array.isArray(batch) ? batch : batch.ops;
|
|
4792
|
-
const inverted = [];
|
|
4793
|
-
for (let i = ops.length - 1; i >= 0; i--) {
|
|
4794
|
-
const op = ops[i];
|
|
4795
|
-
if (op.kind === 'delete') {
|
|
4796
|
-
inverted.push({
|
|
4797
|
-
kind: 'set',
|
|
4798
|
-
path: op.path,
|
|
4799
|
-
next: op.prev,
|
|
4800
|
-
prev: undefined,
|
|
4801
|
-
});
|
|
4802
|
-
continue;
|
|
4803
|
-
}
|
|
4804
|
-
if (!Object.hasOwn(op, 'prev')) {
|
|
4805
|
-
inverted.push({ kind: 'delete', path: op.path, prev: op.next });
|
|
4806
|
-
}
|
|
4807
|
-
else {
|
|
4808
|
-
inverted.push({
|
|
4809
|
-
kind: 'set',
|
|
4810
|
-
path: op.path,
|
|
4811
|
-
next: op.prev,
|
|
4812
|
-
prev: op.next,
|
|
4813
|
-
});
|
|
4814
|
-
}
|
|
4815
|
-
}
|
|
4816
|
-
return inverted;
|
|
5251
|
+
function generateOrigin() {
|
|
5252
|
+
if (globalThis.crypto?.randomUUID)
|
|
5253
|
+
return globalThis.crypto.randomUUID();
|
|
5254
|
+
return Math.random().toString(36).substring(2);
|
|
4817
5255
|
}
|
|
4818
5256
|
/**
|
|
4819
|
-
*
|
|
4820
|
-
*
|
|
4821
|
-
*
|
|
4822
|
-
* batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
|
|
4823
|
-
*
|
|
4824
|
-
* Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
|
|
4825
|
-
* of the root value per tick (structural sharing makes it O(changed paths)), driven by one
|
|
4826
|
-
* effect. A batch therefore coalesces everything written in one tick — for coarser,
|
|
4827
|
-
* intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
|
|
4828
|
-
*
|
|
4829
|
-
* NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
|
|
4830
|
-
* defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
|
|
4831
|
-
* warning fires and nothing emits.
|
|
4832
|
-
*
|
|
4833
|
-
* ```ts
|
|
4834
|
-
* const s = store({ todos: [{ done: false }] });
|
|
4835
|
-
* const log = opLog(s, { origin: 'tab-a' });
|
|
4836
|
-
* log.subscribe((b) => channel.postMessage(encode(b))); // ship
|
|
4837
|
-
* channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
|
|
4838
|
-
* s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
|
|
4839
|
-
* ```
|
|
5257
|
+
* Wires a copy-on-write signal (a `store` root) to the op protocol: local writes emit
|
|
5258
|
+
* stamped envelopes, received envelopes fold in through the converging apply. The
|
|
5259
|
+
* unsequenced-topology client core that `tabSync(store)` and P2P transports build on.
|
|
4840
5260
|
*/
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
const
|
|
4846
|
-
const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
|
|
4847
|
-
if (isDevMode() && mutableSource) {
|
|
4848
|
-
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.');
|
|
4849
|
-
}
|
|
4850
|
-
let prevRoot = untracked(source);
|
|
4851
|
-
let version = 0;
|
|
4852
|
-
let destroyed = false;
|
|
5261
|
+
const RECENT_LOCAL_CAP = 64;
|
|
5262
|
+
function opSync(source, opt) {
|
|
5263
|
+
const origin = opt.origin ?? generateOrigin();
|
|
5264
|
+
const clock = opt.clock ?? createHlcClock();
|
|
5265
|
+
const conv = createConvergingApply({ policies: opt.policies });
|
|
4853
5266
|
const subscribers = new Set();
|
|
4854
|
-
const
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
const
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
|
|
5267
|
+
const versions = new Map();
|
|
5268
|
+
const recentLocal = [];
|
|
5269
|
+
let version = 0;
|
|
5270
|
+
const log = opLog(source, opt.driver
|
|
5271
|
+
? { origin, driver: opt.driver }
|
|
5272
|
+
: { origin, injector: opt.injector ?? inject(Injector) });
|
|
5273
|
+
const emitLocal = (ops) => {
|
|
5274
|
+
const env = {
|
|
5275
|
+
proto: OP_PROTO_VERSION,
|
|
5276
|
+
origin,
|
|
5277
|
+
writer: opt.writer,
|
|
5278
|
+
version: ++version,
|
|
5279
|
+
hlc: clock.next(),
|
|
5280
|
+
policyVersion: opt.policyVersion ?? 0,
|
|
5281
|
+
ops,
|
|
5282
|
+
};
|
|
5283
|
+
versions.set(origin, env.version);
|
|
5284
|
+
conv.ingest(env, { local: true });
|
|
5285
|
+
recentLocal.push(env);
|
|
5286
|
+
if (recentLocal.length > RECENT_LOCAL_CAP)
|
|
5287
|
+
recentLocal.shift();
|
|
4870
5288
|
for (const cb of [...subscribers])
|
|
4871
|
-
cb(
|
|
4872
|
-
};
|
|
4873
|
-
const run = () => {
|
|
4874
|
-
source(); // track every commit…
|
|
4875
|
-
untracked(flush); // …and emit the delta since the last flush
|
|
5289
|
+
cb(env);
|
|
4876
5290
|
};
|
|
4877
|
-
|
|
4878
|
-
// (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
|
|
4879
|
-
const ref = opt?.driver
|
|
4880
|
-
? opt.driver(run)
|
|
4881
|
-
: effect(run, { injector: opt?.injector ?? inject(Injector) });
|
|
5291
|
+
const unsub = log.subscribe((batch) => emitLocal(batch.ops));
|
|
4882
5292
|
return {
|
|
4883
|
-
|
|
5293
|
+
origin,
|
|
4884
5294
|
subscribe: (cb) => {
|
|
4885
5295
|
subscribers.add(cb);
|
|
4886
5296
|
return () => subscribers.delete(cb);
|
|
4887
5297
|
},
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
flush: () => flush(),
|
|
4891
|
-
apply: (batchOrOps) => {
|
|
4892
|
-
const ops = Array.isArray(batchOrOps)
|
|
4893
|
-
? batchOrOps
|
|
4894
|
-
: batchOrOps.ops;
|
|
4895
|
-
if (!ops.length)
|
|
5298
|
+
receive: (env) => {
|
|
5299
|
+
if (env.origin === origin)
|
|
4896
5300
|
return;
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
5301
|
+
if (env.proto !== OP_PROTO_VERSION) {
|
|
5302
|
+
if (isDevMode()) {
|
|
5303
|
+
console.warn(`[@mmstack/primitives] dropped envelope with proto ${env.proto} (expected ${OP_PROTO_VERSION})`);
|
|
5304
|
+
}
|
|
5305
|
+
return;
|
|
5306
|
+
}
|
|
5307
|
+
clock.observe(env.hlc);
|
|
5308
|
+
const known = versions.get(env.origin);
|
|
5309
|
+
if (known !== undefined && env.version <= known)
|
|
5310
|
+
return; // duplicate/covered — idempotent
|
|
5311
|
+
if (known !== undefined && env.version !== known + 1) {
|
|
5312
|
+
opt.onGap?.(env.origin, known + 1, env.version);
|
|
5313
|
+
}
|
|
5314
|
+
versions.set(env.origin, env.version);
|
|
5315
|
+
log.flush();
|
|
5316
|
+
const ops = conv.ingest(env);
|
|
5317
|
+
if (ops.length)
|
|
5318
|
+
log.apply(ops);
|
|
5319
|
+
},
|
|
5320
|
+
flush: () => log.flush(),
|
|
5321
|
+
watermark: () => Object.fromEntries(versions),
|
|
5322
|
+
snapshot: () => {
|
|
5323
|
+
log.flush();
|
|
5324
|
+
return { root: untracked(source), wm: Object.fromEntries(versions) };
|
|
5325
|
+
},
|
|
5326
|
+
seed: () => {
|
|
5327
|
+
log.flush();
|
|
5328
|
+
emitLocal([{ kind: 'set', path: [], next: untracked(source) }]);
|
|
5329
|
+
},
|
|
5330
|
+
hydrate: (root, wm) => {
|
|
5331
|
+
log.flush();
|
|
5332
|
+
const covered = wm?.[origin] ?? 0;
|
|
5333
|
+
const pending = recentLocal.filter((e) => e.version > covered);
|
|
5334
|
+
conv.reset();
|
|
5335
|
+
let next = root;
|
|
5336
|
+
for (const e of pending)
|
|
5337
|
+
next = applyOps(next, e.ops);
|
|
5338
|
+
log.apply([{ kind: 'set', path: [], next }]);
|
|
5339
|
+
for (const [o, v] of Object.entries(wm ?? {})) {
|
|
5340
|
+
versions.set(o, Math.max(versions.get(o) ?? 0, v));
|
|
5341
|
+
}
|
|
5342
|
+
for (const e of pending)
|
|
5343
|
+
conv.ingest(e, { local: true });
|
|
4902
5344
|
},
|
|
4903
5345
|
destroy: () => {
|
|
4904
|
-
|
|
5346
|
+
unsub();
|
|
4905
5347
|
subscribers.clear();
|
|
4906
|
-
|
|
5348
|
+
log.destroy();
|
|
5349
|
+
},
|
|
5350
|
+
};
|
|
5351
|
+
}
|
|
5352
|
+
|
|
5353
|
+
/**
|
|
5354
|
+
* Undo/redo for a copy-on-write store, built on the op-log: each tracked change is stored as
|
|
5355
|
+
* its inverse batch, so `undo()` is one `apply` and history costs only the diffs, not full
|
|
5356
|
+
* snapshots. Redoing is invert-of-the-inverse. A new edit made after an undo clears the redo
|
|
5357
|
+
* stack (linear history). Applying a redo/undo does not itself re-enter history.
|
|
5358
|
+
*
|
|
5359
|
+
* Composes with sync for collaborative undo: pass `track: syncClient` so only YOUR writes are
|
|
5360
|
+
* undoable, while `undo()` emits a normal op that propagates to peers (it writes through the
|
|
5361
|
+
* store, which the sync client picks up).
|
|
5362
|
+
*/
|
|
5363
|
+
function storeHistory(source, opt) {
|
|
5364
|
+
const limit = opt?.limit ?? 100;
|
|
5365
|
+
const logOpt = { origin: opt?.origin };
|
|
5366
|
+
if (opt?.driver)
|
|
5367
|
+
logOpt.driver = opt.driver;
|
|
5368
|
+
else
|
|
5369
|
+
logOpt.injector = opt?.injector ?? inject(Injector);
|
|
5370
|
+
const log = opLog(source, logOpt);
|
|
5371
|
+
const undoStack = [];
|
|
5372
|
+
const redoStack = [];
|
|
5373
|
+
const version = signal(0, /* @ts-ignore */
|
|
5374
|
+
...(ngDevMode ? [{ debugName: "version" }] : /* istanbul ignore next */ [])); // monotonic: bumps on every mutation so the computeds recompute
|
|
5375
|
+
let applying = false;
|
|
5376
|
+
const push = (stack, inverse) => {
|
|
5377
|
+
stack.push(inverse);
|
|
5378
|
+
if (stack.length > limit)
|
|
5379
|
+
stack.shift();
|
|
5380
|
+
};
|
|
5381
|
+
const record = (batch) => {
|
|
5382
|
+
if (applying)
|
|
5383
|
+
return; // an undo/redo's own emission must not re-enter history
|
|
5384
|
+
if (!batch.ops.length)
|
|
5385
|
+
return;
|
|
5386
|
+
push(undoStack, invertBatch(batch));
|
|
5387
|
+
redoStack.length = 0; // a fresh edit forks the timeline
|
|
5388
|
+
version.update((v) => v + 1);
|
|
5389
|
+
};
|
|
5390
|
+
// track the sync client's local stream when given, else self-diff every store change
|
|
5391
|
+
const unsub = (opt?.track ?? log).subscribe(record);
|
|
5392
|
+
const run = (from, to) => {
|
|
5393
|
+
const inverse = from.pop();
|
|
5394
|
+
if (!inverse)
|
|
5395
|
+
return;
|
|
5396
|
+
log.flush(); // settle pending local writes before applying
|
|
5397
|
+
applying = true;
|
|
5398
|
+
try {
|
|
5399
|
+
log.apply(inverse);
|
|
5400
|
+
}
|
|
5401
|
+
finally {
|
|
5402
|
+
applying = false;
|
|
5403
|
+
}
|
|
5404
|
+
push(to, invertBatch(inverse)); // the inverse of what we applied restores the other direction
|
|
5405
|
+
version.update((v) => v + 1);
|
|
5406
|
+
};
|
|
5407
|
+
return {
|
|
5408
|
+
canUndo: computed(() => (version(), undoStack.length > 0)),
|
|
5409
|
+
canRedo: computed(() => (version(), redoStack.length > 0)),
|
|
5410
|
+
undo: () => run(undoStack, redoStack),
|
|
5411
|
+
redo: () => run(redoStack, undoStack),
|
|
5412
|
+
clear: () => {
|
|
5413
|
+
undoStack.length = 0;
|
|
5414
|
+
redoStack.length = 0;
|
|
5415
|
+
version.update((v) => v + 1);
|
|
5416
|
+
},
|
|
5417
|
+
destroy: () => {
|
|
5418
|
+
unsub();
|
|
5419
|
+
log.destroy();
|
|
5420
|
+
},
|
|
5421
|
+
};
|
|
5422
|
+
}
|
|
5423
|
+
|
|
5424
|
+
const PERSISTED_STORE_OPTIONS = new InjectionToken('@mmstack/primitives:persisted-store-options');
|
|
5425
|
+
/**
|
|
5426
|
+
* Wire the {@link AsyncStore} backend (and any shared debounce) once, override per call. The
|
|
5427
|
+
* typical use is to install idb-keyval at bootstrap so every `persist`/`persistedStore` persists
|
|
5428
|
+
* without re-passing the backend.
|
|
5429
|
+
*
|
|
5430
|
+
* @example
|
|
5431
|
+
* import * as idbKeyval from 'idb-keyval';
|
|
5432
|
+
* providePersistedStoreOptions({ store: idbKeyval });
|
|
5433
|
+
*/
|
|
5434
|
+
function providePersistedStoreOptions(opt) {
|
|
5435
|
+
return { provide: PERSISTED_STORE_OPTIONS, useValue: opt };
|
|
5436
|
+
}
|
|
5437
|
+
/**
|
|
5438
|
+
* Attach durable local persistence to an EXISTING store: its whole-value snapshot is written to an
|
|
5439
|
+
* async backend (IndexedDB via idb-keyval or Dexie) and restored on boot. A reader over the store,
|
|
5440
|
+
* so it composes with the other op-log readers (`tabSync`, `@mmstack/mesh`) on the same store — a
|
|
5441
|
+
* persisted, synced graph is just two readers. Local durability, not sync.
|
|
5442
|
+
*
|
|
5443
|
+
* Because the backend is async, hydration cannot precede the first read: the store keeps its current
|
|
5444
|
+
* value, then adopts the persisted snapshot once the backend answers, UNLESS a write happened first
|
|
5445
|
+
* (an explicit boot-time write wins over stale disk). Writes are coalesced and flushed on teardown
|
|
5446
|
+
* and on page hide, so the last change is never lost. On the server it is a no-op.
|
|
5447
|
+
*
|
|
5448
|
+
* When the persisted shape evolves, pass `version` and a `migrate` hook: an older snapshot is
|
|
5449
|
+
* brought forward on boot before it is adopted, then re-persisted in the new shape. Because boot is
|
|
5450
|
+
* already async, `migrate` may be async, so the migration ladder can be lazy-imported.
|
|
5451
|
+
*/
|
|
5452
|
+
function persist(source, opt) {
|
|
5453
|
+
const injector = opt.injector ?? inject(Injector);
|
|
5454
|
+
const defaults = injector.get(PERSISTED_STORE_OPTIONS, null);
|
|
5455
|
+
const key = opt.key;
|
|
5456
|
+
const backend = opt.store ?? defaults?.store;
|
|
5457
|
+
const serialize = opt.serialize ?? ((v) => v);
|
|
5458
|
+
const deserialize = opt.deserialize ?? ((r) => r);
|
|
5459
|
+
const version = opt.version;
|
|
5460
|
+
const debounceMs = opt.writeDebounceMs ?? defaults?.writeDebounceMs ?? 300;
|
|
5461
|
+
const read = source;
|
|
5462
|
+
const setRoot = (value) => source.set(value);
|
|
5463
|
+
const VERSION_KEY = '__mmstack_pv';
|
|
5464
|
+
const encode = (value) => version === undefined
|
|
5465
|
+
? serialize(value)
|
|
5466
|
+
: { [VERSION_KEY]: version, data: serialize(value) };
|
|
5467
|
+
const isServer = isPlatformServer(injector.get(PLATFORM_ID));
|
|
5468
|
+
const initialRef = untracked(read); // copy-on-write: an untouched store keeps this reference
|
|
5469
|
+
const hydrated = signal(false, /* @ts-ignore */
|
|
5470
|
+
...(ngDevMode ? [{ debugName: "hydrated" }] : /* istanbul ignore next */ []));
|
|
5471
|
+
if (isServer || !backend) {
|
|
5472
|
+
if (!backend && !isServer && isDevMode()) {
|
|
5473
|
+
console.warn(`[@mmstack/primitives] persist("${key}"): no AsyncStore backend (pass { store } or providePersistedStoreOptions). Running in-memory, not persisted.`);
|
|
5474
|
+
}
|
|
5475
|
+
hydrated.set(true);
|
|
5476
|
+
return {
|
|
5477
|
+
hydrated: hydrated.asReadonly(),
|
|
5478
|
+
flush: () => Promise.resolve(),
|
|
5479
|
+
clear: () => {
|
|
5480
|
+
setRoot(initialRef);
|
|
5481
|
+
return Promise.resolve();
|
|
5482
|
+
},
|
|
5483
|
+
};
|
|
5484
|
+
}
|
|
5485
|
+
let persistedRef = initialRef;
|
|
5486
|
+
void (async () => {
|
|
5487
|
+
try {
|
|
5488
|
+
const raw = await backend.get(key);
|
|
5489
|
+
// apply the snapshot only if nothing wrote in the boot window (explicit write wins)
|
|
5490
|
+
if (raw !== undefined && raw !== null && untracked(read) === initialRef) {
|
|
5491
|
+
let fromVersion = 0;
|
|
5492
|
+
let payload = raw;
|
|
5493
|
+
if (typeof raw === 'object' &&
|
|
5494
|
+
raw !== null &&
|
|
5495
|
+
VERSION_KEY in raw) {
|
|
5496
|
+
const env = raw;
|
|
5497
|
+
fromVersion =
|
|
5498
|
+
typeof env[VERSION_KEY] === 'number'
|
|
5499
|
+
? env[VERSION_KEY]
|
|
5500
|
+
: 0;
|
|
5501
|
+
payload = env['data'];
|
|
5502
|
+
}
|
|
5503
|
+
const target = version ?? 0;
|
|
5504
|
+
if (fromVersion > target) {
|
|
5505
|
+
if (isDevMode()) {
|
|
5506
|
+
console.warn(`[@mmstack/primitives] persist("${key}"): stored snapshot is version ${fromVersion} but this build is ${target}; leaving it untouched (a newer build wrote it).`);
|
|
5507
|
+
}
|
|
5508
|
+
}
|
|
5509
|
+
else {
|
|
5510
|
+
const migrated = !!(opt.migrate && fromVersion < target);
|
|
5511
|
+
let value = deserialize(payload);
|
|
5512
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
5513
|
+
if (migrated)
|
|
5514
|
+
value = await opt.migrate(value, fromVersion);
|
|
5515
|
+
if (untracked(read) === initialRef) {
|
|
5516
|
+
setRoot(value);
|
|
5517
|
+
if (!migrated)
|
|
5518
|
+
persistedRef = value;
|
|
5519
|
+
}
|
|
5520
|
+
}
|
|
5521
|
+
}
|
|
5522
|
+
}
|
|
5523
|
+
catch (err) {
|
|
5524
|
+
if (isDevMode()) {
|
|
5525
|
+
console.warn(`[@mmstack/primitives] persist("${key}") hydrate failed`, err);
|
|
5526
|
+
}
|
|
5527
|
+
}
|
|
5528
|
+
finally {
|
|
5529
|
+
hydrated.set(true);
|
|
5530
|
+
}
|
|
5531
|
+
})();
|
|
5532
|
+
let timer;
|
|
5533
|
+
const write = async (value) => {
|
|
5534
|
+
try {
|
|
5535
|
+
await backend.set(key, encode(value));
|
|
5536
|
+
persistedRef = value;
|
|
5537
|
+
}
|
|
5538
|
+
catch (err) {
|
|
5539
|
+
if (isDevMode()) {
|
|
5540
|
+
console.warn(`[@mmstack/primitives] persist("${key}") write failed`, err);
|
|
5541
|
+
}
|
|
5542
|
+
}
|
|
5543
|
+
};
|
|
5544
|
+
const cancelTimer = () => {
|
|
5545
|
+
if (timer !== undefined) {
|
|
5546
|
+
clearTimeout(timer);
|
|
5547
|
+
timer = undefined;
|
|
5548
|
+
}
|
|
5549
|
+
};
|
|
5550
|
+
const flush = async () => {
|
|
5551
|
+
cancelTimer();
|
|
5552
|
+
const current = untracked(read);
|
|
5553
|
+
if (!untracked(hydrated) ||
|
|
5554
|
+
current === initialRef ||
|
|
5555
|
+
current === persistedRef)
|
|
5556
|
+
return;
|
|
5557
|
+
await write(current);
|
|
5558
|
+
};
|
|
5559
|
+
effect(() => {
|
|
5560
|
+
if (!hydrated())
|
|
5561
|
+
return;
|
|
5562
|
+
const value = read();
|
|
5563
|
+
untracked(() => {
|
|
5564
|
+
cancelTimer();
|
|
5565
|
+
// untouched / reset-to-initial, or already the value on disk (e.g. just hydrated): skip
|
|
5566
|
+
if (value === initialRef || value === persistedRef)
|
|
5567
|
+
return;
|
|
5568
|
+
timer = setTimeout(() => {
|
|
5569
|
+
timer = undefined;
|
|
5570
|
+
void write(value);
|
|
5571
|
+
}, debounceMs);
|
|
5572
|
+
});
|
|
5573
|
+
}, { injector });
|
|
5574
|
+
const onHide = () => {
|
|
5575
|
+
void flush();
|
|
5576
|
+
};
|
|
5577
|
+
if (typeof document !== 'undefined') {
|
|
5578
|
+
document.addEventListener('visibilitychange', onHide);
|
|
5579
|
+
window.addEventListener('pagehide', onHide);
|
|
5580
|
+
}
|
|
5581
|
+
injector.get(DestroyRef).onDestroy(() => {
|
|
5582
|
+
void flush();
|
|
5583
|
+
if (typeof document !== 'undefined') {
|
|
5584
|
+
document.removeEventListener('visibilitychange', onHide);
|
|
5585
|
+
window.removeEventListener('pagehide', onHide);
|
|
5586
|
+
}
|
|
5587
|
+
});
|
|
5588
|
+
return {
|
|
5589
|
+
hydrated: hydrated.asReadonly(),
|
|
5590
|
+
flush,
|
|
5591
|
+
clear: async () => {
|
|
5592
|
+
cancelTimer();
|
|
5593
|
+
setRoot(initialRef); // back to initialRef, so the persist effect skips (no re-write over the delete)
|
|
5594
|
+
persistedRef = initialRef; // disk is now empty
|
|
5595
|
+
try {
|
|
5596
|
+
await backend.del(key);
|
|
5597
|
+
}
|
|
5598
|
+
catch (err) {
|
|
5599
|
+
if (isDevMode()) {
|
|
5600
|
+
console.warn(`[@mmstack/primitives] persist("${key}") clear failed`, err);
|
|
5601
|
+
}
|
|
5602
|
+
}
|
|
4907
5603
|
},
|
|
4908
5604
|
};
|
|
4909
5605
|
}
|
|
5606
|
+
/**
|
|
5607
|
+
* A `store` with {@link persist} already attached: a whole-value snapshot persisted to an async
|
|
5608
|
+
* backend and restored on boot. Equivalent to `const s = store(initial); persist(s, opt)` — reach
|
|
5609
|
+
* for `persist` directly when you want persistence on a store you already have (e.g. to also
|
|
5610
|
+
* `meshSync` it).
|
|
5611
|
+
*/
|
|
5612
|
+
function persistedStore(initial, opt) {
|
|
5613
|
+
const injector = opt.injector ?? inject(Injector);
|
|
5614
|
+
// store() reads only the signal/store opts it knows; the persistence keys ride along harmlessly
|
|
5615
|
+
const s = store(initial, { ...opt, injector });
|
|
5616
|
+
const handle = persist(s, { ...opt, injector });
|
|
5617
|
+
return { store: s, ...handle };
|
|
5618
|
+
}
|
|
4910
5619
|
|
|
4911
5620
|
const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
|
|
4912
5621
|
function keyOf(item, key) {
|
|
@@ -4985,13 +5694,9 @@ function reconcileValue(prev, next, key) {
|
|
|
4985
5694
|
*/
|
|
4986
5695
|
function projection(fn, seed, opt) {
|
|
4987
5696
|
const { key = 'id', ...storeOpt } = opt ?? {};
|
|
4988
|
-
// linkedSignal rather than an effect-driven signal: the computation runs in the tracked
|
|
4989
|
-
// context (fn's reads are dependencies) and `previous` hands back the last emitted value for
|
|
4990
|
-
// the reconcile, so the projection is glitch-free, lazy, and needs no effect scheduler.
|
|
4991
5697
|
const root = linkedSignal({ ...(ngDevMode ? { debugName: "root" } : /* istanbul ignore next */ {}), source: () => undefined,
|
|
4992
5698
|
computation: (_, previous) => {
|
|
4993
5699
|
const base = previous ? previous.value : seed;
|
|
4994
|
-
// a plain mutable scratch seeded with the current value; fn mutates it or returns new data
|
|
4995
5700
|
const draft = structuredClone(base);
|
|
4996
5701
|
const returned = fn(draft);
|
|
4997
5702
|
const next = (returned === undefined ? draft : returned);
|
|
@@ -5187,6 +5892,85 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
5187
5892
|
return writable;
|
|
5188
5893
|
}
|
|
5189
5894
|
|
|
5895
|
+
/** Op-mode sync for a writable store: hello exchange, then live envelopes (RFC §6 tab flavor). */
|
|
5896
|
+
function storeTabSync(sig, opt, bus, injector) {
|
|
5897
|
+
const sync = opSync(sig, {
|
|
5898
|
+
writer: opt.writer ?? 'local',
|
|
5899
|
+
policies: opt.policies,
|
|
5900
|
+
injector,
|
|
5901
|
+
});
|
|
5902
|
+
const helloTimeoutMs = opt.helloTimeoutMs ?? 250;
|
|
5903
|
+
const jitterMs = opt.jitterMs ?? 25;
|
|
5904
|
+
let phase = 'joining';
|
|
5905
|
+
const joinBuffer = [];
|
|
5906
|
+
const responseTimers = new Map();
|
|
5907
|
+
let helloTimer;
|
|
5908
|
+
function goLive() {
|
|
5909
|
+
if (phase === 'live')
|
|
5910
|
+
return;
|
|
5911
|
+
phase = 'live';
|
|
5912
|
+
if (helloTimer !== undefined) {
|
|
5913
|
+
clearTimeout(helloTimer);
|
|
5914
|
+
helloTimer = undefined;
|
|
5915
|
+
}
|
|
5916
|
+
for (const env of joinBuffer.splice(0))
|
|
5917
|
+
sync.receive(env);
|
|
5918
|
+
}
|
|
5919
|
+
const { unsub, post } = bus.subscribe(opt.id, (msg) => {
|
|
5920
|
+
if (!msg || typeof msg !== 'object')
|
|
5921
|
+
return;
|
|
5922
|
+
switch (msg.t) {
|
|
5923
|
+
case 'env':
|
|
5924
|
+
if (phase === 'joining')
|
|
5925
|
+
joinBuffer.push(msg.env);
|
|
5926
|
+
else
|
|
5927
|
+
sync.receive(msg.env);
|
|
5928
|
+
return;
|
|
5929
|
+
case 'hello': {
|
|
5930
|
+
if (phase !== 'live' || msg.from === sync.origin)
|
|
5931
|
+
return;
|
|
5932
|
+
// first responder wins: jittered answer, cancelled when someone else answers first
|
|
5933
|
+
const timer = setTimeout(() => {
|
|
5934
|
+
responseTimers.delete(msg.from);
|
|
5935
|
+
const snap = sync.snapshot();
|
|
5936
|
+
const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
|
|
5937
|
+
post(covered
|
|
5938
|
+
? { t: 'uptodate', to: msg.from }
|
|
5939
|
+
: { t: 'state', to: msg.from, root: snap.root, wm: snap.wm });
|
|
5940
|
+
}, Math.random() * jitterMs);
|
|
5941
|
+
responseTimers.set(msg.from, timer);
|
|
5942
|
+
return;
|
|
5943
|
+
}
|
|
5944
|
+
case 'state':
|
|
5945
|
+
case 'uptodate': {
|
|
5946
|
+
const scheduled = responseTimers.get(msg.to);
|
|
5947
|
+
if (scheduled !== undefined) {
|
|
5948
|
+
clearTimeout(scheduled);
|
|
5949
|
+
responseTimers.delete(msg.to);
|
|
5950
|
+
}
|
|
5951
|
+
if (msg.to !== sync.origin || phase !== 'joining')
|
|
5952
|
+
return;
|
|
5953
|
+
if (msg.t === 'state')
|
|
5954
|
+
sync.hydrate(msg.root, msg.wm);
|
|
5955
|
+
goLive();
|
|
5956
|
+
return;
|
|
5957
|
+
}
|
|
5958
|
+
}
|
|
5959
|
+
});
|
|
5960
|
+
const unsubEnv = sync.subscribe((env) => post({ t: 'env', env }));
|
|
5961
|
+
post({ t: 'hello', from: sync.origin, wm: sync.watermark() });
|
|
5962
|
+
helloTimer = setTimeout(goLive, helloTimeoutMs);
|
|
5963
|
+
injector.get(DestroyRef).onDestroy(() => {
|
|
5964
|
+
if (helloTimer !== undefined)
|
|
5965
|
+
clearTimeout(helloTimer);
|
|
5966
|
+
for (const timer of responseTimers.values())
|
|
5967
|
+
clearTimeout(timer);
|
|
5968
|
+
responseTimers.clear();
|
|
5969
|
+
unsubEnv();
|
|
5970
|
+
unsub();
|
|
5971
|
+
sync.destroy();
|
|
5972
|
+
});
|
|
5973
|
+
}
|
|
5190
5974
|
class MessageBus {
|
|
5191
5975
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
5192
5976
|
listeners = new Map();
|
|
@@ -5298,8 +6082,23 @@ function tabSync(sig, opt) {
|
|
|
5298
6082
|
return sig;
|
|
5299
6083
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
5300
6084
|
const bus = injector.get(MessageBus);
|
|
6085
|
+
const storeKind = sig[STORE_KIND];
|
|
6086
|
+
if (storeKind === 'writable') {
|
|
6087
|
+
storeTabSync(sig, { ...optObj, id }, bus, injector);
|
|
6088
|
+
return sig;
|
|
6089
|
+
}
|
|
6090
|
+
if (storeKind === 'readonly') {
|
|
6091
|
+
if (isDevMode()) {
|
|
6092
|
+
console.warn('[@mmstack/primitives] tabSync: a readonly store cannot receive remote ops — not synced.');
|
|
6093
|
+
}
|
|
6094
|
+
return sig;
|
|
6095
|
+
}
|
|
6096
|
+
if (storeKind === 'mutable' && isDevMode()) {
|
|
6097
|
+
console.warn('[@mmstack/primitives] tabSync: mutable stores fall back to whole-value sync (op diffing needs copy-on-write).');
|
|
6098
|
+
}
|
|
5301
6099
|
const NONE = Symbol();
|
|
5302
6100
|
let received = NONE;
|
|
6101
|
+
let last = untracked(sig);
|
|
5303
6102
|
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
5304
6103
|
const before = untracked(sig);
|
|
5305
6104
|
received = next;
|
|
@@ -5307,13 +6106,12 @@ function tabSync(sig, opt) {
|
|
|
5307
6106
|
if (untracked(sig) === before)
|
|
5308
6107
|
received = NONE;
|
|
5309
6108
|
});
|
|
5310
|
-
let firstDone = false;
|
|
5311
6109
|
const effectRef = effect(() => {
|
|
5312
6110
|
const val = sig();
|
|
5313
|
-
if (
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
6111
|
+
if (val === last)
|
|
6112
|
+
return; // unchanged since last seen → nothing to post
|
|
6113
|
+
last = val;
|
|
6114
|
+
// came from bus → don't echo
|
|
5317
6115
|
if (val === received) {
|
|
5318
6116
|
received = NONE;
|
|
5319
6117
|
return;
|
|
@@ -5528,5 +6326,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
5528
6326
|
* Generated bundle index. Do not edit.
|
|
5529
6327
|
*/
|
|
5530
6328
|
|
|
5531
|
-
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 };
|
|
6329
|
+
export { CONCURRENCY_INSTRUMENTATION, MmActivity, MmTransition, MmViewTransitionName, OP_PROTO_VERSION, PAUSABLE_OPTIONS, PERSISTED_STORE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, applyOps, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, compareHlc, compareTotal, createAttributedPending, createConvergingApply, createForwardingScope, createHlcClock, createStoreContext, createTransaction, createTransitionScope, debounce, debounced, deferredValue, derived, diffOps, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, invertBatch, isConflicted, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, keyedArray, latest, lww, map, mapArray, mapObject, mediaQuery, merge3, mergeThree, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opLog, opSync, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, perfCustomTracks, persist, persistedStore, pipeable, piped, pointerDrag, policyStrategy, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, preserve, projection, provideConcurrencyInstrumentation, provideForwardingTransitionScope, providePausableOptions, providePaused, providePersistedStoreOptions, provideTransitionScope, rebaseOps, reconcile, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, storeHistory, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, use, windowSize, withHistory };
|
|
5532
6330
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|