@mmstack/primitives 20.10.0 → 20.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -2
- package/fesm2022/mmstack-primitives.mjs +1213 -230
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +552 -79
- package/package.json +1 -1
|
@@ -208,8 +208,7 @@ class MmActivity {
|
|
|
208
208
|
if (this.onServer)
|
|
209
209
|
return;
|
|
210
210
|
for (const node of this.view.rootNodes) {
|
|
211
|
-
// covers HTML and SVG roots; text/comment roots can't be styled
|
|
212
|
-
// detached, but prefer an element root for true visual hiding
|
|
211
|
+
// covers HTML and SVG roots; text/comment roots can't be styled, their CD is still detached
|
|
213
212
|
if (node instanceof HTMLElement || node instanceof SVGElement)
|
|
214
213
|
node.style.display = visible ? '' : 'none';
|
|
215
214
|
}
|
|
@@ -227,8 +226,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
|
|
|
227
226
|
selector: '[mmActivity]',
|
|
228
227
|
}]
|
|
229
228
|
}], ctorParameters: () => [], propDecorators: { visible: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmActivity", required: true }] }] } });
|
|
230
|
-
// Shared never-paused signal returned outside a boundary / on the server (SSR renders the full tree,
|
|
231
|
-
// nothing is paused). Readonly so a consumer can't cast-and-`.set()` the shared default for everyone.
|
|
232
229
|
const NEVER_PAUSED = signal(false).asReadonly();
|
|
233
230
|
/**
|
|
234
231
|
* Inject the nearest paused-state signal — `true` while the surrounding subtree is paused (hidden by
|
|
@@ -493,7 +490,7 @@ function deferredValue(source, opt) {
|
|
|
493
490
|
let cancel = null;
|
|
494
491
|
const watch = effect(() => {
|
|
495
492
|
const v = source();
|
|
496
|
-
cancel?.();
|
|
493
|
+
cancel?.();
|
|
497
494
|
cancel = schedule(() => {
|
|
498
495
|
cancel = null;
|
|
499
496
|
out.set(v);
|
|
@@ -505,8 +502,6 @@ function deferredValue(source, opt) {
|
|
|
505
502
|
cancel = null;
|
|
506
503
|
});
|
|
507
504
|
const result = computed(() => out());
|
|
508
|
-
// "behind" is a value comparison, not a schedule flag: an equal-valued catch-up
|
|
509
|
-
// (e.g. type a char, delete it before the deferred view caught up) is not pending
|
|
510
505
|
result.pending = computed(() => !equal(out(), source()), ...(ngDevMode ? [{ debugName: "pending" }] : []));
|
|
511
506
|
return result;
|
|
512
507
|
}
|
|
@@ -530,6 +525,46 @@ function resolveScheduler(strategy, injector) {
|
|
|
530
525
|
};
|
|
531
526
|
}
|
|
532
527
|
|
|
528
|
+
const CONCURRENCY_INSTRUMENTATION = new InjectionToken('@mmstack/primitives:concurrency-instrumentation');
|
|
529
|
+
function provideConcurrencyInstrumentation(listener) {
|
|
530
|
+
return { provide: CONCURRENCY_INSTRUMENTATION, useValue: listener };
|
|
531
|
+
}
|
|
532
|
+
const now = () => typeof globalThis.performance !== 'undefined'
|
|
533
|
+
? globalThis.performance.now()
|
|
534
|
+
: Date.now();
|
|
535
|
+
/**
|
|
536
|
+
* Chrome DevTools "Performance" custom-tracks preset (idea/concurrency-devtools.md): writes a
|
|
537
|
+
* `performance.measure` for each pending/transaction window onto an "mmstack" extension track,
|
|
538
|
+
* so reactive coordination shows up on the Performance panel timeline. Dev-only, zero backend,
|
|
539
|
+
* no dependencies. Give each measure the scope name for readability.
|
|
540
|
+
*/
|
|
541
|
+
function perfCustomTracks(track = 'mmstack concurrency') {
|
|
542
|
+
const canMeasure = typeof globalThis.performance !== 'undefined' &&
|
|
543
|
+
typeof globalThis.performance.measure === 'function';
|
|
544
|
+
const span = (name, start) => {
|
|
545
|
+
if (!canMeasure)
|
|
546
|
+
return;
|
|
547
|
+
try {
|
|
548
|
+
globalThis.performance.measure(name, {
|
|
549
|
+
start,
|
|
550
|
+
end: now(),
|
|
551
|
+
detail: {
|
|
552
|
+
devtools: { dataType: 'track-entry', track, color: 'primary' },
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// measure options with detail are unsupported on this engine — skip silently
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
return {
|
|
561
|
+
pendingStart: (e) => e.at,
|
|
562
|
+
pendingEnd: (handle, e) => span(`pending`, handle ?? e.at),
|
|
563
|
+
transactionStart: (e) => e.at,
|
|
564
|
+
transactionEnd: (handle, e) => span(`transaction`, handle ?? e.at),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
533
568
|
/**
|
|
534
569
|
* Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
|
|
535
570
|
* subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
|
|
@@ -604,8 +639,13 @@ function isMutable(value) {
|
|
|
604
639
|
return 'mutate' in value && typeof value.mutate === 'function';
|
|
605
640
|
}
|
|
606
641
|
|
|
607
|
-
function createTransitionScope() {
|
|
642
|
+
function createTransitionScope(opt) {
|
|
608
643
|
const list = mutable([]);
|
|
644
|
+
const inst = opt?.instrumentation;
|
|
645
|
+
const name = opt?.name ?? 'scope';
|
|
646
|
+
const at = () => typeof globalThis.performance !== 'undefined'
|
|
647
|
+
? globalThis.performance.now()
|
|
648
|
+
: Date.now();
|
|
609
649
|
const pending = computed(() => list().some(({ ref }) => {
|
|
610
650
|
const s = ref.status();
|
|
611
651
|
return s === 'loading' || s === 'reloading';
|
|
@@ -616,11 +656,17 @@ function createTransitionScope() {
|
|
|
616
656
|
resources: computed(() => list().map((e) => e.ref)),
|
|
617
657
|
pending,
|
|
618
658
|
suspended: (type) => list().some(({ ref, suspends }) => suspends && (type === 'loading' ? ref.isLoading() : !ref.hasValue())),
|
|
619
|
-
add: (ref,
|
|
659
|
+
add: (ref, o) => untracked(() => {
|
|
660
|
+
const suspends = o?.suspends ?? true;
|
|
661
|
+
list.inline((c) => c.push({ ref, suspends }));
|
|
662
|
+
inst?.resourceRegistered?.({ scope: name, suspends });
|
|
663
|
+
}),
|
|
620
664
|
remove: (ref) => untracked(() => list.inline((c) => {
|
|
621
665
|
const i = c.findIndex((e) => e.ref === ref);
|
|
622
|
-
if (i !== -1)
|
|
666
|
+
if (i !== -1) {
|
|
623
667
|
c.splice(i, 1);
|
|
668
|
+
inst?.resourceRemoved?.({ scope: name });
|
|
669
|
+
}
|
|
624
670
|
})),
|
|
625
671
|
commit: (value) => linkedSignal({
|
|
626
672
|
source: () => ({ v: value(), settled: !pending() }),
|
|
@@ -635,6 +681,8 @@ function createTransitionScope() {
|
|
|
635
681
|
aborted++;
|
|
636
682
|
}
|
|
637
683
|
}
|
|
684
|
+
if (aborted > 0)
|
|
685
|
+
inst?.abortPending?.({ scope: name, aborted, at: at() });
|
|
638
686
|
return aborted;
|
|
639
687
|
}),
|
|
640
688
|
holding,
|
|
@@ -703,13 +751,59 @@ function bridgeScopeToPendingTasks(scope, injector) {
|
|
|
703
751
|
});
|
|
704
752
|
});
|
|
705
753
|
}
|
|
754
|
+
/**
|
|
755
|
+
* While a listener is installed, bracket each pending window of `scope` with a
|
|
756
|
+
* `pendingStart`/`pendingEnd` span (the reactive tap that needs an injection context). No-op
|
|
757
|
+
* when no listener is provided, so it stays zero-cost by default.
|
|
758
|
+
*/
|
|
759
|
+
function bridgeScopeToInstrumentation(scope, name, injector) {
|
|
760
|
+
const run = (fn) => injector ? runInInjectionContext(injector, fn) : fn();
|
|
761
|
+
run(() => {
|
|
762
|
+
const inst = inject(CONCURRENCY_INSTRUMENTATION, { optional: true });
|
|
763
|
+
if (!inst?.pendingStart && !inst?.pendingEnd)
|
|
764
|
+
return;
|
|
765
|
+
const at = () => typeof globalThis.performance !== 'undefined'
|
|
766
|
+
? globalThis.performance.now()
|
|
767
|
+
: Date.now();
|
|
768
|
+
let handle;
|
|
769
|
+
let open = false;
|
|
770
|
+
effect(() => {
|
|
771
|
+
const pending = scope.pending();
|
|
772
|
+
untracked(() => {
|
|
773
|
+
if (pending && !open) {
|
|
774
|
+
open = true;
|
|
775
|
+
handle = inst.pendingStart?.({
|
|
776
|
+
scope: name,
|
|
777
|
+
resources: scope.resources().length,
|
|
778
|
+
at: at(),
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
else if (!pending && open) {
|
|
782
|
+
open = false;
|
|
783
|
+
inst.pendingEnd?.(handle, { at: at() });
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
inject(DestroyRef).onDestroy(() => {
|
|
788
|
+
if (open)
|
|
789
|
+
inst.pendingEnd?.(handle, { at: at() });
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
}
|
|
706
793
|
/** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
|
|
707
|
-
function provideTransitionScope() {
|
|
794
|
+
function provideTransitionScope(opt) {
|
|
708
795
|
return {
|
|
709
796
|
provide: TRANSITION_SCOPE,
|
|
710
797
|
useFactory: () => {
|
|
711
|
-
const
|
|
798
|
+
const listener = opt?.instrumentation ??
|
|
799
|
+
inject(CONCURRENCY_INSTRUMENTATION, { optional: true }) ??
|
|
800
|
+
undefined;
|
|
801
|
+
const scope = createTransitionScope({
|
|
802
|
+
name: opt?.name,
|
|
803
|
+
instrumentation: listener,
|
|
804
|
+
});
|
|
712
805
|
bridgeScopeToPendingTasks(scope);
|
|
806
|
+
bridgeScopeToInstrumentation(scope, opt?.name ?? 'scope');
|
|
713
807
|
return scope;
|
|
714
808
|
},
|
|
715
809
|
};
|
|
@@ -857,8 +951,6 @@ function use(res) {
|
|
|
857
951
|
frame.seen.add(res);
|
|
858
952
|
frame.deps.push(res);
|
|
859
953
|
}
|
|
860
|
-
// status() is read tracked even on the short-circuit paths, so the owning computed
|
|
861
|
-
// re-evaluates when the load settles / the error clears.
|
|
862
954
|
if (res.status() === 'error') {
|
|
863
955
|
frame.errors.push(res.error?.());
|
|
864
956
|
throw BLOCKED;
|
|
@@ -906,9 +998,6 @@ function latest(fn, opt) {
|
|
|
906
998
|
}
|
|
907
999
|
}, opt?.debugName ? { debugName: `${opt.debugName}:evaluation` } : undefined);
|
|
908
1000
|
const equal = opt?.equal ?? Object.is;
|
|
909
|
-
// The stale-while-revalidate atom: holds the last successful result through blocked /
|
|
910
|
-
// errored rounds. `equal` gates notification, so an in-flight cycle that lands on an
|
|
911
|
-
// equal value never ripples to consumers — while `pending` (independent) still cycles.
|
|
912
1001
|
const held = linkedSignal(...(ngDevMode ? [{ debugName: "held", source: evaluation,
|
|
913
1002
|
computation: (ev, prev) => ev.kind === 'value'
|
|
914
1003
|
? { has: true, v: ev.value }
|
|
@@ -980,8 +1069,7 @@ function injectStartTransition() {
|
|
|
980
1069
|
const destroyRef = inject(DestroyRef);
|
|
981
1070
|
const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
|
|
982
1071
|
return (fn) => {
|
|
983
|
-
// attributed: loads already in flight when the transition starts are not ours
|
|
984
|
-
// they can neither settle this transition early nor block it forever
|
|
1072
|
+
// attributed: loads already in flight when the transition starts are not ours
|
|
985
1073
|
const pending = createAttributedPending(scope);
|
|
986
1074
|
untracked(fn);
|
|
987
1075
|
let sawPending = false;
|
|
@@ -1006,8 +1094,7 @@ function injectStartTransition() {
|
|
|
1006
1094
|
settle();
|
|
1007
1095
|
return;
|
|
1008
1096
|
}
|
|
1009
|
-
// no-async fallback: once the reactive system has processed the writes
|
|
1010
|
-
// if nothing ever went in flight, the transition is already complete.
|
|
1097
|
+
// no-async fallback: once the reactive system has processed the writes,
|
|
1011
1098
|
afterNextRender(() => {
|
|
1012
1099
|
if (!sawPending && !untracked(pending))
|
|
1013
1100
|
settle();
|
|
@@ -1112,9 +1199,6 @@ function createTransaction() {
|
|
|
1112
1199
|
clear: () => log.clear(),
|
|
1113
1200
|
};
|
|
1114
1201
|
}
|
|
1115
|
-
// The currently-active transaction, set only for the synchronous duration of a `startTransaction`
|
|
1116
|
-
// body (so stateful actions running inside it can record their writes). Module-level + sync
|
|
1117
|
-
// set/reset is the honest shape: a transaction is call-scoped, not structural-per-injector.
|
|
1118
1202
|
let active = null;
|
|
1119
1203
|
/** The transaction in effect right now, or `null`. Stateful actions consult this to record undo. */
|
|
1120
1204
|
function activeTransaction() {
|
|
@@ -1153,10 +1237,7 @@ function injectStartTransaction() {
|
|
|
1153
1237
|
const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
|
|
1154
1238
|
return (fn) => {
|
|
1155
1239
|
const txn = createTransaction();
|
|
1156
|
-
// attributed: loads already in flight when the transaction starts are not ours —
|
|
1157
|
-
// they can neither commit this transaction early nor block its settle forever
|
|
1158
1240
|
const pending = createAttributedPending(scope);
|
|
1159
|
-
// Hold BEFORE the writes, so the display freezes at pre-transaction values.
|
|
1160
1241
|
scope.beginHold();
|
|
1161
1242
|
let finished = false;
|
|
1162
1243
|
// eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
|
|
@@ -1165,9 +1246,6 @@ function injectStartTransaction() {
|
|
|
1165
1246
|
const done = new Promise((resolve) => {
|
|
1166
1247
|
resolveDone = resolve;
|
|
1167
1248
|
});
|
|
1168
|
-
// Every exit path funnels through here, so `done` always settles — including `abort()`
|
|
1169
|
-
// and a throwing transaction body (which would otherwise leak the hold forever and
|
|
1170
|
-
// freeze the boundary with no recovery).
|
|
1171
1249
|
const finish = (restore) => {
|
|
1172
1250
|
if (finished)
|
|
1173
1251
|
return;
|
|
@@ -1181,9 +1259,6 @@ function injectStartTransaction() {
|
|
|
1181
1259
|
scope.endHold();
|
|
1182
1260
|
resolveDone();
|
|
1183
1261
|
};
|
|
1184
|
-
// The scope may outlive the calling context (a component transacting on an ancestor
|
|
1185
|
-
// boundary): a destroy mid-flight kills the settle watcher, so without this the hold
|
|
1186
|
-
// would leak and freeze the surviving scope forever. Keep the writes — they landed live.
|
|
1187
1262
|
const releaseDestroy = destroyRef.onDestroy(() => finish(false));
|
|
1188
1263
|
try {
|
|
1189
1264
|
runInTransaction(txn, fn);
|
|
@@ -1205,7 +1280,7 @@ function injectStartTransaction() {
|
|
|
1205
1280
|
finish(false);
|
|
1206
1281
|
}
|
|
1207
1282
|
else {
|
|
1208
|
-
// no-async fallback
|
|
1283
|
+
// no-async fallback
|
|
1209
1284
|
afterNextRender(() => {
|
|
1210
1285
|
if (!sawPending && !untracked(pending))
|
|
1211
1286
|
finish(false);
|
|
@@ -1283,7 +1358,6 @@ class MmTransition {
|
|
|
1283
1358
|
}
|
|
1284
1359
|
onValue(v) {
|
|
1285
1360
|
if (!this.current) {
|
|
1286
|
-
// first render: nothing to hold yet — show immediately (also what SSR serializes)
|
|
1287
1361
|
this.current = this.createView(v).view;
|
|
1288
1362
|
return;
|
|
1289
1363
|
}
|
|
@@ -1297,8 +1371,7 @@ class MmTransition {
|
|
|
1297
1371
|
const { view, scope } = this.createView(v);
|
|
1298
1372
|
this.setHidden(view, true);
|
|
1299
1373
|
this.holding.set(true);
|
|
1300
|
-
// Registration happens synchronously during view creation, so a resource already
|
|
1301
|
-
// flight counts from the start; later kickoffs are caught by the watcher.
|
|
1374
|
+
// Registration happens synchronously during view creation, so a resource already incl. later kickoffs are caught by the watcher.
|
|
1302
1375
|
let sawPending = untracked(scope.pending);
|
|
1303
1376
|
const watcher = effect(() => {
|
|
1304
1377
|
const pending = scope.pending();
|
|
@@ -1355,8 +1428,6 @@ class MmTransition {
|
|
|
1355
1428
|
this.holding.set(false);
|
|
1356
1429
|
}
|
|
1357
1430
|
createView(v) {
|
|
1358
|
-
// Each view gets its own scope, so its subtree's resources register here by existing —
|
|
1359
|
-
// and the outgoing view's background work can't block the swap (per-view isolation).
|
|
1360
1431
|
const injector = Injector.create({
|
|
1361
1432
|
parent: this.parent,
|
|
1362
1433
|
providers: [provideTransitionScope()],
|
|
@@ -1442,7 +1513,7 @@ function getSignalEquality(sig) {
|
|
|
1442
1513
|
if (internal && typeof internal.equal === 'function') {
|
|
1443
1514
|
return internal.equal;
|
|
1444
1515
|
}
|
|
1445
|
-
return Object.is;
|
|
1516
|
+
return Object.is;
|
|
1446
1517
|
}
|
|
1447
1518
|
|
|
1448
1519
|
/**
|
|
@@ -1590,8 +1661,6 @@ function isIndexProp(prop) {
|
|
|
1590
1661
|
return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
|
|
1591
1662
|
}
|
|
1592
1663
|
|
|
1593
|
-
// Container resolvers used by createVivify: each returns the current value when present and
|
|
1594
|
-
// only creates a new container when it is null/undefined.
|
|
1595
1664
|
function identity(x) {
|
|
1596
1665
|
return x;
|
|
1597
1666
|
}
|
|
@@ -1749,11 +1818,6 @@ function derived(source, optOrKey, opt) {
|
|
|
1749
1818
|
cnt++;
|
|
1750
1819
|
try {
|
|
1751
1820
|
sig.update(updater);
|
|
1752
|
-
// The wrapped computed evaluates its `equal` lazily — at the next read, which would
|
|
1753
|
-
// normally happen after `cnt` has already dropped back to 0. For a reference-stable
|
|
1754
|
-
// mutation that read compares the same object to itself and the version never bumps,
|
|
1755
|
-
// so dependents are never notified. Reading here, while equality is still suppressed,
|
|
1756
|
-
// forces the recompute (and version bump) inside the mutate window.
|
|
1757
1821
|
untracked(sig);
|
|
1758
1822
|
}
|
|
1759
1823
|
finally {
|
|
@@ -1841,8 +1905,6 @@ function createSetter(source) {
|
|
|
1841
1905
|
|
|
1842
1906
|
function keepPrevious(src, opt) {
|
|
1843
1907
|
const mutableSrc = isWritableSignal(src) && isMutable(src);
|
|
1844
|
-
// For a mutable source the linkedSignal's equality must be suppressible: a forwarded
|
|
1845
|
-
// `mutate` keeps the same reference, which default equality would otherwise swallow.
|
|
1846
1908
|
let cnt = 0;
|
|
1847
1909
|
const baseEqual = opt?.equal;
|
|
1848
1910
|
const equal = mutableSrc
|
|
@@ -1860,16 +1922,11 @@ function keepPrevious(src, opt) {
|
|
|
1860
1922
|
if (isWritableSignal(src)) {
|
|
1861
1923
|
persisted.set = src.set;
|
|
1862
1924
|
persisted.update = src.update;
|
|
1863
|
-
// NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
|
|
1864
|
-
// source's readonly view would reintroduce the `undefined` flashes this wrapper exists
|
|
1865
|
-
// to prevent.
|
|
1866
1925
|
if (mutableSrc) {
|
|
1867
1926
|
persisted.mutate = (updater) => {
|
|
1868
1927
|
cnt++;
|
|
1869
1928
|
try {
|
|
1870
1929
|
src.mutate(updater);
|
|
1871
|
-
// force the recompute while equality is suppressed, so the reference-stable
|
|
1872
|
-
// mutation bumps the wrapper's version (see derived.ts for the same pattern)
|
|
1873
1930
|
untracked(persisted);
|
|
1874
1931
|
}
|
|
1875
1932
|
finally {
|
|
@@ -1912,8 +1969,7 @@ function indexArray(source, map, opt = {}) {
|
|
|
1912
1969
|
: toWritable(data, () => {
|
|
1913
1970
|
// noop
|
|
1914
1971
|
});
|
|
1915
|
-
// copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
|
|
1916
|
-
// (possibly shared/reused) options object
|
|
1972
|
+
// copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned (possibly shared/reused) options object
|
|
1917
1973
|
if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
|
|
1918
1974
|
opt = {
|
|
1919
1975
|
...opt,
|
|
@@ -2608,8 +2664,7 @@ function observerSupported$1() {
|
|
|
2608
2664
|
*/
|
|
2609
2665
|
function elementSize(target, opt) {
|
|
2610
2666
|
return runInSensorContext(opt?.injector, () =>
|
|
2611
|
-
// the host-element default must resolve INSIDE the sensor context
|
|
2612
|
-
// parameter default (which would run before the injector wrapper)
|
|
2667
|
+
// the host-element default must resolve INSIDE the sensor context
|
|
2613
2668
|
createElementSize(target ?? inject(ElementRef), opt));
|
|
2614
2669
|
}
|
|
2615
2670
|
function createElementSize(target, opt) {
|
|
@@ -2746,10 +2801,7 @@ function observerSupported() {
|
|
|
2746
2801
|
* ```
|
|
2747
2802
|
*/
|
|
2748
2803
|
function elementVisibility(target, opt) {
|
|
2749
|
-
return runInSensorContext(opt?.injector, () =>
|
|
2750
|
-
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2751
|
-
// parameter default (which would run before the injector wrapper)
|
|
2752
|
-
createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2804
|
+
return runInSensorContext(opt?.injector, () => createElementVisibility(target ?? inject(ElementRef), opt));
|
|
2753
2805
|
}
|
|
2754
2806
|
function createElementVisibility(target, opt) {
|
|
2755
2807
|
if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
|
|
@@ -2820,10 +2872,7 @@ function unwrap$1(target) {
|
|
|
2820
2872
|
* ```
|
|
2821
2873
|
*/
|
|
2822
2874
|
function focusWithin(target, opt) {
|
|
2823
|
-
return runInSensorContext(opt?.injector, () =>
|
|
2824
|
-
// the host-element default must resolve INSIDE the sensor context, not as a
|
|
2825
|
-
// parameter default (which would run before the injector wrapper)
|
|
2826
|
-
createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2875
|
+
return runInSensorContext(opt?.injector, () => createFocusWithin(target ?? inject(ElementRef), opt));
|
|
2827
2876
|
}
|
|
2828
2877
|
function createFocusWithin(target, opt) {
|
|
2829
2878
|
const debugName = opt?.debugName ?? 'focusWithin';
|
|
@@ -3300,8 +3349,6 @@ function createMousePosition(opt) {
|
|
|
3300
3349
|
}
|
|
3301
3350
|
pos.set({ x, y });
|
|
3302
3351
|
};
|
|
3303
|
-
// passive: the handler never calls preventDefault, and a non-passive touchmove on
|
|
3304
|
-
// window forces the browser to wait on JS before scrolling (scroll jank on touch)
|
|
3305
3352
|
const attach = (el) => {
|
|
3306
3353
|
const controller = new AbortController();
|
|
3307
3354
|
el.addEventListener('mousemove', updatePosition, {
|
|
@@ -3317,7 +3364,7 @@ function createMousePosition(opt) {
|
|
|
3317
3364
|
return () => controller.abort();
|
|
3318
3365
|
};
|
|
3319
3366
|
if (isSignal(target)) {
|
|
3320
|
-
//
|
|
3367
|
+
// covers viewChild case
|
|
3321
3368
|
effect((cleanup) => {
|
|
3322
3369
|
const el = resolve(target());
|
|
3323
3370
|
if (!el)
|
|
@@ -3770,7 +3817,6 @@ function createScrollPosition(opt) {
|
|
|
3770
3817
|
ms: throttle,
|
|
3771
3818
|
});
|
|
3772
3819
|
if (isSignal(target)) {
|
|
3773
|
-
// re-attach whenever the signal resolves to a (new) element — covers viewChild
|
|
3774
3820
|
effect((cleanup) => {
|
|
3775
3821
|
const el = resolve(target());
|
|
3776
3822
|
if (!el)
|
|
@@ -3964,7 +4010,6 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
3964
4010
|
state.set(event);
|
|
3965
4011
|
};
|
|
3966
4012
|
const { destroyRef: providedDestroyRef,
|
|
3967
|
-
// strip non-listener keys so they don't leak into addEventListener options
|
|
3968
4013
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3969
4014
|
injector: _injector,
|
|
3970
4015
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
@@ -3978,8 +4023,7 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
|
|
|
3978
4023
|
resolved.addEventListener(eventName, handler, listenerOpts);
|
|
3979
4024
|
cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
|
|
3980
4025
|
}, ...(ngDevMode ? [{ debugName: "effectRef", injector }] : [{ injector }]));
|
|
3981
|
-
// honor an explicit destroyRef for signal targets
|
|
3982
|
-
// only follow the injector's lifetime, contradicting the documented option
|
|
4026
|
+
// honor an explicit destroyRef for signal targets
|
|
3983
4027
|
providedDestroyRef?.onDestroy(() => effectRef.destroy());
|
|
3984
4028
|
}
|
|
3985
4029
|
else {
|
|
@@ -4148,6 +4192,228 @@ function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
|
|
|
4148
4192
|
: (newValue) => target.update(write(newValue));
|
|
4149
4193
|
}
|
|
4150
4194
|
|
|
4195
|
+
function generateOrigin$1() {
|
|
4196
|
+
if (globalThis.crypto?.randomUUID)
|
|
4197
|
+
return globalThis.crypto.randomUUID();
|
|
4198
|
+
return Math.random().toString(36).substring(2);
|
|
4199
|
+
}
|
|
4200
|
+
const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
|
|
4201
|
+
/**
|
|
4202
|
+
* Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
|
|
4203
|
+
* an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
|
|
4204
|
+
* descends only where refs differ. O(changed paths), not O(tree).
|
|
4205
|
+
*/
|
|
4206
|
+
function diffNode(prev, next, path, ops) {
|
|
4207
|
+
if (Object.is(prev, next))
|
|
4208
|
+
return;
|
|
4209
|
+
if (isRecord(prev) && isRecord(next)) {
|
|
4210
|
+
for (const key of Object.keys(prev)) {
|
|
4211
|
+
if (!Object.hasOwn(next, key))
|
|
4212
|
+
ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
|
|
4213
|
+
}
|
|
4214
|
+
for (const key of Object.keys(next)) {
|
|
4215
|
+
if (!Object.hasOwn(prev, key)) {
|
|
4216
|
+
// added key: deliberately NO `prev` property (absent ≠ undefined)
|
|
4217
|
+
ops.push({ kind: 'set', path: [...path, key], next: next[key] });
|
|
4218
|
+
}
|
|
4219
|
+
else {
|
|
4220
|
+
diffNode(prev[key], next[key], [...path, key], ops);
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
return;
|
|
4224
|
+
}
|
|
4225
|
+
if (isPlainArray$1(prev) && isPlainArray$1(next)) {
|
|
4226
|
+
// same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
|
|
4227
|
+
// change is a whole unit — index attribution lies under insert/remove/reorder
|
|
4228
|
+
if (prev.length === next.length) {
|
|
4229
|
+
for (let i = 0; i < next.length; i++)
|
|
4230
|
+
diffNode(prev[i], next[i], [...path, i], ops);
|
|
4231
|
+
return;
|
|
4232
|
+
}
|
|
4233
|
+
ops.push({ kind: 'set', path, prev, next });
|
|
4234
|
+
return;
|
|
4235
|
+
}
|
|
4236
|
+
// leaf / type change / opaque — one unit, prev present (the slot existed)
|
|
4237
|
+
ops.push({ kind: 'set', path, prev, next });
|
|
4238
|
+
}
|
|
4239
|
+
/** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
|
|
4240
|
+
function applyAt(container, path, idx, op) {
|
|
4241
|
+
const seg = path[idx];
|
|
4242
|
+
const base = isPlainArray$1(container)
|
|
4243
|
+
? container.slice()
|
|
4244
|
+
: isRecord(container)
|
|
4245
|
+
? { ...container }
|
|
4246
|
+
: typeof seg === 'number'
|
|
4247
|
+
? []
|
|
4248
|
+
: {};
|
|
4249
|
+
if (idx === path.length - 1) {
|
|
4250
|
+
if (op.kind === 'delete') {
|
|
4251
|
+
// arrays never receive deletes (length changes travel as whole-array sets)
|
|
4252
|
+
delete base[seg];
|
|
4253
|
+
}
|
|
4254
|
+
else {
|
|
4255
|
+
base[seg] = op.next;
|
|
4256
|
+
}
|
|
4257
|
+
return base;
|
|
4258
|
+
}
|
|
4259
|
+
base[seg] = applyAt(base[seg], path, idx + 1, op);
|
|
4260
|
+
return base;
|
|
4261
|
+
}
|
|
4262
|
+
/**
|
|
4263
|
+
* Pure, store-free application of ops onto a plain root value, returning the next immutable root
|
|
4264
|
+
* (structural-sharing along op paths, missing containers vivified `'auto'`-style). This is the
|
|
4265
|
+
* same transform {@link OpLog.apply} runs, extracted so a replica can fold a received batch into
|
|
4266
|
+
* a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
|
|
4267
|
+
* Accepts a batch or a bare op list.
|
|
4268
|
+
*/
|
|
4269
|
+
function applyOps(root, ops) {
|
|
4270
|
+
const list = Array.isArray(ops) ? ops : ops.ops;
|
|
4271
|
+
let next = root;
|
|
4272
|
+
for (const op of list) {
|
|
4273
|
+
if (op.path.length === 0) {
|
|
4274
|
+
if (op.kind === 'set')
|
|
4275
|
+
next = op.next;
|
|
4276
|
+
continue; // a root delete is meaningless — ignore (mirrors OpLog.apply)
|
|
4277
|
+
}
|
|
4278
|
+
next = applyAt(next, op.path, 0, op);
|
|
4279
|
+
}
|
|
4280
|
+
return next;
|
|
4281
|
+
}
|
|
4282
|
+
/**
|
|
4283
|
+
* Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
|
|
4284
|
+
* {@link opLog}, exported so code outside a log can produce a batch — e.g. diffing a scratch
|
|
4285
|
+
* draft against a replica's current value to route a write to its owner). Trusts the
|
|
4286
|
+
* copy-on-write contract: an untouched subtree that kept its reference is skipped.
|
|
4287
|
+
*/
|
|
4288
|
+
function diffOps(prev, next) {
|
|
4289
|
+
const ops = [];
|
|
4290
|
+
diffNode(prev, next, [], ops);
|
|
4291
|
+
return ops;
|
|
4292
|
+
}
|
|
4293
|
+
/**
|
|
4294
|
+
* Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
|
|
4295
|
+
* `prev` — inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
|
|
4296
|
+
* result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
|
|
4297
|
+
* carry — a wire-serialized batch that stripped them is not invertible.
|
|
4298
|
+
*/
|
|
4299
|
+
function invertBatch(batch) {
|
|
4300
|
+
const ops = Array.isArray(batch) ? batch : batch.ops;
|
|
4301
|
+
const inverted = [];
|
|
4302
|
+
for (let i = ops.length - 1; i >= 0; i--) {
|
|
4303
|
+
const op = ops[i];
|
|
4304
|
+
if (op.kind === 'delete') {
|
|
4305
|
+
inverted.push({
|
|
4306
|
+
kind: 'set',
|
|
4307
|
+
path: op.path,
|
|
4308
|
+
next: op.prev,
|
|
4309
|
+
prev: undefined,
|
|
4310
|
+
});
|
|
4311
|
+
continue;
|
|
4312
|
+
}
|
|
4313
|
+
if (!Object.hasOwn(op, 'prev')) {
|
|
4314
|
+
inverted.push({ kind: 'delete', path: op.path, prev: op.next });
|
|
4315
|
+
}
|
|
4316
|
+
else {
|
|
4317
|
+
inverted.push({
|
|
4318
|
+
kind: 'set',
|
|
4319
|
+
path: op.path,
|
|
4320
|
+
next: op.prev,
|
|
4321
|
+
prev: op.next,
|
|
4322
|
+
});
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
return inverted;
|
|
4326
|
+
}
|
|
4327
|
+
/**
|
|
4328
|
+
* Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
|
|
4329
|
+
* immutably-updated objects) and emits its changes as minimal structural op batches — the
|
|
4330
|
+
* shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
|
|
4331
|
+
* batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
|
|
4332
|
+
*
|
|
4333
|
+
* Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
|
|
4334
|
+
* of the root value per tick (structural sharing makes it O(changed paths)), driven by one
|
|
4335
|
+
* effect. A batch therefore coalesces everything written in one tick — for coarser,
|
|
4336
|
+
* intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
|
|
4337
|
+
*
|
|
4338
|
+
* NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
|
|
4339
|
+
* defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
|
|
4340
|
+
* warning fires and nothing emits.
|
|
4341
|
+
*
|
|
4342
|
+
* ```ts
|
|
4343
|
+
* const s = store({ todos: [{ done: false }] });
|
|
4344
|
+
* const log = opLog(s, { origin: 'tab-a' });
|
|
4345
|
+
* log.subscribe((b) => channel.postMessage(encode(b))); // ship
|
|
4346
|
+
* channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
|
|
4347
|
+
* s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
|
|
4348
|
+
* ```
|
|
4349
|
+
*/
|
|
4350
|
+
function opLog(source, opt) {
|
|
4351
|
+
const origin = opt?.origin ?? generateOrigin$1();
|
|
4352
|
+
const storeKind = source[STORE_KIND];
|
|
4353
|
+
const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
|
|
4354
|
+
if (isDevMode() && mutableSource) {
|
|
4355
|
+
console.warn('[@mmstack/primitives] opLog observes copy-on-write updates via reference identity — a MUTABLE store/signal mutates in place, so changes are invisible to it. Use an immutable store, or set whole values.');
|
|
4356
|
+
}
|
|
4357
|
+
let prevRoot = untracked(source);
|
|
4358
|
+
let version = 0;
|
|
4359
|
+
let destroyed = false;
|
|
4360
|
+
const subscribers = new Set();
|
|
4361
|
+
const latest = signal(null, ...(ngDevMode ? [{ debugName: "latest" }] : []));
|
|
4362
|
+
/** Diff now, emit if there's a delta, advance the baseline. */
|
|
4363
|
+
const flush = () => {
|
|
4364
|
+
if (destroyed)
|
|
4365
|
+
return;
|
|
4366
|
+
const next = untracked(source);
|
|
4367
|
+
if (Object.is(prevRoot, next))
|
|
4368
|
+
return;
|
|
4369
|
+
const ops = [];
|
|
4370
|
+
diffNode(prevRoot, next, [], ops);
|
|
4371
|
+
prevRoot = next;
|
|
4372
|
+
if (!ops.length)
|
|
4373
|
+
return; // fresh refs, equal values — spurious-write tolerance
|
|
4374
|
+
const batch = { origin, version: ++version, ops };
|
|
4375
|
+
latest.set(batch);
|
|
4376
|
+
for (const cb of [...subscribers])
|
|
4377
|
+
cb(batch);
|
|
4378
|
+
};
|
|
4379
|
+
const run = () => {
|
|
4380
|
+
source(); // track every commit…
|
|
4381
|
+
untracked(flush); // …and emit the delta since the last flush
|
|
4382
|
+
};
|
|
4383
|
+
// default driver is an Angular effect (needs an injector); a supplied driver runs injector-free
|
|
4384
|
+
// (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
|
|
4385
|
+
const ref = opt?.driver
|
|
4386
|
+
? opt.driver(run)
|
|
4387
|
+
: effect(run, { injector: opt?.injector ?? inject(Injector) });
|
|
4388
|
+
return {
|
|
4389
|
+
latest: latest.asReadonly(),
|
|
4390
|
+
subscribe: (cb) => {
|
|
4391
|
+
subscribers.add(cb);
|
|
4392
|
+
return () => subscribers.delete(cb);
|
|
4393
|
+
},
|
|
4394
|
+
// the emission core, callable on demand — reads the source untracked, so it never disturbs the
|
|
4395
|
+
// driver's subscription; a subsequent scheduled run just finds the baseline already advanced
|
|
4396
|
+
flush: () => flush(),
|
|
4397
|
+
apply: (batchOrOps) => {
|
|
4398
|
+
const ops = Array.isArray(batchOrOps)
|
|
4399
|
+
? batchOrOps
|
|
4400
|
+
: batchOrOps.ops;
|
|
4401
|
+
if (!ops.length)
|
|
4402
|
+
return;
|
|
4403
|
+
// pending local writes must emit BEFORE the baseline advances past them
|
|
4404
|
+
flush();
|
|
4405
|
+
const root = applyOps(untracked(source), ops); // one atomic root, structural-shared
|
|
4406
|
+
source.set(root);
|
|
4407
|
+
prevRoot = root; // baseline advance: an applied batch never echoes
|
|
4408
|
+
},
|
|
4409
|
+
destroy: () => {
|
|
4410
|
+
destroyed = true;
|
|
4411
|
+
subscribers.clear();
|
|
4412
|
+
ref.destroy();
|
|
4413
|
+
},
|
|
4414
|
+
};
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4151
4417
|
/**
|
|
4152
4418
|
* @internal Runtime brand carrying a store node's lazily-built leaf probe. Exported (like
|
|
4153
4419
|
* {@link OPAQUE}) only so the `{ readonly [LEAF]: () => boolean }` brand on the store types is
|
|
@@ -4290,7 +4556,11 @@ function buildChildNode(target, prop, isMutableSource, options) {
|
|
|
4290
4556
|
function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...rest } = {}) {
|
|
4291
4557
|
if (isStore(source))
|
|
4292
4558
|
return source;
|
|
4293
|
-
if
|
|
4559
|
+
// injector is needed ONLY to resolve the two proxy-globals tokens; if a caller supplies the
|
|
4560
|
+
// globals directly (createStoreContext — the worker-side seam with no DI), skip inject entirely
|
|
4561
|
+
const sharedGlobals = rest[STORE_SHARED_GLOBALS];
|
|
4562
|
+
const hasSharedGlobals = !!(sharedGlobals?.cache && sharedGlobals?.registry);
|
|
4563
|
+
if (!injector && !hasSharedGlobals)
|
|
4294
4564
|
injector = inject(Injector);
|
|
4295
4565
|
const writableSource = isWritableSignal(source)
|
|
4296
4566
|
? source
|
|
@@ -4308,13 +4578,17 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4308
4578
|
return 'primitive';
|
|
4309
4579
|
}, ...(ngDevMode ? [{ debugName: "kind" }] : []));
|
|
4310
4580
|
const STORE_OPTIONS = {
|
|
4311
|
-
|
|
4581
|
+
// may be undefined in worker/DI-less mode; unused downstream once globals are resolved
|
|
4582
|
+
// (children thread the resolved globals via STORE_SHARED_OPTIONS, derived needs no injector)
|
|
4583
|
+
injector: injector,
|
|
4312
4584
|
vivify,
|
|
4313
4585
|
noUnionLeaves,
|
|
4314
4586
|
[STORE_SHARED_GLOBALS]: {
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4587
|
+
// the `injector!` reads run only when a global is absent
|
|
4588
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
4589
|
+
cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
|
|
4590
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
4591
|
+
registry: sharedGlobals?.registry ?? injector.get(PROXY_CLEANUP_TOKEN),
|
|
4318
4592
|
},
|
|
4319
4593
|
};
|
|
4320
4594
|
// built lazily so non-array nodes never allocate it
|
|
@@ -4386,7 +4660,12 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4386
4660
|
return () => {
|
|
4387
4661
|
if (!isWritableSource)
|
|
4388
4662
|
return s;
|
|
4389
|
-
return untracked(() => toStore(source.asReadonly(), {
|
|
4663
|
+
return untracked(() => toStore(source.asReadonly(), {
|
|
4664
|
+
injector,
|
|
4665
|
+
vivify,
|
|
4666
|
+
noUnionLeaves,
|
|
4667
|
+
[STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
|
|
4668
|
+
}));
|
|
4390
4669
|
};
|
|
4391
4670
|
const k = untracked(kind);
|
|
4392
4671
|
if (prop === 'extend' && k !== 'array')
|
|
@@ -4400,8 +4679,6 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4400
4679
|
return arrayLength();
|
|
4401
4680
|
if (prop === Symbol.iterator)
|
|
4402
4681
|
return function* () {
|
|
4403
|
-
// read length reactively: a spread/for-of inside a computed/effect must re-run
|
|
4404
|
-
// when items are added or removed, not only when already-read elements change
|
|
4405
4682
|
const len = arrayLength();
|
|
4406
4683
|
for (let i = 0; i < len(); i++)
|
|
4407
4684
|
yield receiver[i];
|
|
@@ -4551,6 +4828,40 @@ function mutableStore(value, opt) {
|
|
|
4551
4828
|
...opt,
|
|
4552
4829
|
});
|
|
4553
4830
|
}
|
|
4831
|
+
/**
|
|
4832
|
+
* Builds a DI-less store context — the shared proxy-cache and cleanup registry that {@link toStore}
|
|
4833
|
+
* normally resolves from the injector — so a `store`/`toStore`/`opLog` graph can run with NO Angular
|
|
4834
|
+
* injection context. Spread the result into the options:
|
|
4835
|
+
*
|
|
4836
|
+
* ```ts
|
|
4837
|
+
* import { microtaskOpLogDriver } from '@mmstack/worker/host';
|
|
4838
|
+
* const ctx = createStoreContext();
|
|
4839
|
+
* const s = store({ todos: [] }, ctx);
|
|
4840
|
+
* const log = opLog(s, { driver: microtaskOpLogDriver(), origin: 'worker' }); // no injector anywhere
|
|
4841
|
+
* ```
|
|
4842
|
+
*
|
|
4843
|
+
* **This is a worker-only fallback — do NOT use it on the main thread.** DI is the default and
|
|
4844
|
+
* correct path in an app: the injector scopes the proxy-cache/cleanup singletons per app instance,
|
|
4845
|
+
* which on the SERVER keeps one request's store identity from bleeding into another's (the exact
|
|
4846
|
+
* hazard a module-scope singleton would reintroduce). A Web Worker is safe because it is a single
|
|
4847
|
+
* store graph per thread and never runs during SSR (spawn is a `PLATFORM_ID === 'server'` no-op),
|
|
4848
|
+
* so there is no cross-request scope to contaminate. Never hoist a `createStoreContext()` to module
|
|
4849
|
+
* scope on a shared/main thread.
|
|
4850
|
+
*
|
|
4851
|
+
* **Share ONE context across every store in a worker** — the same way `providedIn: 'root'` shares
|
|
4852
|
+
* one cache across all of an app's stores. `@mmstack/worker/host` memoizes this per worker
|
|
4853
|
+
* (`workerStoreContext()`); reach for `createStoreContext()` directly only in a bare
|
|
4854
|
+
* (non-worker-host) DI-less setup, and hold the single instance yourself.
|
|
4855
|
+
*/
|
|
4856
|
+
function createStoreContext() {
|
|
4857
|
+
const cache = new WeakMap();
|
|
4858
|
+
const registry = new FinalizationRegistry(({ target, prop }) => {
|
|
4859
|
+
const entry = cache.get(target);
|
|
4860
|
+
if (entry)
|
|
4861
|
+
entry.delete(prop);
|
|
4862
|
+
});
|
|
4863
|
+
return { [STORE_SHARED_GLOBALS]: { cache, registry } };
|
|
4864
|
+
}
|
|
4554
4865
|
|
|
4555
4866
|
function isPlainRecord(value) {
|
|
4556
4867
|
if (value === null || typeof value !== 'object')
|
|
@@ -4614,9 +4925,6 @@ function forkStore(base, opt) {
|
|
|
4614
4925
|
source: () => base(),
|
|
4615
4926
|
computation: (theirs, prev) => prev === undefined ? theirs : merge(prev.source, prev.value, theirs),
|
|
4616
4927
|
}]));
|
|
4617
|
-
// Inherit the base's shared options (injector, vivify, noUnionLeaves + the
|
|
4618
|
-
// proxy cache/registry), same as extendStore — a fork should vivify like its
|
|
4619
|
-
// base and share its injector-scoped cache. `opt` overrides (advanced use).
|
|
4620
4928
|
const store = toStore(staged, {
|
|
4621
4929
|
...base[STORE_SHARED_OPTIONS],
|
|
4622
4930
|
...opt,
|
|
@@ -4625,192 +4933,774 @@ function forkStore(base, opt) {
|
|
|
4625
4933
|
store,
|
|
4626
4934
|
commit: () => base.set(untracked(staged)),
|
|
4627
4935
|
discard: () => staged.set(untracked(base)),
|
|
4936
|
+
ops: () => diffOps(untracked(base), untracked(staged)),
|
|
4628
4937
|
};
|
|
4629
4938
|
}
|
|
4630
4939
|
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4940
|
+
/** Total order over stamps alone; ties break on `writer` via {@link compareTotal}. */
|
|
4941
|
+
function compareHlc(a, b) {
|
|
4942
|
+
return a.p !== b.p ? a.p - b.p : a.l - b.l;
|
|
4943
|
+
}
|
|
4944
|
+
/** The protocol's total order: (hlc.p, hlc.l, writer). Never returns 0 for distinct writers. */
|
|
4945
|
+
function compareTotal(a, writerA, b, writerB) {
|
|
4946
|
+
const byClock = compareHlc(a, b);
|
|
4947
|
+
if (byClock !== 0)
|
|
4948
|
+
return byClock;
|
|
4949
|
+
return writerA < writerB ? -1 : writerA > writerB ? 1 : 0;
|
|
4950
|
+
}
|
|
4951
|
+
const SKEW_WARN_MS = 5 * 60_000;
|
|
4952
|
+
/**
|
|
4953
|
+
* HLC per Kulkarni et al.: convergence never depends on wall clocks, but LWW fairness
|
|
4954
|
+
* degrades under large skew, so observing a remote clock far ahead warns in dev mode.
|
|
4955
|
+
*/
|
|
4956
|
+
function createHlcClock(now = Date.now) {
|
|
4957
|
+
let p = 0;
|
|
4958
|
+
let l = 0;
|
|
4959
|
+
const advance = (wall, observed) => {
|
|
4960
|
+
const nextP = Math.max(p, wall, observed?.p ?? 0);
|
|
4961
|
+
if (nextP === p) {
|
|
4962
|
+
l = Math.max(l, observed && observed.p === nextP ? observed.l : 0) + 1;
|
|
4963
|
+
}
|
|
4964
|
+
else {
|
|
4965
|
+
p = nextP;
|
|
4966
|
+
l = observed && observed.p === nextP ? observed.l + 1 : 0;
|
|
4967
|
+
}
|
|
4968
|
+
};
|
|
4969
|
+
return {
|
|
4970
|
+
next: () => {
|
|
4971
|
+
advance(now());
|
|
4972
|
+
return { p, l };
|
|
4973
|
+
},
|
|
4974
|
+
observe: (remote) => {
|
|
4975
|
+
const wall = now();
|
|
4976
|
+
if (isDevMode() && remote.p - wall > SKEW_WARN_MS) {
|
|
4977
|
+
console.warn(`[@mmstack/primitives] observed remote clock ${Math.round((remote.p - wall) / 1000)}s ahead — convergence holds, but last-writer-wins fairness degrades under clock skew`);
|
|
4978
|
+
}
|
|
4979
|
+
advance(wall, remote);
|
|
4980
|
+
},
|
|
4981
|
+
};
|
|
4635
4982
|
}
|
|
4636
|
-
|
|
4983
|
+
|
|
4984
|
+
const OP_PROTO_VERSION = 1;
|
|
4985
|
+
const CONFLICT_BRAND = '~mmstackConflict';
|
|
4986
|
+
function isConflicted(value) {
|
|
4987
|
+
return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
|
|
4988
|
+
}
|
|
4989
|
+
const lww = (_ancestor, mine) => mine;
|
|
4990
|
+
const mergeThree = (ancestor, mine, theirs) => merge3(ancestor, mine, theirs);
|
|
4991
|
+
const preserve = (ancestor, mine, theirs) => ({ [CONFLICT_BRAND]: true, mine, theirs, ancestor });
|
|
4637
4992
|
/**
|
|
4638
|
-
*
|
|
4639
|
-
* an
|
|
4640
|
-
*
|
|
4993
|
+
* Identity-aware array merge (op-protocol RFC §12 v0): reconciles two concurrent versions of
|
|
4994
|
+
* an array item-wise by a user-provided identity, instead of last-writer-wins on the whole
|
|
4995
|
+
* array. Items are matched by key; per-item fields merge via `merge3` against the ancestor
|
|
4996
|
+
* item; items added on either side survive; an item removed on either side and unedited on
|
|
4997
|
+
* the other stays removed. Item ORDER follows `mine` (the total-order winner), with `theirs`-
|
|
4998
|
+
* only additions appended — positional merging is out of scope (fractional indexing is the
|
|
4999
|
+
* known upgrade if dogfooding demands it). Arrays still TRAVEL as whole-value sets; identity
|
|
5000
|
+
* only shapes conflict resolution, so the wire format is untouched.
|
|
4641
5001
|
*/
|
|
4642
|
-
function
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
if (!Object.hasOwn(next, key))
|
|
4648
|
-
ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
|
|
5002
|
+
function keyedArray(identity, opt) {
|
|
5003
|
+
const mergeItem = opt?.item ?? mergeThree;
|
|
5004
|
+
return (ancestor, mine, theirs, ctx) => {
|
|
5005
|
+
if (!Array.isArray(mine) || !Array.isArray(theirs)) {
|
|
5006
|
+
return mine; // type conflict → total-order winner, like lww
|
|
4649
5007
|
}
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
5008
|
+
const anc = Array.isArray(ancestor) ? ancestor : [];
|
|
5009
|
+
const byKey = (arr) => {
|
|
5010
|
+
const map = new Map();
|
|
5011
|
+
for (const item of arr)
|
|
5012
|
+
map.set(identity(item), item);
|
|
5013
|
+
return map;
|
|
5014
|
+
};
|
|
5015
|
+
const ancMap = byKey(anc);
|
|
5016
|
+
const mineMap = byKey(mine);
|
|
5017
|
+
const theirsMap = byKey(theirs);
|
|
5018
|
+
const out = [];
|
|
5019
|
+
for (const item of mine) {
|
|
5020
|
+
const key = identity(item);
|
|
5021
|
+
const other = theirsMap.get(key);
|
|
5022
|
+
const base = ancMap.get(key);
|
|
5023
|
+
if (theirsMap.has(key)) {
|
|
5024
|
+
out.push(structuralEq(item, other)
|
|
5025
|
+
? item
|
|
5026
|
+
: mergeItem(base, item, other, ctx));
|
|
4654
5027
|
}
|
|
4655
|
-
else {
|
|
4656
|
-
|
|
5028
|
+
else if (!ancMap.has(key) || !structuralEq(item, base)) {
|
|
5029
|
+
out.push(item); // added by mine, or edited by mine while theirs removed it → keep
|
|
4657
5030
|
}
|
|
5031
|
+
// else: theirs removed it and mine left it untouched → stays removed
|
|
4658
5032
|
}
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
diffNode(prev[i], next[i], [...path, i], ops);
|
|
4667
|
-
return;
|
|
5033
|
+
for (const item of theirs) {
|
|
5034
|
+
const key = identity(item);
|
|
5035
|
+
if (mineMap.has(key))
|
|
5036
|
+
continue;
|
|
5037
|
+
if (!ancMap.has(key) || !structuralEq(item, ancMap.get(key))) {
|
|
5038
|
+
out.push(item); // added by theirs, or edited by theirs while mine removed it → keep
|
|
5039
|
+
}
|
|
4668
5040
|
}
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
}
|
|
4672
|
-
// leaf / type change / opaque — one unit, prev present (the slot existed)
|
|
4673
|
-
ops.push({ kind: 'set', path, prev, next });
|
|
5041
|
+
return out;
|
|
5042
|
+
};
|
|
4674
5043
|
}
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
delete base[seg];
|
|
5044
|
+
function compilePolicies(entries) {
|
|
5045
|
+
return entries.map((e) => ({
|
|
5046
|
+
segments: typeof e.path === 'string' ? e.path.split('.') : e.path.map(String),
|
|
5047
|
+
merge: e.merge,
|
|
5048
|
+
}));
|
|
5049
|
+
}
|
|
5050
|
+
function policyFor(policies, path) {
|
|
5051
|
+
outer: for (const p of policies) {
|
|
5052
|
+
if (p.segments.length !== path.length)
|
|
5053
|
+
continue;
|
|
5054
|
+
for (let i = 0; i < path.length; i++) {
|
|
5055
|
+
if (p.segments[i] !== '*' && p.segments[i] !== String(path[i]))
|
|
5056
|
+
continue outer;
|
|
4689
5057
|
}
|
|
4690
|
-
|
|
4691
|
-
|
|
5058
|
+
return p.merge;
|
|
5059
|
+
}
|
|
5060
|
+
return lww;
|
|
5061
|
+
}
|
|
5062
|
+
const SEP = '';
|
|
5063
|
+
const keyOf$1 = (path) => path.map(String).join(SEP);
|
|
5064
|
+
function structuralEq(a, b) {
|
|
5065
|
+
if (Object.is(a, b))
|
|
5066
|
+
return true;
|
|
5067
|
+
if (typeof a !== 'object' ||
|
|
5068
|
+
typeof b !== 'object' ||
|
|
5069
|
+
a === null ||
|
|
5070
|
+
b === null ||
|
|
5071
|
+
Array.isArray(a) !== Array.isArray(b)) {
|
|
5072
|
+
return false;
|
|
5073
|
+
}
|
|
5074
|
+
const ka = Object.keys(a);
|
|
5075
|
+
const kb = Object.keys(b);
|
|
5076
|
+
if (ka.length !== kb.length)
|
|
5077
|
+
return false;
|
|
5078
|
+
for (const k of ka) {
|
|
5079
|
+
if (!Object.hasOwn(b, k))
|
|
5080
|
+
return false;
|
|
5081
|
+
if (!structuralEq(a[k], b[k])) {
|
|
5082
|
+
return false;
|
|
4692
5083
|
}
|
|
4693
|
-
return base;
|
|
4694
5084
|
}
|
|
4695
|
-
|
|
4696
|
-
return base;
|
|
5085
|
+
return true;
|
|
4697
5086
|
}
|
|
5087
|
+
// total order (hlc, writer, origin): two origins can share a writer AND a stamp
|
|
5088
|
+
// (independent clocks, same ms), so only origin makes the order strict
|
|
5089
|
+
const compareStamp = (a, b) => {
|
|
5090
|
+
const byTotal = compareTotal(a.hlc, a.writer, b.hlc, b.writer);
|
|
5091
|
+
if (byTotal !== 0)
|
|
5092
|
+
return byTotal;
|
|
5093
|
+
return a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0;
|
|
5094
|
+
};
|
|
5095
|
+
const beats = (a, b) => compareStamp(a, b) > 0;
|
|
4698
5096
|
/**
|
|
4699
|
-
*
|
|
4700
|
-
*
|
|
4701
|
-
*
|
|
4702
|
-
* carry — a wire-serialized batch that stripped them is not invertible.
|
|
5097
|
+
* The unsequenced-topology convergence core (op-protocol RFC §4): a per-path last-writer-wins
|
|
5098
|
+
* register map over the total order (hlc, writer), with subtree dominance. Order-independent:
|
|
5099
|
+
* any arrival order of the same envelope set yields the same state.
|
|
4703
5100
|
*/
|
|
4704
|
-
function
|
|
4705
|
-
const
|
|
4706
|
-
const
|
|
4707
|
-
|
|
4708
|
-
const
|
|
4709
|
-
if (
|
|
4710
|
-
|
|
4711
|
-
continue;
|
|
4712
|
-
}
|
|
4713
|
-
if (!Object.hasOwn(op, 'prev')) {
|
|
4714
|
-
inverted.push({ kind: 'delete', path: op.path, prev: op.next });
|
|
4715
|
-
}
|
|
4716
|
-
else {
|
|
4717
|
-
inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: op.next });
|
|
5101
|
+
function createConvergingApply(opt) {
|
|
5102
|
+
const registers = new Map();
|
|
5103
|
+
const policies = compilePolicies(opt?.policies ?? []);
|
|
5104
|
+
const resolveConcurrent = (winner, loser, path) => {
|
|
5105
|
+
const merge = policyFor(policies, path);
|
|
5106
|
+
if (merge === lww || winner.kind === 'delete' || loser.kind === 'delete') {
|
|
5107
|
+
return winner;
|
|
4718
5108
|
}
|
|
5109
|
+
const resolved = merge(loser.prev, winner.next, loser.next, { path });
|
|
5110
|
+
if (Object.is(resolved, winner.next))
|
|
5111
|
+
return winner;
|
|
5112
|
+
return { kind: 'set', path, next: resolved, prev: winner.next };
|
|
5113
|
+
};
|
|
5114
|
+
// a sequential edit carries the value it overwrote; a mismatch means neither saw the other.
|
|
5115
|
+
// Structural, not referential: identity never survives the wire, so a peer that built on
|
|
5116
|
+
// the replicated copy of a value must still count as sequential.
|
|
5117
|
+
const concurrentWith = (incoming, registered) => {
|
|
5118
|
+
if (incoming.kind === 'delete' || registered.kind === 'delete')
|
|
5119
|
+
return false;
|
|
5120
|
+
if (!Object.hasOwn(incoming, 'prev'))
|
|
5121
|
+
return true;
|
|
5122
|
+
return !structuralEq(incoming.prev, registered.next);
|
|
5123
|
+
};
|
|
5124
|
+
return {
|
|
5125
|
+
ingest: (env, o) => {
|
|
5126
|
+
const stamp = { hlc: env.hlc, writer: env.writer, origin: env.origin };
|
|
5127
|
+
const out = [];
|
|
5128
|
+
for (const op of env.ops) {
|
|
5129
|
+
const key = keyOf$1(op.path);
|
|
5130
|
+
let dominated = false;
|
|
5131
|
+
let exact;
|
|
5132
|
+
for (let len = 0; len <= op.path.length; len++) {
|
|
5133
|
+
const reg = registers.get(keyOf$1(op.path.slice(0, len)));
|
|
5134
|
+
if (!reg)
|
|
5135
|
+
continue;
|
|
5136
|
+
if (len === op.path.length)
|
|
5137
|
+
exact = reg;
|
|
5138
|
+
else if (beats(reg, stamp)) {
|
|
5139
|
+
dominated = true;
|
|
5140
|
+
break;
|
|
5141
|
+
}
|
|
5142
|
+
}
|
|
5143
|
+
if (dominated)
|
|
5144
|
+
continue;
|
|
5145
|
+
if (exact && beats(exact, stamp)) {
|
|
5146
|
+
if (concurrentWith(op, exact.op)) {
|
|
5147
|
+
const resolved = resolveConcurrent(exact.op, op, op.path);
|
|
5148
|
+
if (resolved !== exact.op) {
|
|
5149
|
+
exact.op = resolved;
|
|
5150
|
+
if (!o?.local)
|
|
5151
|
+
out.push(resolved);
|
|
5152
|
+
}
|
|
5153
|
+
}
|
|
5154
|
+
continue;
|
|
5155
|
+
}
|
|
5156
|
+
let accepted = op;
|
|
5157
|
+
if (exact && concurrentWith(op, exact.op)) {
|
|
5158
|
+
accepted = resolveConcurrent(op, exact.op, op.path);
|
|
5159
|
+
}
|
|
5160
|
+
const isDescendant = key === ''
|
|
5161
|
+
? (k) => k !== ''
|
|
5162
|
+
: (k) => k.startsWith(key + SEP);
|
|
5163
|
+
const replays = [];
|
|
5164
|
+
for (const [k, reg] of registers) {
|
|
5165
|
+
if (!isDescendant(k))
|
|
5166
|
+
continue;
|
|
5167
|
+
if (beats(stamp, reg))
|
|
5168
|
+
registers.delete(k);
|
|
5169
|
+
else
|
|
5170
|
+
replays.push(reg);
|
|
5171
|
+
}
|
|
5172
|
+
replays.sort(compareStamp);
|
|
5173
|
+
registers.set(key, { hlc: env.hlc, writer: env.writer, origin: env.origin, op: accepted });
|
|
5174
|
+
if (!o?.local) {
|
|
5175
|
+
out.push(accepted);
|
|
5176
|
+
for (const r of replays)
|
|
5177
|
+
out.push(r.op);
|
|
5178
|
+
}
|
|
5179
|
+
}
|
|
5180
|
+
return out;
|
|
5181
|
+
},
|
|
5182
|
+
reset: () => registers.clear(),
|
|
5183
|
+
};
|
|
5184
|
+
}
|
|
5185
|
+
function getAtPath(root, path) {
|
|
5186
|
+
let cur = root;
|
|
5187
|
+
for (const seg of path) {
|
|
5188
|
+
if (cur === null || typeof cur !== 'object')
|
|
5189
|
+
return undefined;
|
|
5190
|
+
cur = cur[seg];
|
|
4719
5191
|
}
|
|
4720
|
-
return
|
|
5192
|
+
return cur;
|
|
4721
5193
|
}
|
|
4722
5194
|
/**
|
|
4723
|
-
*
|
|
4724
|
-
*
|
|
4725
|
-
*
|
|
4726
|
-
* batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
|
|
4727
|
-
*
|
|
4728
|
-
* Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
|
|
4729
|
-
* of the root value per tick (structural sharing makes it O(changed paths)), driven by one
|
|
4730
|
-
* effect. A batch therefore coalesces everything written in one tick — for coarser,
|
|
4731
|
-
* intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
|
|
4732
|
-
*
|
|
4733
|
-
* NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
|
|
4734
|
-
* defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
|
|
4735
|
-
* warning fires and nothing emits.
|
|
4736
|
-
*
|
|
4737
|
-
* ```ts
|
|
4738
|
-
* const s = store({ todos: [{ done: false }] });
|
|
4739
|
-
* const log = opLog(s, { origin: 'tab-a' });
|
|
4740
|
-
* log.subscribe((b) => channel.postMessage(encode(b))); // ship
|
|
4741
|
-
* channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
|
|
4742
|
-
* s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
|
|
4743
|
-
* ```
|
|
5195
|
+
* The shared rebase routine (op-protocol RFC §5): invert pending, apply remote, re-apply
|
|
5196
|
+
* pending through the merge policies. Pure — branching's `rebase()` and the sequenced relay
|
|
5197
|
+
* client both call this.
|
|
4744
5198
|
*/
|
|
4745
|
-
function
|
|
4746
|
-
const
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
const storeKind = source[STORE_KIND];
|
|
4751
|
-
const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
|
|
4752
|
-
if (isDevMode() && mutableSource) {
|
|
4753
|
-
console.warn('[@mmstack/primitives] opLog observes copy-on-write updates via reference identity — a MUTABLE store/signal mutates in place, so changes are invisible to it. Use an immutable store, or set whole values.');
|
|
5199
|
+
function rebaseOps(root, pending, remote, policies) {
|
|
5200
|
+
const compiled = compilePolicies(policies ?? []);
|
|
5201
|
+
let base = root;
|
|
5202
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
5203
|
+
base = applyOps(base, invertBatch(pending[i]));
|
|
4754
5204
|
}
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
5205
|
+
base = applyOps(base, remote);
|
|
5206
|
+
const rebased = [];
|
|
5207
|
+
for (const batch of pending) {
|
|
5208
|
+
const next = [];
|
|
5209
|
+
for (const op of batch) {
|
|
5210
|
+
const cur = getAtPath(base, op.path);
|
|
5211
|
+
if (op.kind === 'delete') {
|
|
5212
|
+
next.push({ kind: 'delete', path: op.path, prev: cur });
|
|
5213
|
+
}
|
|
5214
|
+
else if (cur === undefined) {
|
|
5215
|
+
next.push({ kind: 'set', path: op.path, next: op.next });
|
|
5216
|
+
}
|
|
5217
|
+
else if (Object.hasOwn(op, 'prev') && !structuralEq(op.prev, cur)) {
|
|
5218
|
+
const merge = policyFor(compiled, op.path);
|
|
5219
|
+
const resolved = merge(op.prev, op.next, cur, { path: op.path });
|
|
5220
|
+
next.push({ kind: 'set', path: op.path, next: resolved, prev: cur });
|
|
5221
|
+
}
|
|
5222
|
+
else {
|
|
5223
|
+
next.push({ kind: 'set', path: op.path, next: op.next, prev: cur });
|
|
5224
|
+
}
|
|
5225
|
+
}
|
|
5226
|
+
base = applyOps(base, next);
|
|
5227
|
+
rebased.push(next);
|
|
5228
|
+
}
|
|
5229
|
+
return { root: base, pending: rebased };
|
|
5230
|
+
}
|
|
5231
|
+
/**
|
|
5232
|
+
* A per-path-policy `ForkStrategy` for `forkStore`: a three-way reconcile built from the
|
|
5233
|
+
* shared rebase (invert mine → apply theirs' delta → re-apply mine through the policies).
|
|
5234
|
+
* Paths only one side touched resolve like `merge3`; paths BOTH touched go through the
|
|
5235
|
+
* matching {@link MergePolicyEntry} (`lww` default — fork wins, matching `'fine'`; or
|
|
5236
|
+
* `mergeThree` / `preserve` / custom). Same copy-on-write contract as `'fine'`.
|
|
5237
|
+
*/
|
|
5238
|
+
function policyStrategy(policies) {
|
|
5239
|
+
return (ancestor, mine, theirs) => rebaseOps(mine, [diffOps(ancestor, mine)], diffOps(ancestor, theirs), policies).root;
|
|
5240
|
+
}
|
|
5241
|
+
function generateOrigin() {
|
|
5242
|
+
if (globalThis.crypto?.randomUUID)
|
|
5243
|
+
return globalThis.crypto.randomUUID();
|
|
5244
|
+
return Math.random().toString(36).substring(2);
|
|
5245
|
+
}
|
|
5246
|
+
/**
|
|
5247
|
+
* Wires a copy-on-write signal (a `store` root) to the op protocol: local writes emit
|
|
5248
|
+
* stamped envelopes, received envelopes fold in through the converging apply. The
|
|
5249
|
+
* unsequenced-topology client core that `tabSync(store)` and P2P transports build on.
|
|
5250
|
+
*/
|
|
5251
|
+
const RECENT_LOCAL_CAP = 64;
|
|
5252
|
+
function opSync(source, opt) {
|
|
5253
|
+
const origin = opt.origin ?? generateOrigin();
|
|
5254
|
+
const clock = opt.clock ?? createHlcClock();
|
|
5255
|
+
const conv = createConvergingApply({ policies: opt.policies });
|
|
4758
5256
|
const subscribers = new Set();
|
|
4759
|
-
const
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
5257
|
+
const versions = new Map();
|
|
5258
|
+
const recentLocal = [];
|
|
5259
|
+
let version = 0;
|
|
5260
|
+
const log = opLog(source, opt.driver
|
|
5261
|
+
? { origin, driver: opt.driver }
|
|
5262
|
+
: { origin, injector: opt.injector ?? inject(Injector) });
|
|
5263
|
+
const emitLocal = (ops) => {
|
|
5264
|
+
const env = {
|
|
5265
|
+
proto: OP_PROTO_VERSION,
|
|
5266
|
+
origin,
|
|
5267
|
+
writer: opt.writer,
|
|
5268
|
+
version: ++version,
|
|
5269
|
+
hlc: clock.next(),
|
|
5270
|
+
policyVersion: opt.policyVersion ?? 0,
|
|
5271
|
+
ops,
|
|
5272
|
+
};
|
|
5273
|
+
versions.set(origin, env.version);
|
|
5274
|
+
conv.ingest(env, { local: true });
|
|
5275
|
+
recentLocal.push(env);
|
|
5276
|
+
if (recentLocal.length > RECENT_LOCAL_CAP)
|
|
5277
|
+
recentLocal.shift();
|
|
4774
5278
|
for (const cb of [...subscribers])
|
|
4775
|
-
cb(
|
|
5279
|
+
cb(env);
|
|
4776
5280
|
};
|
|
4777
|
-
const
|
|
4778
|
-
source(); // track every commit…
|
|
4779
|
-
untracked(flush); // …and emit the delta since the last flush
|
|
4780
|
-
}, ...(ngDevMode ? [{ debugName: "ref", injector: opt?.injector }] : [{ injector: opt?.injector }]));
|
|
5281
|
+
const unsub = log.subscribe((batch) => emitLocal(batch.ops));
|
|
4781
5282
|
return {
|
|
4782
|
-
|
|
5283
|
+
origin,
|
|
4783
5284
|
subscribe: (cb) => {
|
|
4784
5285
|
subscribers.add(cb);
|
|
4785
5286
|
return () => subscribers.delete(cb);
|
|
4786
5287
|
},
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
? batchOrOps
|
|
4790
|
-
: batchOrOps.ops;
|
|
4791
|
-
if (!ops.length)
|
|
5288
|
+
receive: (env) => {
|
|
5289
|
+
if (env.origin === origin)
|
|
4792
5290
|
return;
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
for (const op of ops) {
|
|
4797
|
-
if (op.path.length === 0) {
|
|
4798
|
-
if (op.kind === 'set')
|
|
4799
|
-
root = op.next;
|
|
4800
|
-
continue; // a root delete is meaningless — ignore
|
|
5291
|
+
if (env.proto !== OP_PROTO_VERSION) {
|
|
5292
|
+
if (isDevMode()) {
|
|
5293
|
+
console.warn(`[@mmstack/primitives] dropped envelope with proto ${env.proto} (expected ${OP_PROTO_VERSION})`);
|
|
4801
5294
|
}
|
|
4802
|
-
|
|
5295
|
+
return;
|
|
4803
5296
|
}
|
|
4804
|
-
|
|
4805
|
-
|
|
5297
|
+
clock.observe(env.hlc);
|
|
5298
|
+
const known = versions.get(env.origin);
|
|
5299
|
+
if (known !== undefined && env.version <= known)
|
|
5300
|
+
return; // duplicate/covered — idempotent
|
|
5301
|
+
if (known !== undefined && env.version !== known + 1) {
|
|
5302
|
+
opt.onGap?.(env.origin, known + 1, env.version);
|
|
5303
|
+
}
|
|
5304
|
+
versions.set(env.origin, env.version);
|
|
5305
|
+
log.flush();
|
|
5306
|
+
const ops = conv.ingest(env);
|
|
5307
|
+
if (ops.length)
|
|
5308
|
+
log.apply(ops);
|
|
5309
|
+
},
|
|
5310
|
+
flush: () => log.flush(),
|
|
5311
|
+
watermark: () => Object.fromEntries(versions),
|
|
5312
|
+
snapshot: () => {
|
|
5313
|
+
log.flush();
|
|
5314
|
+
return { root: untracked(source), wm: Object.fromEntries(versions) };
|
|
5315
|
+
},
|
|
5316
|
+
seed: () => {
|
|
5317
|
+
log.flush();
|
|
5318
|
+
emitLocal([{ kind: 'set', path: [], next: untracked(source) }]);
|
|
5319
|
+
},
|
|
5320
|
+
hydrate: (root, wm) => {
|
|
5321
|
+
log.flush();
|
|
5322
|
+
const covered = wm?.[origin] ?? 0;
|
|
5323
|
+
const pending = recentLocal.filter((e) => e.version > covered);
|
|
5324
|
+
conv.reset();
|
|
5325
|
+
let next = root;
|
|
5326
|
+
for (const e of pending)
|
|
5327
|
+
next = applyOps(next, e.ops);
|
|
5328
|
+
log.apply([{ kind: 'set', path: [], next }]);
|
|
5329
|
+
for (const [o, v] of Object.entries(wm ?? {})) {
|
|
5330
|
+
versions.set(o, Math.max(versions.get(o) ?? 0, v));
|
|
5331
|
+
}
|
|
5332
|
+
for (const e of pending)
|
|
5333
|
+
conv.ingest(e, { local: true });
|
|
4806
5334
|
},
|
|
4807
5335
|
destroy: () => {
|
|
4808
|
-
|
|
5336
|
+
unsub();
|
|
4809
5337
|
subscribers.clear();
|
|
4810
|
-
|
|
5338
|
+
log.destroy();
|
|
5339
|
+
},
|
|
5340
|
+
};
|
|
5341
|
+
}
|
|
5342
|
+
|
|
5343
|
+
/**
|
|
5344
|
+
* Undo/redo for a copy-on-write store, built on the op-log: each tracked change is stored as
|
|
5345
|
+
* its inverse batch, so `undo()` is one `apply` and history costs only the diffs, not full
|
|
5346
|
+
* snapshots. Redoing is invert-of-the-inverse. A new edit made after an undo clears the redo
|
|
5347
|
+
* stack (linear history). Applying a redo/undo does not itself re-enter history.
|
|
5348
|
+
*
|
|
5349
|
+
* Composes with sync for collaborative undo: pass `track: syncClient` so only YOUR writes are
|
|
5350
|
+
* undoable, while `undo()` emits a normal op that propagates to peers (it writes through the
|
|
5351
|
+
* store, which the sync client picks up).
|
|
5352
|
+
*/
|
|
5353
|
+
function storeHistory(source, opt) {
|
|
5354
|
+
const limit = opt?.limit ?? 100;
|
|
5355
|
+
const logOpt = { origin: opt?.origin };
|
|
5356
|
+
if (opt?.driver)
|
|
5357
|
+
logOpt.driver = opt.driver;
|
|
5358
|
+
else
|
|
5359
|
+
logOpt.injector = opt?.injector ?? inject(Injector);
|
|
5360
|
+
const log = opLog(source, logOpt);
|
|
5361
|
+
const undoStack = [];
|
|
5362
|
+
const redoStack = [];
|
|
5363
|
+
const version = signal(0, ...(ngDevMode ? [{ debugName: "version" }] : [])); // monotonic: bumps on every mutation so the computeds recompute
|
|
5364
|
+
let applying = false;
|
|
5365
|
+
const push = (stack, inverse) => {
|
|
5366
|
+
stack.push(inverse);
|
|
5367
|
+
if (stack.length > limit)
|
|
5368
|
+
stack.shift();
|
|
5369
|
+
};
|
|
5370
|
+
const record = (batch) => {
|
|
5371
|
+
if (applying)
|
|
5372
|
+
return; // an undo/redo's own emission must not re-enter history
|
|
5373
|
+
if (!batch.ops.length)
|
|
5374
|
+
return;
|
|
5375
|
+
push(undoStack, invertBatch(batch));
|
|
5376
|
+
redoStack.length = 0; // a fresh edit forks the timeline
|
|
5377
|
+
version.update((v) => v + 1);
|
|
5378
|
+
};
|
|
5379
|
+
// track the sync client's local stream when given, else self-diff every store change
|
|
5380
|
+
const unsub = (opt?.track ?? log).subscribe(record);
|
|
5381
|
+
const run = (from, to) => {
|
|
5382
|
+
const inverse = from.pop();
|
|
5383
|
+
if (!inverse)
|
|
5384
|
+
return;
|
|
5385
|
+
log.flush(); // settle pending local writes before applying
|
|
5386
|
+
applying = true;
|
|
5387
|
+
try {
|
|
5388
|
+
log.apply(inverse);
|
|
5389
|
+
}
|
|
5390
|
+
finally {
|
|
5391
|
+
applying = false;
|
|
5392
|
+
}
|
|
5393
|
+
push(to, invertBatch(inverse)); // the inverse of what we applied restores the other direction
|
|
5394
|
+
version.update((v) => v + 1);
|
|
5395
|
+
};
|
|
5396
|
+
return {
|
|
5397
|
+
canUndo: computed(() => (version(), undoStack.length > 0)),
|
|
5398
|
+
canRedo: computed(() => (version(), redoStack.length > 0)),
|
|
5399
|
+
undo: () => run(undoStack, redoStack),
|
|
5400
|
+
redo: () => run(redoStack, undoStack),
|
|
5401
|
+
clear: () => {
|
|
5402
|
+
undoStack.length = 0;
|
|
5403
|
+
redoStack.length = 0;
|
|
5404
|
+
version.update((v) => v + 1);
|
|
5405
|
+
},
|
|
5406
|
+
destroy: () => {
|
|
5407
|
+
unsub();
|
|
5408
|
+
log.destroy();
|
|
5409
|
+
},
|
|
5410
|
+
};
|
|
5411
|
+
}
|
|
5412
|
+
|
|
5413
|
+
const PERSISTED_STORE_OPTIONS = new InjectionToken('@mmstack/primitives:persisted-store-options');
|
|
5414
|
+
/**
|
|
5415
|
+
* Wire the {@link AsyncStore} backend (and any shared debounce) once, override per call. The
|
|
5416
|
+
* typical use is to install idb-keyval at bootstrap so every `persist`/`persistedStore` persists
|
|
5417
|
+
* without re-passing the backend.
|
|
5418
|
+
*
|
|
5419
|
+
* @example
|
|
5420
|
+
* import * as idbKeyval from 'idb-keyval';
|
|
5421
|
+
* providePersistedStoreOptions({ store: idbKeyval });
|
|
5422
|
+
*/
|
|
5423
|
+
function providePersistedStoreOptions(opt) {
|
|
5424
|
+
return { provide: PERSISTED_STORE_OPTIONS, useValue: opt };
|
|
5425
|
+
}
|
|
5426
|
+
/**
|
|
5427
|
+
* Attach durable local persistence to an EXISTING store: its whole-value snapshot is written to an
|
|
5428
|
+
* async backend (IndexedDB via idb-keyval or Dexie) and restored on boot. A reader over the store,
|
|
5429
|
+
* so it composes with the other op-log readers (`tabSync`, `@mmstack/mesh`) on the same store — a
|
|
5430
|
+
* persisted, synced graph is just two readers. Local durability, not sync.
|
|
5431
|
+
*
|
|
5432
|
+
* Because the backend is async, hydration cannot precede the first read: the store keeps its current
|
|
5433
|
+
* value, then adopts the persisted snapshot once the backend answers, UNLESS a write happened first
|
|
5434
|
+
* (an explicit boot-time write wins over stale disk). Writes are coalesced and flushed on teardown
|
|
5435
|
+
* and on page hide, so the last change is never lost. On the server it is a no-op.
|
|
5436
|
+
*
|
|
5437
|
+
* When the persisted shape evolves, pass `version` and a `migrate` hook: an older snapshot is
|
|
5438
|
+
* brought forward on boot before it is adopted, then re-persisted in the new shape. Because boot is
|
|
5439
|
+
* already async, `migrate` may be async, so the migration ladder can be lazy-imported.
|
|
5440
|
+
*/
|
|
5441
|
+
function persist(source, opt) {
|
|
5442
|
+
const injector = opt.injector ?? inject(Injector);
|
|
5443
|
+
const defaults = injector.get(PERSISTED_STORE_OPTIONS, null);
|
|
5444
|
+
const key = opt.key;
|
|
5445
|
+
const backend = opt.store ?? defaults?.store;
|
|
5446
|
+
const serialize = opt.serialize ?? ((v) => v);
|
|
5447
|
+
const deserialize = opt.deserialize ?? ((r) => r);
|
|
5448
|
+
const version = opt.version;
|
|
5449
|
+
const debounceMs = opt.writeDebounceMs ?? defaults?.writeDebounceMs ?? 300;
|
|
5450
|
+
const read = source;
|
|
5451
|
+
const setRoot = (value) => source.set(value);
|
|
5452
|
+
const VERSION_KEY = '__mmstack_pv';
|
|
5453
|
+
const encode = (value) => version === undefined
|
|
5454
|
+
? serialize(value)
|
|
5455
|
+
: { [VERSION_KEY]: version, data: serialize(value) };
|
|
5456
|
+
const isServer = isPlatformServer(injector.get(PLATFORM_ID));
|
|
5457
|
+
const initialRef = untracked(read); // copy-on-write: an untouched store keeps this reference
|
|
5458
|
+
const hydrated = signal(false, ...(ngDevMode ? [{ debugName: "hydrated" }] : []));
|
|
5459
|
+
if (isServer || !backend) {
|
|
5460
|
+
if (!backend && !isServer && isDevMode()) {
|
|
5461
|
+
console.warn(`[@mmstack/primitives] persist("${key}"): no AsyncStore backend (pass { store } or providePersistedStoreOptions). Running in-memory, not persisted.`);
|
|
5462
|
+
}
|
|
5463
|
+
hydrated.set(true);
|
|
5464
|
+
return {
|
|
5465
|
+
hydrated: hydrated.asReadonly(),
|
|
5466
|
+
flush: () => Promise.resolve(),
|
|
5467
|
+
clear: () => {
|
|
5468
|
+
setRoot(initialRef);
|
|
5469
|
+
return Promise.resolve();
|
|
5470
|
+
},
|
|
5471
|
+
};
|
|
5472
|
+
}
|
|
5473
|
+
let persistedRef = initialRef;
|
|
5474
|
+
void (async () => {
|
|
5475
|
+
try {
|
|
5476
|
+
const raw = await backend.get(key);
|
|
5477
|
+
// apply the snapshot only if nothing wrote in the boot window (explicit write wins)
|
|
5478
|
+
if (raw !== undefined && raw !== null && untracked(read) === initialRef) {
|
|
5479
|
+
let fromVersion = 0;
|
|
5480
|
+
let payload = raw;
|
|
5481
|
+
if (typeof raw === 'object' &&
|
|
5482
|
+
raw !== null &&
|
|
5483
|
+
VERSION_KEY in raw) {
|
|
5484
|
+
const env = raw;
|
|
5485
|
+
fromVersion =
|
|
5486
|
+
typeof env[VERSION_KEY] === 'number'
|
|
5487
|
+
? env[VERSION_KEY]
|
|
5488
|
+
: 0;
|
|
5489
|
+
payload = env['data'];
|
|
5490
|
+
}
|
|
5491
|
+
const target = version ?? 0;
|
|
5492
|
+
if (fromVersion > target) {
|
|
5493
|
+
if (isDevMode()) {
|
|
5494
|
+
console.warn(`[@mmstack/primitives] persist("${key}"): stored snapshot is version ${fromVersion} but this build is ${target}; leaving it untouched (a newer build wrote it).`);
|
|
5495
|
+
}
|
|
5496
|
+
}
|
|
5497
|
+
else {
|
|
5498
|
+
const migrated = !!(opt.migrate && fromVersion < target);
|
|
5499
|
+
let value = deserialize(payload);
|
|
5500
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
5501
|
+
if (migrated)
|
|
5502
|
+
value = await opt.migrate(value, fromVersion);
|
|
5503
|
+
if (untracked(read) === initialRef) {
|
|
5504
|
+
setRoot(value);
|
|
5505
|
+
if (!migrated)
|
|
5506
|
+
persistedRef = value;
|
|
5507
|
+
}
|
|
5508
|
+
}
|
|
5509
|
+
}
|
|
5510
|
+
}
|
|
5511
|
+
catch (err) {
|
|
5512
|
+
if (isDevMode()) {
|
|
5513
|
+
console.warn(`[@mmstack/primitives] persist("${key}") hydrate failed`, err);
|
|
5514
|
+
}
|
|
5515
|
+
}
|
|
5516
|
+
finally {
|
|
5517
|
+
hydrated.set(true);
|
|
5518
|
+
}
|
|
5519
|
+
})();
|
|
5520
|
+
let timer;
|
|
5521
|
+
const write = async (value) => {
|
|
5522
|
+
try {
|
|
5523
|
+
await backend.set(key, encode(value));
|
|
5524
|
+
persistedRef = value;
|
|
5525
|
+
}
|
|
5526
|
+
catch (err) {
|
|
5527
|
+
if (isDevMode()) {
|
|
5528
|
+
console.warn(`[@mmstack/primitives] persist("${key}") write failed`, err);
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
};
|
|
5532
|
+
const cancelTimer = () => {
|
|
5533
|
+
if (timer !== undefined) {
|
|
5534
|
+
clearTimeout(timer);
|
|
5535
|
+
timer = undefined;
|
|
5536
|
+
}
|
|
5537
|
+
};
|
|
5538
|
+
const flush = async () => {
|
|
5539
|
+
cancelTimer();
|
|
5540
|
+
const current = untracked(read);
|
|
5541
|
+
if (!untracked(hydrated) ||
|
|
5542
|
+
current === initialRef ||
|
|
5543
|
+
current === persistedRef)
|
|
5544
|
+
return;
|
|
5545
|
+
await write(current);
|
|
5546
|
+
};
|
|
5547
|
+
effect(() => {
|
|
5548
|
+
if (!hydrated())
|
|
5549
|
+
return;
|
|
5550
|
+
const value = read();
|
|
5551
|
+
untracked(() => {
|
|
5552
|
+
cancelTimer();
|
|
5553
|
+
// untouched / reset-to-initial, or already the value on disk (e.g. just hydrated): skip
|
|
5554
|
+
if (value === initialRef || value === persistedRef)
|
|
5555
|
+
return;
|
|
5556
|
+
timer = setTimeout(() => {
|
|
5557
|
+
timer = undefined;
|
|
5558
|
+
void write(value);
|
|
5559
|
+
}, debounceMs);
|
|
5560
|
+
});
|
|
5561
|
+
}, { injector });
|
|
5562
|
+
const onHide = () => {
|
|
5563
|
+
void flush();
|
|
5564
|
+
};
|
|
5565
|
+
if (typeof document !== 'undefined') {
|
|
5566
|
+
document.addEventListener('visibilitychange', onHide);
|
|
5567
|
+
window.addEventListener('pagehide', onHide);
|
|
5568
|
+
}
|
|
5569
|
+
injector.get(DestroyRef).onDestroy(() => {
|
|
5570
|
+
void flush();
|
|
5571
|
+
if (typeof document !== 'undefined') {
|
|
5572
|
+
document.removeEventListener('visibilitychange', onHide);
|
|
5573
|
+
window.removeEventListener('pagehide', onHide);
|
|
5574
|
+
}
|
|
5575
|
+
});
|
|
5576
|
+
return {
|
|
5577
|
+
hydrated: hydrated.asReadonly(),
|
|
5578
|
+
flush,
|
|
5579
|
+
clear: async () => {
|
|
5580
|
+
cancelTimer();
|
|
5581
|
+
setRoot(initialRef); // back to initialRef, so the persist effect skips (no re-write over the delete)
|
|
5582
|
+
persistedRef = initialRef; // disk is now empty
|
|
5583
|
+
try {
|
|
5584
|
+
await backend.del(key);
|
|
5585
|
+
}
|
|
5586
|
+
catch (err) {
|
|
5587
|
+
if (isDevMode()) {
|
|
5588
|
+
console.warn(`[@mmstack/primitives] persist("${key}") clear failed`, err);
|
|
5589
|
+
}
|
|
5590
|
+
}
|
|
4811
5591
|
},
|
|
4812
5592
|
};
|
|
4813
5593
|
}
|
|
5594
|
+
/**
|
|
5595
|
+
* A `store` with {@link persist} already attached: a whole-value snapshot persisted to an async
|
|
5596
|
+
* backend and restored on boot. Equivalent to `const s = store(initial); persist(s, opt)` — reach
|
|
5597
|
+
* for `persist` directly when you want persistence on a store you already have (e.g. to also
|
|
5598
|
+
* `meshSync` it).
|
|
5599
|
+
*/
|
|
5600
|
+
function persistedStore(initial, opt) {
|
|
5601
|
+
const injector = opt.injector ?? inject(Injector);
|
|
5602
|
+
// store() reads only the signal/store opts it knows; the persistence keys ride along harmlessly
|
|
5603
|
+
const s = store(initial, { ...opt, injector });
|
|
5604
|
+
const handle = persist(s, { ...opt, injector });
|
|
5605
|
+
return { store: s, ...handle };
|
|
5606
|
+
}
|
|
5607
|
+
|
|
5608
|
+
const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
|
|
5609
|
+
function keyOf(item, key) {
|
|
5610
|
+
if (typeof key === 'function')
|
|
5611
|
+
return key(item);
|
|
5612
|
+
return isRecord(item) ? item[key] : item;
|
|
5613
|
+
}
|
|
5614
|
+
/**
|
|
5615
|
+
* Produces a value equal to `next` but sharing as much of `prev`'s reference structure as possible:
|
|
5616
|
+
* an object subtree that did not change keeps its `prev` reference, and array items are matched by
|
|
5617
|
+
* `key` so a surviving item keeps its identity across a reorder/insert/remove (only added items are
|
|
5618
|
+
* new, only removed items are dropped). This is what lets a derived store recompute without tearing
|
|
5619
|
+
* down every downstream `computed` that reads an unchanged part of it.
|
|
5620
|
+
*/
|
|
5621
|
+
function reconcile(prev, next, key = 'id') {
|
|
5622
|
+
return reconcileValue(prev, next, key);
|
|
5623
|
+
}
|
|
5624
|
+
function reconcileValue(prev, next, key) {
|
|
5625
|
+
if (Object.is(prev, next))
|
|
5626
|
+
return prev;
|
|
5627
|
+
if (isPlainArray(prev) && isPlainArray(next)) {
|
|
5628
|
+
const byKey = new Map();
|
|
5629
|
+
for (const item of prev)
|
|
5630
|
+
byKey.set(keyOf(item, key), item);
|
|
5631
|
+
let changed = prev.length !== next.length;
|
|
5632
|
+
const out = next.map((item, i) => {
|
|
5633
|
+
const match = byKey.get(keyOf(item, key));
|
|
5634
|
+
const rv = match !== undefined ? reconcileValue(match, item, key) : item;
|
|
5635
|
+
if (rv !== prev[i])
|
|
5636
|
+
changed = true;
|
|
5637
|
+
return rv;
|
|
5638
|
+
});
|
|
5639
|
+
return changed ? out : prev;
|
|
5640
|
+
}
|
|
5641
|
+
if (isRecord(prev) && isRecord(next)) {
|
|
5642
|
+
const nextKeys = Object.keys(next);
|
|
5643
|
+
let changed = Object.keys(prev).length !== nextKeys.length;
|
|
5644
|
+
const out = {};
|
|
5645
|
+
for (const k of nextKeys) {
|
|
5646
|
+
const rv = Object.hasOwn(prev, k)
|
|
5647
|
+
? reconcileValue(prev[k], next[k], key)
|
|
5648
|
+
: next[k];
|
|
5649
|
+
out[k] = rv;
|
|
5650
|
+
if (rv !== prev[k])
|
|
5651
|
+
changed = true;
|
|
5652
|
+
}
|
|
5653
|
+
return changed ? out : prev;
|
|
5654
|
+
}
|
|
5655
|
+
return next;
|
|
5656
|
+
}
|
|
5657
|
+
/**
|
|
5658
|
+
* A derived STORE, the store-shaped counterpart to `computed`. `fn` receives a mutable draft seeded
|
|
5659
|
+
* with the current value and either mutates it in place or returns a new value; whichever it does,
|
|
5660
|
+
* the result is reconciled against the previous value (see {@link reconcile}) so unchanged subtrees
|
|
5661
|
+
* keep reference identity and keyed array items keep their proxy identity. Reading through the
|
|
5662
|
+
* returned store is fine-grained: a `computed` over one field only recomputes when that field
|
|
5663
|
+
* actually changes, even though the whole projection re-ran.
|
|
5664
|
+
*
|
|
5665
|
+
* Recompute is pull-based, exactly like `computed`: the projection is memoized and re-runs on the
|
|
5666
|
+
* first read after a signal `fn` depends on changes, so reads are always coherent (no waiting on an
|
|
5667
|
+
* effect flush) and nothing recomputes while nobody reads. `fn` must be pure, it runs inside the
|
|
5668
|
+
* reactive computation. Prefer `computed` for a plain value; reach for `projection` when you want
|
|
5669
|
+
* the per-property tracking of a store on top of a derivation.
|
|
5670
|
+
*
|
|
5671
|
+
* ```ts
|
|
5672
|
+
* const active = projection<User[]>(() => users().filter((u) => u.active), [], { key: 'id' });
|
|
5673
|
+
* // active[0].name(); — surviving users keep identity across recomputes
|
|
5674
|
+
* ```
|
|
5675
|
+
*
|
|
5676
|
+
* Needs an injection context (or an explicit `injector`) for the store layer's cleanup on the main
|
|
5677
|
+
* thread; with an explicit store context (`createStoreContext()`) it is injector-free, so it also
|
|
5678
|
+
* runs on a worker host.
|
|
5679
|
+
*
|
|
5680
|
+
* @param fn receives the current draft; mutate it, or return new data.
|
|
5681
|
+
* @param seed the initial value, held before the first run.
|
|
5682
|
+
*/
|
|
5683
|
+
function projection(fn, seed, opt) {
|
|
5684
|
+
const { key = 'id', ...storeOpt } = opt ?? {};
|
|
5685
|
+
const root = linkedSignal(...(ngDevMode ? [{ debugName: "root", source: () => undefined,
|
|
5686
|
+
computation: (_, previous) => {
|
|
5687
|
+
const base = previous ? previous.value : seed;
|
|
5688
|
+
const draft = structuredClone(base);
|
|
5689
|
+
const returned = fn(draft);
|
|
5690
|
+
const next = (returned === undefined ? draft : returned);
|
|
5691
|
+
return reconcile(base, next, key);
|
|
5692
|
+
} }] : [{
|
|
5693
|
+
source: () => undefined,
|
|
5694
|
+
computation: (_, previous) => {
|
|
5695
|
+
const base = previous ? previous.value : seed;
|
|
5696
|
+
const draft = structuredClone(base);
|
|
5697
|
+
const returned = fn(draft);
|
|
5698
|
+
const next = (returned === undefined ? draft : returned);
|
|
5699
|
+
return reconcile(base, next, key);
|
|
5700
|
+
},
|
|
5701
|
+
}]));
|
|
5702
|
+
return toStore(root, storeOpt).asReadonlyStore();
|
|
5703
|
+
}
|
|
4814
5704
|
|
|
4815
5705
|
/**
|
|
4816
5706
|
* @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
|
|
@@ -5008,6 +5898,85 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
5008
5898
|
return writable;
|
|
5009
5899
|
}
|
|
5010
5900
|
|
|
5901
|
+
/** Op-mode sync for a writable store: hello exchange, then live envelopes (RFC §6 tab flavor). */
|
|
5902
|
+
function storeTabSync(sig, opt, bus, injector) {
|
|
5903
|
+
const sync = opSync(sig, {
|
|
5904
|
+
writer: opt.writer ?? 'local',
|
|
5905
|
+
policies: opt.policies,
|
|
5906
|
+
injector,
|
|
5907
|
+
});
|
|
5908
|
+
const helloTimeoutMs = opt.helloTimeoutMs ?? 250;
|
|
5909
|
+
const jitterMs = opt.jitterMs ?? 25;
|
|
5910
|
+
let phase = 'joining';
|
|
5911
|
+
const joinBuffer = [];
|
|
5912
|
+
const responseTimers = new Map();
|
|
5913
|
+
let helloTimer;
|
|
5914
|
+
function goLive() {
|
|
5915
|
+
if (phase === 'live')
|
|
5916
|
+
return;
|
|
5917
|
+
phase = 'live';
|
|
5918
|
+
if (helloTimer !== undefined) {
|
|
5919
|
+
clearTimeout(helloTimer);
|
|
5920
|
+
helloTimer = undefined;
|
|
5921
|
+
}
|
|
5922
|
+
for (const env of joinBuffer.splice(0))
|
|
5923
|
+
sync.receive(env);
|
|
5924
|
+
}
|
|
5925
|
+
const { unsub, post } = bus.subscribe(opt.id, (msg) => {
|
|
5926
|
+
if (!msg || typeof msg !== 'object')
|
|
5927
|
+
return;
|
|
5928
|
+
switch (msg.t) {
|
|
5929
|
+
case 'env':
|
|
5930
|
+
if (phase === 'joining')
|
|
5931
|
+
joinBuffer.push(msg.env);
|
|
5932
|
+
else
|
|
5933
|
+
sync.receive(msg.env);
|
|
5934
|
+
return;
|
|
5935
|
+
case 'hello': {
|
|
5936
|
+
if (phase !== 'live' || msg.from === sync.origin)
|
|
5937
|
+
return;
|
|
5938
|
+
// first responder wins: jittered answer, cancelled when someone else answers first
|
|
5939
|
+
const timer = setTimeout(() => {
|
|
5940
|
+
responseTimers.delete(msg.from);
|
|
5941
|
+
const snap = sync.snapshot();
|
|
5942
|
+
const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
|
|
5943
|
+
post(covered
|
|
5944
|
+
? { t: 'uptodate', to: msg.from }
|
|
5945
|
+
: { t: 'state', to: msg.from, root: snap.root, wm: snap.wm });
|
|
5946
|
+
}, Math.random() * jitterMs);
|
|
5947
|
+
responseTimers.set(msg.from, timer);
|
|
5948
|
+
return;
|
|
5949
|
+
}
|
|
5950
|
+
case 'state':
|
|
5951
|
+
case 'uptodate': {
|
|
5952
|
+
const scheduled = responseTimers.get(msg.to);
|
|
5953
|
+
if (scheduled !== undefined) {
|
|
5954
|
+
clearTimeout(scheduled);
|
|
5955
|
+
responseTimers.delete(msg.to);
|
|
5956
|
+
}
|
|
5957
|
+
if (msg.to !== sync.origin || phase !== 'joining')
|
|
5958
|
+
return;
|
|
5959
|
+
if (msg.t === 'state')
|
|
5960
|
+
sync.hydrate(msg.root, msg.wm);
|
|
5961
|
+
goLive();
|
|
5962
|
+
return;
|
|
5963
|
+
}
|
|
5964
|
+
}
|
|
5965
|
+
});
|
|
5966
|
+
const unsubEnv = sync.subscribe((env) => post({ t: 'env', env }));
|
|
5967
|
+
post({ t: 'hello', from: sync.origin, wm: sync.watermark() });
|
|
5968
|
+
helloTimer = setTimeout(goLive, helloTimeoutMs);
|
|
5969
|
+
injector.get(DestroyRef).onDestroy(() => {
|
|
5970
|
+
if (helloTimer !== undefined)
|
|
5971
|
+
clearTimeout(helloTimer);
|
|
5972
|
+
for (const timer of responseTimers.values())
|
|
5973
|
+
clearTimeout(timer);
|
|
5974
|
+
responseTimers.clear();
|
|
5975
|
+
unsubEnv();
|
|
5976
|
+
unsub();
|
|
5977
|
+
sync.destroy();
|
|
5978
|
+
});
|
|
5979
|
+
}
|
|
5011
5980
|
class MessageBus {
|
|
5012
5981
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
5013
5982
|
listeners = new Map();
|
|
@@ -5119,6 +6088,20 @@ function tabSync(sig, opt) {
|
|
|
5119
6088
|
return sig;
|
|
5120
6089
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
5121
6090
|
const bus = injector.get(MessageBus);
|
|
6091
|
+
const storeKind = sig[STORE_KIND];
|
|
6092
|
+
if (storeKind === 'writable') {
|
|
6093
|
+
storeTabSync(sig, { ...optObj, id }, bus, injector);
|
|
6094
|
+
return sig;
|
|
6095
|
+
}
|
|
6096
|
+
if (storeKind === 'readonly') {
|
|
6097
|
+
if (isDevMode()) {
|
|
6098
|
+
console.warn('[@mmstack/primitives] tabSync: a readonly store cannot receive remote ops — not synced.');
|
|
6099
|
+
}
|
|
6100
|
+
return sig;
|
|
6101
|
+
}
|
|
6102
|
+
if (storeKind === 'mutable' && isDevMode()) {
|
|
6103
|
+
console.warn('[@mmstack/primitives] tabSync: mutable stores fall back to whole-value sync (op diffing needs copy-on-write).');
|
|
6104
|
+
}
|
|
5122
6105
|
const NONE = Symbol();
|
|
5123
6106
|
let received = NONE;
|
|
5124
6107
|
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
@@ -5346,5 +6329,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
5346
6329
|
* Generated bundle index. Do not edit.
|
|
5347
6330
|
*/
|
|
5348
6331
|
|
|
5349
|
-
export { MmActivity, MmTransition, MmViewTransitionName, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, createAttributedPending, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, deferredValue, derived, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, invertBatch, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, latest, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opLog, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pointerDrag, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePausableOptions, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, use, windowSize, withHistory };
|
|
6332
|
+
export { CONCURRENCY_INSTRUMENTATION, MmActivity, MmTransition, MmViewTransitionName, OP_PROTO_VERSION, PAUSABLE_OPTIONS, PERSISTED_STORE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, applyOps, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, compareHlc, compareTotal, createAttributedPending, createConvergingApply, createForwardingScope, createHlcClock, createStoreContext, createTransaction, createTransitionScope, debounce, debounced, deferredValue, derived, diffOps, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, invertBatch, isConflicted, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, keyedArray, latest, lww, map, mapArray, mapObject, mediaQuery, merge3, mergeThree, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opLog, opSync, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, perfCustomTracks, persist, persistedStore, pipeable, piped, pointerDrag, policyStrategy, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, preserve, projection, provideConcurrencyInstrumentation, provideForwardingTransitionScope, providePausableOptions, providePaused, providePersistedStoreOptions, provideTransitionScope, rebaseOps, reconcile, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, storeHistory, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, use, windowSize, withHistory };
|
|
5350
6333
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|