@mmstack/primitives 22.9.3 → 22.10.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
CHANGED
|
@@ -766,7 +766,8 @@ const profile = persistedStore({ first: '', last: '' }, {
|
|
|
766
766
|
`persistedStore` is `store()` + `persist()`. Reach for `persist(store, opt)` directly to add durability to a store you already have — one you also `meshSync`, or a worker-owned store's replica. Persistence is a reader over the op-log, so it composes with the other readers on the same store.
|
|
767
767
|
|
|
768
768
|
```typescript
|
|
769
|
-
import { store, persist
|
|
769
|
+
import { store, persist } from '@mmstack/primitives';
|
|
770
|
+
import { meshSync } from '@mmstack/mesh';
|
|
770
771
|
|
|
771
772
|
const doc = store({ title: '', body: '' });
|
|
772
773
|
persist(doc, { key: 'draft', store: idbKeyval }); // durable to IndexedDB
|
|
@@ -4323,7 +4323,7 @@ function generateOrigin$1() {
|
|
|
4323
4323
|
return globalThis.crypto.randomUUID();
|
|
4324
4324
|
return Math.random().toString(36).substring(2);
|
|
4325
4325
|
}
|
|
4326
|
-
const isPlainArray$
|
|
4326
|
+
const isPlainArray$2 = (v) => Array.isArray(v) && !isOpaque(v);
|
|
4327
4327
|
/**
|
|
4328
4328
|
* Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
|
|
4329
4329
|
* an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
|
|
@@ -4348,7 +4348,7 @@ function diffNode(prev, next, path, ops) {
|
|
|
4348
4348
|
}
|
|
4349
4349
|
return;
|
|
4350
4350
|
}
|
|
4351
|
-
if (isPlainArray$
|
|
4351
|
+
if (isPlainArray$2(prev) && isPlainArray$2(next)) {
|
|
4352
4352
|
// same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
|
|
4353
4353
|
// change is a whole unit — index attribution lies under insert/remove/reorder
|
|
4354
4354
|
if (prev.length === next.length) {
|
|
@@ -4367,7 +4367,7 @@ function applyAt(container, path, idx, op) {
|
|
|
4367
4367
|
const seg = path[idx];
|
|
4368
4368
|
if (seg === '__proto__')
|
|
4369
4369
|
return container;
|
|
4370
|
-
const base = isPlainArray$
|
|
4370
|
+
const base = isPlainArray$2(container)
|
|
4371
4371
|
? container.slice()
|
|
4372
4372
|
: isRecord$1(container)
|
|
4373
4373
|
? { ...container }
|
|
@@ -5064,12 +5064,15 @@ function createHlcClock(now = Date.now) {
|
|
|
5064
5064
|
}
|
|
5065
5065
|
|
|
5066
5066
|
/**
|
|
5067
|
-
* Wire protocol version. Version 2 ops carry `cites` + `epoch` (the dot-citation register)
|
|
5068
|
-
*
|
|
5069
|
-
*
|
|
5070
|
-
*
|
|
5067
|
+
* Wire protocol version. Version 2 ops carry `cites` + `epoch` (the dot-citation register).
|
|
5068
|
+
* Version 3 changes MATERIALIZATION semantics, not shape: grafts descend through plain arrays
|
|
5069
|
+
* (per-index ops materialize instead of silently dropping) and the documented drop rule is
|
|
5070
|
+
* enforced for non-plain ancestors — so a v2 replica and a v3 replica CANNOT converge on the
|
|
5071
|
+
* same op set, which is exactly what the version fence exists to make loud. Envelopes from
|
|
5072
|
+
* other versions are dropped loudly, and the tab-sync join protocol refuses to pair mismatched
|
|
5073
|
+
* peers: versions are never silently mixed, in shape OR in meaning.
|
|
5071
5074
|
*/
|
|
5072
|
-
const OP_PROTO_VERSION =
|
|
5075
|
+
const OP_PROTO_VERSION = 3;
|
|
5073
5076
|
const CONFLICT_BRAND = '~mmstackConflict';
|
|
5074
5077
|
function isConflicted(value) {
|
|
5075
5078
|
return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
|
|
@@ -5326,7 +5329,14 @@ const mergeFold = (merge) => {
|
|
|
5326
5329
|
return { kind: 'set', value: acc };
|
|
5327
5330
|
};
|
|
5328
5331
|
};
|
|
5329
|
-
const
|
|
5332
|
+
const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
|
|
5333
|
+
/**
|
|
5334
|
+
* The graft-side container admission. MUST equal `diffNode`'s descent set (plain records and
|
|
5335
|
+
* plain non-opaque arrays) and `applyAt`'s copy set: emission, incremental apply and
|
|
5336
|
+
* checkpoint materialization decide "container or leaf" identically, or a checkpoint-seeded
|
|
5337
|
+
* replica and an incrementally-applied one disagree about the same op set.
|
|
5338
|
+
*/
|
|
5339
|
+
const isContainer = (v) => isPlainArray$1(v) || isRecord$1(v);
|
|
5330
5340
|
/**
|
|
5331
5341
|
* The unsequenced-topology convergence core: a dot-citation multi-value register per path.
|
|
5332
5342
|
* An op supersedes exactly the sibling dots it cites; uncited concurrent writes stay live; a
|
|
@@ -5385,8 +5395,6 @@ function createConvergingApply(opt) {
|
|
|
5385
5395
|
}
|
|
5386
5396
|
return out.sort((a, b) => a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0);
|
|
5387
5397
|
};
|
|
5388
|
-
// The live siblings a frontier had observed: those that arrived at or below its captured seq.
|
|
5389
|
-
// Used by a fork commit so it supersedes only what it saw when it forked, not later writes.
|
|
5390
5398
|
const liveObserved = (reg, frontier) => {
|
|
5391
5399
|
const live = liveOf(reg);
|
|
5392
5400
|
if (!frontier)
|
|
@@ -5394,11 +5402,6 @@ function createConvergingApply(opt) {
|
|
|
5394
5402
|
const sm = seqs.get(keyOf$1(reg.path));
|
|
5395
5403
|
return live.filter((s) => (sm?.get(s.origin) ?? 0) <= frontier.seq);
|
|
5396
5404
|
};
|
|
5397
|
-
// JSON of a tuple array, not a separator-joined string: `origin` is a caller-supplied value on a
|
|
5398
|
-
// P2P peer, so a naive `origin@p.l#epoch` join lets a crafted origin collide the signatures of two
|
|
5399
|
-
// distinct live sets. A collision makes refresh() skip a fold update, and since that skip is
|
|
5400
|
-
// arrival-order-sensitive it breaks convergence. JSON.stringify escapes the strings and the array
|
|
5401
|
-
// structure is unambiguous, so the signature is injective in the live set.
|
|
5402
5405
|
const sigOf = (live) => JSON.stringify(live.map((s) => [s.origin, s.hlc.p, s.hlc.l, s.epoch, s.kind]));
|
|
5403
5406
|
/** Recompute the fold cache; true iff the materialized result meaningfully changed. */
|
|
5404
5407
|
const refresh = (reg) => {
|
|
@@ -5448,9 +5451,6 @@ function createConvergingApply(opt) {
|
|
|
5448
5451
|
}
|
|
5449
5452
|
return true;
|
|
5450
5453
|
};
|
|
5451
|
-
// A lone tombstone is droppable only if nothing else still materializes its key: no live
|
|
5452
|
-
// descendant register would resurface, and no live ancestor `set` value still holds it. Mirrors
|
|
5453
|
-
// the relay's retention twin so a client that prunes converges with a joiner seeded from the relay.
|
|
5454
5454
|
const tombstoneDroppable = (key, reg) => {
|
|
5455
5455
|
for (const [k, other] of registers) {
|
|
5456
5456
|
if (k === key)
|
|
@@ -5478,31 +5478,37 @@ function createConvergingApply(opt) {
|
|
|
5478
5478
|
}
|
|
5479
5479
|
return undefined;
|
|
5480
5480
|
};
|
|
5481
|
-
// graft with the deterministic type-change rule: a graft whose parent location is not a plain
|
|
5482
|
-
// record is DROPPED (the register stays intact and resurfaces if the container is restored)
|
|
5483
5481
|
const graft = (tree, rel, res) => {
|
|
5484
5482
|
if (!isContainer(tree))
|
|
5485
5483
|
return tree;
|
|
5486
5484
|
const head = String(rel[0]);
|
|
5485
|
+
if (head === '__proto__')
|
|
5486
|
+
return tree;
|
|
5487
|
+
const copyOf = () => (isPlainArray$1(tree) ? tree.slice() : { ...tree });
|
|
5487
5488
|
if (rel.length === 1) {
|
|
5488
5489
|
if (res.kind === 'delete') {
|
|
5489
5490
|
if (!Object.hasOwn(tree, head))
|
|
5490
5491
|
return tree;
|
|
5491
|
-
const copy =
|
|
5492
|
+
const copy = copyOf();
|
|
5492
5493
|
delete copy[head];
|
|
5493
5494
|
return copy;
|
|
5494
5495
|
}
|
|
5495
|
-
|
|
5496
|
+
const copy = copyOf();
|
|
5497
|
+
copy[head] = res.value;
|
|
5498
|
+
return copy;
|
|
5496
5499
|
}
|
|
5497
5500
|
if (!Object.hasOwn(tree, head)) {
|
|
5498
|
-
// vivify an absent middle container so a checkpoint-seeded materialization matches a peer that
|
|
5499
|
-
// applied the ops incrementally (incremental apply creates missing parents). A numeric next
|
|
5500
|
-
// segment vivifies an array, else an object, mirroring the incremental apply path.
|
|
5501
5501
|
const vivified = typeof rel[1] === 'number' ? [] : {};
|
|
5502
|
-
|
|
5502
|
+
const copy = copyOf();
|
|
5503
|
+
copy[head] = graft(vivified, rel.slice(1), res);
|
|
5504
|
+
return copy;
|
|
5503
5505
|
}
|
|
5504
5506
|
const child = graft(tree[head], rel.slice(1), res);
|
|
5505
|
-
|
|
5507
|
+
if (child === tree[head])
|
|
5508
|
+
return tree;
|
|
5509
|
+
const copy = copyOf();
|
|
5510
|
+
copy[head] = child;
|
|
5511
|
+
return copy;
|
|
5506
5512
|
};
|
|
5507
5513
|
/** Would a value at `rel` under `value` materialize, per the graft rules? */
|
|
5508
5514
|
const graftable = (value, rel) => {
|
|
@@ -5604,9 +5610,7 @@ function createConvergingApply(opt) {
|
|
|
5604
5610
|
const seq = ++ingestSeq;
|
|
5605
5611
|
for (const op of env.ops) {
|
|
5606
5612
|
if (o?.frontier && compareHlc(env.hlc, o.frontier) <= 0)
|
|
5607
|
-
continue;
|
|
5608
|
-
// a delete or clear at the root has no parent register to abstain to; it can only blank the
|
|
5609
|
-
// whole document, and materialize would then disagree with the delta path, so drop it
|
|
5613
|
+
continue;
|
|
5610
5614
|
if (!op.path.length && op.kind !== 'set')
|
|
5611
5615
|
continue;
|
|
5612
5616
|
const reg = regAt(op.path);
|
|
@@ -5871,15 +5875,8 @@ function opSync(source, opt) {
|
|
|
5871
5875
|
origin,
|
|
5872
5876
|
});
|
|
5873
5877
|
const subscribers = new Set();
|
|
5874
|
-
// per-origin high-watermark; `versions.get(origin)` IS the local emit counter, so a hydrate/restore
|
|
5875
|
-
// that raises our own watermark also advances the next mint — no separate counter to drift out of
|
|
5876
|
-
// sync and collide with a version acked before a reboot but dropped from a debounced outbox.
|
|
5877
5878
|
const versions = new Map();
|
|
5878
5879
|
const recentLocal = [];
|
|
5879
|
-
// highest stability frontier this peer has pruned to. A remote envelope at or below it is a settled
|
|
5880
|
-
// straggler (its state is compacted away); re-admitting one could resurrect a value below the
|
|
5881
|
-
// frontier, and per-origin version dedup cannot catch a FIRST-CONTACT straggler (no prior entry),
|
|
5882
|
-
// so the frontier is the admission gate that closes that hole on the receive path.
|
|
5883
5880
|
let prunedFrontier;
|
|
5884
5881
|
const resolvedInjector = opt.driver
|
|
5885
5882
|
? null
|
|
@@ -5887,17 +5884,6 @@ function opSync(source, opt) {
|
|
|
5887
5884
|
const log = opLog(source, opt.driver
|
|
5888
5885
|
? { origin, driver: opt.driver }
|
|
5889
5886
|
: { origin, injector: resolvedInjector });
|
|
5890
|
-
// Local envelopes stamped + registered but not yet handed to the transport. A `receive` freezes
|
|
5891
|
-
// this peer's pending writes here so it can ingest the remote WITHOUT emitting mid-receive — the
|
|
5892
|
-
// synchronous re-entrant emission that used to scramble the relay's commit order. The outbox
|
|
5893
|
-
// drains on a LATER tick, so emission always lands outside any receive callstack. Writes made
|
|
5894
|
-
// while a drain is still owed queue here too, keeping wire order == version order (else a receiver
|
|
5895
|
-
// would dedup the older, still-frozen envelope).
|
|
5896
|
-
//
|
|
5897
|
-
// Deferral rides an Angular effect, so it arms only on the injector path — the transport
|
|
5898
|
-
// topologies (`tabSync`, `meshSync`) that actually re-enter through a relay. A custom `driver`
|
|
5899
|
-
// (worker mirror, pure sim, multi-reader) owns its own scheduling and has no such re-entrancy, so
|
|
5900
|
-
// it emits synchronously; the freeze-before-observe STAMPING fix below is identical either way.
|
|
5901
5887
|
const canDefer = !opt.driver;
|
|
5902
5888
|
const outbox = [];
|
|
5903
5889
|
let receiving = false;
|
|
@@ -5983,11 +5969,6 @@ function opSync(source, opt) {
|
|
|
5983
5969
|
opt.onReject?.(env, reason);
|
|
5984
5970
|
return;
|
|
5985
5971
|
}
|
|
5986
|
-
// a settled straggler at or below the pruned stability frontier: reject it (its state is
|
|
5987
|
-
// compacted, re-admitting could resurrect a below-frontier value). All ops in an envelope share
|
|
5988
|
-
// its stamp, so the envelope hlc is the dot for every op. The live relay path never delivers a
|
|
5989
|
-
// below-frontier op (a lagging client gets a snapshot, not a delta), so this only fires on a
|
|
5990
|
-
// stray re-broadcast, e.g. over a P2P/multi-path topology.
|
|
5991
5972
|
if (prunedFrontier && compareHlc(env.hlc, prunedFrontier) <= 0)
|
|
5992
5973
|
return;
|
|
5993
5974
|
const known = versions.get(env.origin);
|
|
@@ -6060,10 +6041,6 @@ function opSync(source, opt) {
|
|
|
6060
6041
|
},
|
|
6061
6042
|
hydrate: (state, pending) => {
|
|
6062
6043
|
log.flush();
|
|
6063
|
-
// rebase this origin's uncovered local writes on top. A caller that keeps a durable outbox
|
|
6064
|
-
// (meshSync, the worker replica) passes its full unacked set, so a long offline burst larger
|
|
6065
|
-
// than the in-memory `recentLocal` cap is never dropped from the rebase; without it, fall back
|
|
6066
|
-
// to the recent-local ring.
|
|
6067
6044
|
const source = pending ?? recentLocal;
|
|
6068
6045
|
const toReplay = source.filter((e) => e.version > (state.wm?.[e.origin] ?? 0));
|
|
6069
6046
|
conv.reset();
|
|
@@ -6926,6 +6903,9 @@ function storeTabSync(sig, opt, bus, injector) {
|
|
|
6926
6903
|
const { unsub, post } = bus.subscribe(opt.id, (msg) => {
|
|
6927
6904
|
if (!msg || typeof msg !== 'object')
|
|
6928
6905
|
return;
|
|
6906
|
+
// the envelope lane has its own loud guard in `receive`; join messages are fenced here
|
|
6907
|
+
if (msg.t !== 'env' && msg.proto !== OP_PROTO_VERSION)
|
|
6908
|
+
return;
|
|
6929
6909
|
switch (msg.t) {
|
|
6930
6910
|
case 'env':
|
|
6931
6911
|
if (phase === 'joining')
|
|
@@ -6942,8 +6922,8 @@ function storeTabSync(sig, opt, bus, injector) {
|
|
|
6942
6922
|
const snap = sync.snapshot();
|
|
6943
6923
|
const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
|
|
6944
6924
|
post(covered
|
|
6945
|
-
? { t: 'uptodate', to: msg.from }
|
|
6946
|
-
: { t: 'state', to: msg.from, state: snap });
|
|
6925
|
+
? { t: 'uptodate', proto: OP_PROTO_VERSION, to: msg.from }
|
|
6926
|
+
: { t: 'state', proto: OP_PROTO_VERSION, to: msg.from, state: snap });
|
|
6947
6927
|
}, Math.random() * jitterMs);
|
|
6948
6928
|
responseTimers.set(msg.from, timer);
|
|
6949
6929
|
return;
|
|
@@ -6965,7 +6945,7 @@ function storeTabSync(sig, opt, bus, injector) {
|
|
|
6965
6945
|
}
|
|
6966
6946
|
});
|
|
6967
6947
|
const unsubEnv = sync.subscribe((env) => post({ t: 'env', env }));
|
|
6968
|
-
post({ t: 'hello', from: sync.origin, wm: sync.watermark() });
|
|
6948
|
+
post({ t: 'hello', proto: OP_PROTO_VERSION, from: sync.origin, wm: sync.watermark() });
|
|
6969
6949
|
helloTimer = setTimeout(goLive, helloTimeoutMs);
|
|
6970
6950
|
injector.get(DestroyRef).onDestroy(() => {
|
|
6971
6951
|
if (helloTimer !== undefined)
|