@mmstack/primitives 22.5.0 → 22.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 +1212 -232
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-primitives.d.ts +559 -79
|
@@ -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)
|
|
@@ -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,6 +4266,229 @@ 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);
|
|
4275
|
+
/**
|
|
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).
|
|
4279
|
+
*/
|
|
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;
|
|
4298
|
+
}
|
|
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;
|
|
4309
|
+
}
|
|
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;
|
|
4335
|
+
}
|
|
4336
|
+
/**
|
|
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.
|
|
4342
|
+
*/
|
|
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;
|
|
4355
|
+
}
|
|
4356
|
+
/**
|
|
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.
|
|
4361
|
+
*/
|
|
4362
|
+
function diffOps(prev, next) {
|
|
4363
|
+
const ops = [];
|
|
4364
|
+
diffNode(prev, next, [], ops);
|
|
4365
|
+
return ops;
|
|
4366
|
+
}
|
|
4367
|
+
/**
|
|
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`).
|
|
4406
|
+
*
|
|
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).
|
|
4411
|
+
*
|
|
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
|
+
* ```
|
|
4423
|
+
*/
|
|
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
|
+
|
|
4221
4492
|
/**
|
|
4222
4493
|
* @internal Reads (or lazily builds + caches) the child node proxy for `prop` on `target`,
|
|
4223
4494
|
* holding it via a `WeakRef` and registering it for finalizer-driven cache pruning. The cache
|
|
@@ -4296,7 +4567,11 @@ function buildChildNode(target, prop, isMutableSource, options) {
|
|
|
4296
4567
|
function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...rest } = {}) {
|
|
4297
4568
|
if (isStore(source))
|
|
4298
4569
|
return source;
|
|
4299
|
-
if
|
|
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)
|
|
4300
4575
|
injector = inject(Injector);
|
|
4301
4576
|
const writableSource = isWritableSignal(source)
|
|
4302
4577
|
? source
|
|
@@ -4315,13 +4590,17 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4315
4590
|
}, /* @ts-ignore */
|
|
4316
4591
|
...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
|
|
4317
4592
|
const STORE_OPTIONS = {
|
|
4318
|
-
|
|
4593
|
+
// may be undefined in worker/DI-less mode; unused downstream once globals are resolved
|
|
4594
|
+
// (children thread the resolved globals via STORE_SHARED_OPTIONS, derived needs no injector)
|
|
4595
|
+
injector: injector,
|
|
4319
4596
|
vivify,
|
|
4320
4597
|
noUnionLeaves,
|
|
4321
4598
|
[STORE_SHARED_GLOBALS]: {
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4599
|
+
// the `injector!` reads run only when a global is absent
|
|
4600
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
4601
|
+
cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
|
|
4602
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
4603
|
+
registry: sharedGlobals?.registry ?? injector.get(PROXY_CLEANUP_TOKEN),
|
|
4325
4604
|
},
|
|
4326
4605
|
};
|
|
4327
4606
|
// built lazily so non-array nodes never allocate it
|
|
@@ -4393,7 +4672,12 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4393
4672
|
return () => {
|
|
4394
4673
|
if (!isWritableSource)
|
|
4395
4674
|
return s;
|
|
4396
|
-
return untracked(() => toStore(source.asReadonly(), {
|
|
4675
|
+
return untracked(() => toStore(source.asReadonly(), {
|
|
4676
|
+
injector,
|
|
4677
|
+
vivify,
|
|
4678
|
+
noUnionLeaves,
|
|
4679
|
+
[STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
|
|
4680
|
+
}));
|
|
4397
4681
|
};
|
|
4398
4682
|
const k = untracked(kind);
|
|
4399
4683
|
if (prop === 'extend' && k !== 'array')
|
|
@@ -4407,8 +4691,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4407
4691
|
return arrayLength();
|
|
4408
4692
|
if (prop === Symbol.iterator)
|
|
4409
4693
|
return function* () {
|
|
4410
|
-
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
4411
|
-
// when items are added or removed, not only when already-read elements change
|
|
4412
4694
|
const len = arrayLength();
|
|
4413
4695
|
for (let i = 0; i < len(); i++)
|
|
4414
4696
|
yield receiver[i];
|
|
@@ -4559,6 +4841,40 @@ function mutableStore(value, opt) {
|
|
|
4559
4841
|
...opt,
|
|
4560
4842
|
});
|
|
4561
4843
|
}
|
|
4844
|
+
/**
|
|
4845
|
+
* Builds a DI-less store context — the shared proxy-cache and cleanup registry that {@link toStore}
|
|
4846
|
+
* normally resolves from the injector — so a `store`/`toStore`/`opLog` graph can run with NO Angular
|
|
4847
|
+
* injection context. Spread the result into the options:
|
|
4848
|
+
*
|
|
4849
|
+
* ```ts
|
|
4850
|
+
* import { microtaskOpLogDriver } from '@mmstack/worker/host';
|
|
4851
|
+
* const ctx = createStoreContext();
|
|
4852
|
+
* const s = store({ todos: [] }, ctx);
|
|
4853
|
+
* const log = opLog(s, { driver: microtaskOpLogDriver(), origin: 'worker' }); // no injector anywhere
|
|
4854
|
+
* ```
|
|
4855
|
+
*
|
|
4856
|
+
* **This is a worker-only fallback — do NOT use it on the main thread.** DI is the default and
|
|
4857
|
+
* correct path in an app: the injector scopes the proxy-cache/cleanup singletons per app instance,
|
|
4858
|
+
* which on the SERVER keeps one request's store identity from bleeding into another's (the exact
|
|
4859
|
+
* hazard a module-scope singleton would reintroduce). A Web Worker is safe because it is a single
|
|
4860
|
+
* store graph per thread and never runs during SSR (spawn is a `PLATFORM_ID === 'server'` no-op),
|
|
4861
|
+
* so there is no cross-request scope to contaminate. Never hoist a `createStoreContext()` to module
|
|
4862
|
+
* scope on a shared/main thread.
|
|
4863
|
+
*
|
|
4864
|
+
* **Share ONE context across every store in a worker** — the same way `providedIn: 'root'` shares
|
|
4865
|
+
* one cache across all of an app's stores. `@mmstack/worker/host` memoizes this per worker
|
|
4866
|
+
* (`workerStoreContext()`); reach for `createStoreContext()` directly only in a bare
|
|
4867
|
+
* (non-worker-host) DI-less setup, and hold the single instance yourself.
|
|
4868
|
+
*/
|
|
4869
|
+
function createStoreContext() {
|
|
4870
|
+
const cache = new WeakMap();
|
|
4871
|
+
const registry = new FinalizationRegistry(({ target, prop }) => {
|
|
4872
|
+
const entry = cache.get(target);
|
|
4873
|
+
if (entry)
|
|
4874
|
+
entry.delete(prop);
|
|
4875
|
+
});
|
|
4876
|
+
return { [STORE_SHARED_GLOBALS]: { cache, registry } };
|
|
4877
|
+
}
|
|
4562
4878
|
|
|
4563
4879
|
function isPlainRecord(value) {
|
|
4564
4880
|
if (value === null || typeof value !== 'object')
|
|
@@ -4619,9 +4935,6 @@ function forkStore(base, opt) {
|
|
|
4619
4935
|
const merge = reconcile;
|
|
4620
4936
|
const staged = linkedSignal({ ...(ngDevMode ? { debugName: "staged" } : /* istanbul ignore next */ {}), source: () => base(),
|
|
4621
4937
|
computation: (theirs, prev) => prev === undefined ? theirs : merge(prev.source, prev.value, theirs) });
|
|
4622
|
-
// Inherit the base's shared options (injector, vivify, noUnionLeaves + the
|
|
4623
|
-
// proxy cache/registry), same as extendStore — a fork should vivify like its
|
|
4624
|
-
// base and share its injector-scoped cache. `opt` overrides (advanced use).
|
|
4625
4938
|
const store = toStore(staged, {
|
|
4626
4939
|
...base[STORE_SHARED_OPTIONS],
|
|
4627
4940
|
...opt,
|
|
@@ -4630,193 +4943,767 @@ function forkStore(base, opt) {
|
|
|
4630
4943
|
store,
|
|
4631
4944
|
commit: () => base.set(untracked(staged)),
|
|
4632
4945
|
discard: () => staged.set(untracked(base)),
|
|
4946
|
+
ops: () => diffOps(untracked(base), untracked(staged)),
|
|
4633
4947
|
};
|
|
4634
4948
|
}
|
|
4635
4949
|
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
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
|
+
};
|
|
4640
4992
|
}
|
|
4641
|
-
|
|
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 });
|
|
4642
5002
|
/**
|
|
4643
|
-
*
|
|
4644
|
-
* an
|
|
4645
|
-
*
|
|
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.
|
|
4646
5011
|
*/
|
|
4647
|
-
function
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
if (!Object.hasOwn(next, key))
|
|
4653
|
-
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
|
|
4654
5017
|
}
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
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));
|
|
4659
5037
|
}
|
|
4660
|
-
else {
|
|
4661
|
-
|
|
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
|
|
4662
5040
|
}
|
|
5041
|
+
// else: theirs removed it and mine left it untouched → stays removed
|
|
4663
5042
|
}
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
diffNode(prev[i], next[i], [...path, i], ops);
|
|
4672
|
-
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
|
+
}
|
|
4673
5050
|
}
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
}
|
|
4677
|
-
// leaf / type change / opaque — one unit, prev present (the slot existed)
|
|
4678
|
-
ops.push({ kind: 'set', path, prev, next });
|
|
5051
|
+
return out;
|
|
5052
|
+
};
|
|
4679
5053
|
}
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
delete base[seg];
|
|
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;
|
|
4694
5067
|
}
|
|
4695
|
-
|
|
4696
|
-
|
|
5068
|
+
return p.merge;
|
|
5069
|
+
}
|
|
5070
|
+
return lww;
|
|
5071
|
+
}
|
|
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;
|
|
4697
5093
|
}
|
|
4698
|
-
return base;
|
|
4699
5094
|
}
|
|
4700
|
-
|
|
4701
|
-
return base;
|
|
5095
|
+
return true;
|
|
4702
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;
|
|
4703
5106
|
/**
|
|
4704
|
-
*
|
|
4705
|
-
*
|
|
4706
|
-
*
|
|
4707
|
-
* carry — a wire-serialized batch that stripped them is not invertible.
|
|
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.
|
|
4708
5110
|
*/
|
|
4709
|
-
function
|
|
4710
|
-
const
|
|
4711
|
-
const
|
|
4712
|
-
|
|
4713
|
-
const
|
|
4714
|
-
if (
|
|
4715
|
-
|
|
4716
|
-
continue;
|
|
4717
|
-
}
|
|
4718
|
-
if (!Object.hasOwn(op, 'prev')) {
|
|
4719
|
-
inverted.push({ kind: 'delete', path: op.path, prev: op.next });
|
|
4720
|
-
}
|
|
4721
|
-
else {
|
|
4722
|
-
inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: op.next });
|
|
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;
|
|
4723
5118
|
}
|
|
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];
|
|
4724
5201
|
}
|
|
4725
|
-
return
|
|
5202
|
+
return cur;
|
|
4726
5203
|
}
|
|
4727
5204
|
/**
|
|
4728
|
-
*
|
|
4729
|
-
*
|
|
4730
|
-
*
|
|
4731
|
-
* batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
|
|
4732
|
-
*
|
|
4733
|
-
* Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
|
|
4734
|
-
* of the root value per tick (structural sharing makes it O(changed paths)), driven by one
|
|
4735
|
-
* effect. A batch therefore coalesces everything written in one tick — for coarser,
|
|
4736
|
-
* intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
|
|
4737
|
-
*
|
|
4738
|
-
* NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
|
|
4739
|
-
* defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
|
|
4740
|
-
* warning fires and nothing emits.
|
|
4741
|
-
*
|
|
4742
|
-
* ```ts
|
|
4743
|
-
* const s = store({ todos: [{ done: false }] });
|
|
4744
|
-
* const log = opLog(s, { origin: 'tab-a' });
|
|
4745
|
-
* log.subscribe((b) => channel.postMessage(encode(b))); // ship
|
|
4746
|
-
* channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
|
|
4747
|
-
* s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
|
|
4748
|
-
* ```
|
|
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.
|
|
4749
5208
|
*/
|
|
4750
|
-
function
|
|
4751
|
-
const
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
const storeKind = source[STORE_KIND];
|
|
4756
|
-
const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
|
|
4757
|
-
if (isDevMode() && mutableSource) {
|
|
4758
|
-
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.');
|
|
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]));
|
|
4759
5214
|
}
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
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
|
+
}
|
|
5235
|
+
}
|
|
5236
|
+
base = applyOps(base, next);
|
|
5237
|
+
rebased.push(next);
|
|
5238
|
+
}
|
|
5239
|
+
return { root: base, pending: rebased };
|
|
5240
|
+
}
|
|
5241
|
+
/**
|
|
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'`.
|
|
5247
|
+
*/
|
|
5248
|
+
function policyStrategy(policies) {
|
|
5249
|
+
return (ancestor, mine, theirs) => rebaseOps(mine, [diffOps(ancestor, mine)], diffOps(ancestor, theirs), policies).root;
|
|
5250
|
+
}
|
|
5251
|
+
function generateOrigin() {
|
|
5252
|
+
if (globalThis.crypto?.randomUUID)
|
|
5253
|
+
return globalThis.crypto.randomUUID();
|
|
5254
|
+
return Math.random().toString(36).substring(2);
|
|
5255
|
+
}
|
|
5256
|
+
/**
|
|
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.
|
|
5260
|
+
*/
|
|
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 });
|
|
4763
5266
|
const subscribers = new Set();
|
|
4764
|
-
const
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
const
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
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();
|
|
4780
5288
|
for (const cb of [...subscribers])
|
|
4781
|
-
cb(
|
|
5289
|
+
cb(env);
|
|
4782
5290
|
};
|
|
4783
|
-
const
|
|
4784
|
-
source(); // track every commit…
|
|
4785
|
-
untracked(flush); // …and emit the delta since the last flush
|
|
4786
|
-
}, { ...(ngDevMode ? { debugName: "ref" } : /* istanbul ignore next */ {}), injector: opt?.injector });
|
|
5291
|
+
const unsub = log.subscribe((batch) => emitLocal(batch.ops));
|
|
4787
5292
|
return {
|
|
4788
|
-
|
|
5293
|
+
origin,
|
|
4789
5294
|
subscribe: (cb) => {
|
|
4790
5295
|
subscribers.add(cb);
|
|
4791
5296
|
return () => subscribers.delete(cb);
|
|
4792
5297
|
},
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
? batchOrOps
|
|
4796
|
-
: batchOrOps.ops;
|
|
4797
|
-
if (!ops.length)
|
|
5298
|
+
receive: (env) => {
|
|
5299
|
+
if (env.origin === origin)
|
|
4798
5300
|
return;
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
for (const op of ops) {
|
|
4803
|
-
if (op.path.length === 0) {
|
|
4804
|
-
if (op.kind === 'set')
|
|
4805
|
-
root = op.next;
|
|
4806
|
-
continue; // a root delete is meaningless — ignore
|
|
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})`);
|
|
4807
5304
|
}
|
|
4808
|
-
|
|
5305
|
+
return;
|
|
4809
5306
|
}
|
|
4810
|
-
|
|
4811
|
-
|
|
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 });
|
|
4812
5344
|
},
|
|
4813
5345
|
destroy: () => {
|
|
4814
|
-
|
|
5346
|
+
unsub();
|
|
4815
5347
|
subscribers.clear();
|
|
4816
|
-
|
|
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
|
+
}
|
|
4817
5603
|
},
|
|
4818
5604
|
};
|
|
4819
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
|
+
}
|
|
5619
|
+
|
|
5620
|
+
const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
|
|
5621
|
+
function keyOf(item, key) {
|
|
5622
|
+
if (typeof key === 'function')
|
|
5623
|
+
return key(item);
|
|
5624
|
+
return isRecord(item) ? item[key] : item;
|
|
5625
|
+
}
|
|
5626
|
+
/**
|
|
5627
|
+
* Produces a value equal to `next` but sharing as much of `prev`'s reference structure as possible:
|
|
5628
|
+
* an object subtree that did not change keeps its `prev` reference, and array items are matched by
|
|
5629
|
+
* `key` so a surviving item keeps its identity across a reorder/insert/remove (only added items are
|
|
5630
|
+
* new, only removed items are dropped). This is what lets a derived store recompute without tearing
|
|
5631
|
+
* down every downstream `computed` that reads an unchanged part of it.
|
|
5632
|
+
*/
|
|
5633
|
+
function reconcile(prev, next, key = 'id') {
|
|
5634
|
+
return reconcileValue(prev, next, key);
|
|
5635
|
+
}
|
|
5636
|
+
function reconcileValue(prev, next, key) {
|
|
5637
|
+
if (Object.is(prev, next))
|
|
5638
|
+
return prev;
|
|
5639
|
+
if (isPlainArray(prev) && isPlainArray(next)) {
|
|
5640
|
+
const byKey = new Map();
|
|
5641
|
+
for (const item of prev)
|
|
5642
|
+
byKey.set(keyOf(item, key), item);
|
|
5643
|
+
let changed = prev.length !== next.length;
|
|
5644
|
+
const out = next.map((item, i) => {
|
|
5645
|
+
const match = byKey.get(keyOf(item, key));
|
|
5646
|
+
const rv = match !== undefined ? reconcileValue(match, item, key) : item;
|
|
5647
|
+
if (rv !== prev[i])
|
|
5648
|
+
changed = true;
|
|
5649
|
+
return rv;
|
|
5650
|
+
});
|
|
5651
|
+
return changed ? out : prev;
|
|
5652
|
+
}
|
|
5653
|
+
if (isRecord(prev) && isRecord(next)) {
|
|
5654
|
+
const nextKeys = Object.keys(next);
|
|
5655
|
+
let changed = Object.keys(prev).length !== nextKeys.length;
|
|
5656
|
+
const out = {};
|
|
5657
|
+
for (const k of nextKeys) {
|
|
5658
|
+
const rv = Object.hasOwn(prev, k)
|
|
5659
|
+
? reconcileValue(prev[k], next[k], key)
|
|
5660
|
+
: next[k];
|
|
5661
|
+
out[k] = rv;
|
|
5662
|
+
if (rv !== prev[k])
|
|
5663
|
+
changed = true;
|
|
5664
|
+
}
|
|
5665
|
+
return changed ? out : prev;
|
|
5666
|
+
}
|
|
5667
|
+
return next;
|
|
5668
|
+
}
|
|
5669
|
+
/**
|
|
5670
|
+
* A derived STORE, the store-shaped counterpart to `computed`. `fn` receives a mutable draft seeded
|
|
5671
|
+
* with the current value and either mutates it in place or returns a new value; whichever it does,
|
|
5672
|
+
* the result is reconciled against the previous value (see {@link reconcile}) so unchanged subtrees
|
|
5673
|
+
* keep reference identity and keyed array items keep their proxy identity. Reading through the
|
|
5674
|
+
* returned store is fine-grained: a `computed` over one field only recomputes when that field
|
|
5675
|
+
* actually changes, even though the whole projection re-ran.
|
|
5676
|
+
*
|
|
5677
|
+
* Recompute is pull-based, exactly like `computed`: the projection is memoized and re-runs on the
|
|
5678
|
+
* first read after a signal `fn` depends on changes, so reads are always coherent (no waiting on an
|
|
5679
|
+
* effect flush) and nothing recomputes while nobody reads. `fn` must be pure, it runs inside the
|
|
5680
|
+
* reactive computation. Prefer `computed` for a plain value; reach for `projection` when you want
|
|
5681
|
+
* the per-property tracking of a store on top of a derivation.
|
|
5682
|
+
*
|
|
5683
|
+
* ```ts
|
|
5684
|
+
* const active = projection<User[]>(() => users().filter((u) => u.active), [], { key: 'id' });
|
|
5685
|
+
* // active[0].name(); — surviving users keep identity across recomputes
|
|
5686
|
+
* ```
|
|
5687
|
+
*
|
|
5688
|
+
* Needs an injection context (or an explicit `injector`) for the store layer's cleanup on the main
|
|
5689
|
+
* thread; with an explicit store context (`createStoreContext()`) it is injector-free, so it also
|
|
5690
|
+
* runs on a worker host.
|
|
5691
|
+
*
|
|
5692
|
+
* @param fn receives the current draft; mutate it, or return new data.
|
|
5693
|
+
* @param seed the initial value, held before the first run.
|
|
5694
|
+
*/
|
|
5695
|
+
function projection(fn, seed, opt) {
|
|
5696
|
+
const { key = 'id', ...storeOpt } = opt ?? {};
|
|
5697
|
+
const root = linkedSignal({ ...(ngDevMode ? { debugName: "root" } : /* istanbul ignore next */ {}), source: () => undefined,
|
|
5698
|
+
computation: (_, previous) => {
|
|
5699
|
+
const base = previous ? previous.value : seed;
|
|
5700
|
+
const draft = structuredClone(base);
|
|
5701
|
+
const returned = fn(draft);
|
|
5702
|
+
const next = (returned === undefined ? draft : returned);
|
|
5703
|
+
return reconcile(base, next, key);
|
|
5704
|
+
} });
|
|
5705
|
+
return toStore(root, storeOpt).asReadonlyStore();
|
|
5706
|
+
}
|
|
4820
5707
|
|
|
4821
5708
|
/**
|
|
4822
5709
|
* @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
|
|
@@ -5005,6 +5892,85 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
5005
5892
|
return writable;
|
|
5006
5893
|
}
|
|
5007
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
|
+
}
|
|
5008
5974
|
class MessageBus {
|
|
5009
5975
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
5010
5976
|
listeners = new Map();
|
|
@@ -5116,6 +6082,20 @@ function tabSync(sig, opt) {
|
|
|
5116
6082
|
return sig;
|
|
5117
6083
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
5118
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
|
+
}
|
|
5119
6099
|
const NONE = Symbol();
|
|
5120
6100
|
let received = NONE;
|
|
5121
6101
|
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
@@ -5346,5 +6326,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
5346
6326
|
* Generated bundle index. Do not edit.
|
|
5347
6327
|
*/
|
|
5348
6328
|
|
|
5349
|
-
export { MmActivity, MmTransition, MmViewTransitionName, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, createAttributedPending, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, deferredValue, derived, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, invertBatch, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, latest, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opLog, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pointerDrag, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePausableOptions, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, use, windowSize, withHistory };
|
|
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 };
|
|
5350
6330
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|