@mmstack/primitives 20.12.0 → 20.13.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 +2 -2
- package/fesm2022/mmstack-primitives.mjs +932 -130
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +348 -38
- package/package.json +1 -1
|
@@ -4121,7 +4121,7 @@ function isOpaque(value) {
|
|
|
4121
4121
|
value[OPAQUE] === true);
|
|
4122
4122
|
}
|
|
4123
4123
|
|
|
4124
|
-
function isRecord(value) {
|
|
4124
|
+
function isRecord$1(value) {
|
|
4125
4125
|
if (value === null || typeof value !== 'object' || isOpaque(value))
|
|
4126
4126
|
return false;
|
|
4127
4127
|
const proto = Object.getPrototypeOf(value);
|
|
@@ -4137,7 +4137,7 @@ function isLeafValue(value, vivifyEnabled) {
|
|
|
4137
4137
|
return !vivifyEnabled;
|
|
4138
4138
|
if (isOpaque(value))
|
|
4139
4139
|
return true; // opaque always wins — even arrays
|
|
4140
|
-
return !Array.isArray(value) && !isRecord(value);
|
|
4140
|
+
return !Array.isArray(value) && !isRecord$1(value);
|
|
4141
4141
|
}
|
|
4142
4142
|
/**
|
|
4143
4143
|
* @internal
|
|
@@ -4150,7 +4150,7 @@ function resolveVivify(sample, option) {
|
|
|
4150
4150
|
return false;
|
|
4151
4151
|
if (Array.isArray(sample))
|
|
4152
4152
|
return 'array';
|
|
4153
|
-
if (isRecord(sample))
|
|
4153
|
+
if (isRecord$1(sample))
|
|
4154
4154
|
return 'object';
|
|
4155
4155
|
return 'auto';
|
|
4156
4156
|
}
|
|
@@ -4175,7 +4175,7 @@ function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
|
|
|
4175
4175
|
? container
|
|
4176
4176
|
: Array.isArray(container)
|
|
4177
4177
|
? container.slice()
|
|
4178
|
-
: isRecord(container)
|
|
4178
|
+
: isRecord$1(container)
|
|
4179
4179
|
? { ...container }
|
|
4180
4180
|
: container; // non-plain leaf (Date/class instance): legacy in-place attempt
|
|
4181
4181
|
try {
|
|
@@ -4206,7 +4206,7 @@ const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
|
|
|
4206
4206
|
function diffNode(prev, next, path, ops) {
|
|
4207
4207
|
if (Object.is(prev, next))
|
|
4208
4208
|
return;
|
|
4209
|
-
if (isRecord(prev) && isRecord(next)) {
|
|
4209
|
+
if (isRecord$1(prev) && isRecord$1(next)) {
|
|
4210
4210
|
for (const key of Object.keys(prev)) {
|
|
4211
4211
|
if (!Object.hasOwn(next, key))
|
|
4212
4212
|
ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
|
|
@@ -4239,9 +4239,11 @@ function diffNode(prev, next, path, ops) {
|
|
|
4239
4239
|
/** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
|
|
4240
4240
|
function applyAt(container, path, idx, op) {
|
|
4241
4241
|
const seg = path[idx];
|
|
4242
|
+
if (seg === '__proto__')
|
|
4243
|
+
return container;
|
|
4242
4244
|
const base = isPlainArray$1(container)
|
|
4243
4245
|
? container.slice()
|
|
4244
|
-
: isRecord(container)
|
|
4246
|
+
: isRecord$1(container)
|
|
4245
4247
|
? { ...container }
|
|
4246
4248
|
: typeof seg === 'number'
|
|
4247
4249
|
? []
|
|
@@ -4270,6 +4272,8 @@ function applyOps(root, ops) {
|
|
|
4270
4272
|
const list = Array.isArray(ops) ? ops : ops.ops;
|
|
4271
4273
|
let next = root;
|
|
4272
4274
|
for (const op of list) {
|
|
4275
|
+
if (op.kind === 'clear')
|
|
4276
|
+
continue; // register retirement, never a value change
|
|
4273
4277
|
if (op.path.length === 0) {
|
|
4274
4278
|
if (op.kind === 'set')
|
|
4275
4279
|
next = op.next;
|
|
@@ -4283,7 +4287,8 @@ function applyOps(root, ops) {
|
|
|
4283
4287
|
* Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
|
|
4284
4288
|
* {@link opLog}, exported so code outside a log can produce a batch — e.g. diffing a scratch
|
|
4285
4289
|
* 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.
|
|
4290
|
+
* copy-on-write contract: an untouched subtree that kept its reference is skipped. Emits only
|
|
4291
|
+
* `set` and `delete`; `clear` is an emission-layer intent, never a diff product.
|
|
4287
4292
|
*/
|
|
4288
4293
|
function diffOps(prev, next) {
|
|
4289
4294
|
const ops = [];
|
|
@@ -4294,13 +4299,17 @@ function diffOps(prev, next) {
|
|
|
4294
4299
|
* Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
|
|
4295
4300
|
* `prev` — inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
|
|
4296
4301
|
* result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
|
|
4297
|
-
* carry
|
|
4302
|
+
* carry (a wire-serialized batch that stripped them is not invertible). A `clear` is skipped:
|
|
4303
|
+
* it never changed a value, so it has no independent inverse (the accompanying subtree `set`'s
|
|
4304
|
+
* `prev` subsumes restoration).
|
|
4298
4305
|
*/
|
|
4299
4306
|
function invertBatch(batch) {
|
|
4300
4307
|
const ops = Array.isArray(batch) ? batch : batch.ops;
|
|
4301
4308
|
const inverted = [];
|
|
4302
4309
|
for (let i = ops.length - 1; i >= 0; i--) {
|
|
4303
4310
|
const op = ops[i];
|
|
4311
|
+
if (op.kind === 'clear')
|
|
4312
|
+
continue;
|
|
4304
4313
|
if (op.kind === 'delete') {
|
|
4305
4314
|
inverted.push({
|
|
4306
4315
|
kind: 'set',
|
|
@@ -4524,7 +4533,7 @@ function buildChildNode(target, prop, isMutableSource, options) {
|
|
|
4524
4533
|
const value = untracked(target);
|
|
4525
4534
|
const nodeVivify = resolveVivify(value, options.vivify);
|
|
4526
4535
|
const vivifyFn = createVivify(nodeVivify);
|
|
4527
|
-
const equalFn = isMutableSource && (isRecord(value) || Array.isArray(value))
|
|
4536
|
+
const equalFn = isMutableSource && (isRecord$1(value) || Array.isArray(value))
|
|
4528
4537
|
? mutableChildEqual
|
|
4529
4538
|
: undefined;
|
|
4530
4539
|
const computation = derived(target, {
|
|
@@ -4573,7 +4582,7 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4573
4582
|
const v = source();
|
|
4574
4583
|
if (Array.isArray(v) && !isOpaque(v))
|
|
4575
4584
|
return 'array';
|
|
4576
|
-
if (isRecord(v))
|
|
4585
|
+
if (isRecord$1(v))
|
|
4577
4586
|
return 'record';
|
|
4578
4587
|
return 'primitive';
|
|
4579
4588
|
}, ...(ngDevMode ? [{ debugName: "kind" }] : []));
|
|
@@ -4621,7 +4630,7 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4621
4630
|
arr[len] = 'length';
|
|
4622
4631
|
return arr;
|
|
4623
4632
|
}
|
|
4624
|
-
if (!isRecord(v))
|
|
4633
|
+
if (!isRecord$1(v))
|
|
4625
4634
|
return [];
|
|
4626
4635
|
return Reflect.ownKeys(v);
|
|
4627
4636
|
},
|
|
@@ -4639,7 +4648,7 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4639
4648
|
return { enumerable: true, configurable: true };
|
|
4640
4649
|
return;
|
|
4641
4650
|
}
|
|
4642
|
-
if (!isRecord(v) || !(prop in v))
|
|
4651
|
+
if (!isRecord$1(v) || !(prop in v))
|
|
4643
4652
|
return;
|
|
4644
4653
|
return { enumerable: true, configurable: true };
|
|
4645
4654
|
},
|
|
@@ -4981,23 +4990,274 @@ function createHlcClock(now = Date.now) {
|
|
|
4981
4990
|
};
|
|
4982
4991
|
}
|
|
4983
4992
|
|
|
4984
|
-
|
|
4993
|
+
/**
|
|
4994
|
+
* Reserved key holding an element's fractional position inside a keyed container. It lives INSIDE
|
|
4995
|
+
* the element (at `[container, elementKey, '~pos']`), so a reorder is a one-field write that never
|
|
4996
|
+
* collides with a concurrent edit to the element's data. It stays visible on the materialized
|
|
4997
|
+
* element value; do not read, write, or strip it by hand, use the helpers in this file.
|
|
4998
|
+
*/
|
|
4999
|
+
const POS_SEGMENT = '~pos';
|
|
5000
|
+
// Order-preserving fractional-index digits. The alphabet is ASCII-ascending, so a plain string
|
|
5001
|
+
// comparison of two positions matches their fractional order with no decoding.
|
|
5002
|
+
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
5003
|
+
const BASE = DIGITS.length;
|
|
5004
|
+
const digitOf = (c) => DIGITS.indexOf(c);
|
|
5005
|
+
// Repeated inserts into the SAME gap grow a position one digit at a time. Healthy positions stay a
|
|
5006
|
+
// few characters; a long one signals a hot insertion point that should be rebalanced.
|
|
5007
|
+
const POS_WARN_LENGTH = 48;
|
|
5008
|
+
let posWarned = false;
|
|
5009
|
+
const warnIfLong = (pos) => {
|
|
5010
|
+
if (isDevMode() && !posWarned && pos.length >= POS_WARN_LENGTH) {
|
|
5011
|
+
posWarned = true;
|
|
5012
|
+
console.warn(`[@mmstack/primitives] a keyed-container position grew to ${pos.length} characters from ` +
|
|
5013
|
+
`repeated inserts into one gap. Call rebalanceContainer(...) to reclaim precision.`);
|
|
5014
|
+
}
|
|
5015
|
+
return pos;
|
|
5016
|
+
};
|
|
5017
|
+
/**
|
|
5018
|
+
* A compact position string strictly between `before` and `after`, ordered by plain string
|
|
5019
|
+
* comparison. Pass `undefined` for an open end: `posBetween()` seeds the first element,
|
|
5020
|
+
* `posBetween(last)` appends, `posBetween(undefined, first)` prepends. Repeated inserts into the
|
|
5021
|
+
* same gap grow the string one digit at a time rather than colliding, and the result is never equal
|
|
5022
|
+
* to either neighbor. `before` must sort before `after`.
|
|
5023
|
+
*/
|
|
5024
|
+
function posBetween(before, after) {
|
|
5025
|
+
// Neighbors can tie (concurrent inserts into the same gap leave two equal positions, ordered only
|
|
5026
|
+
// by key). There is no position strictly between equal bounds, so open the upper end: the new
|
|
5027
|
+
// position sorts just after them and stays deterministic instead of looping.
|
|
5028
|
+
const upper = before != null && after != null && before >= after ? undefined : after;
|
|
5029
|
+
let i = 0;
|
|
5030
|
+
let out = '';
|
|
5031
|
+
// The upper bound only opens to BASE once we pass `after`'s last constraining digit: an adjacent
|
|
5032
|
+
// pair (gap of 1) leaves no room here, so we commit the lower digit and everything deeper is free.
|
|
5033
|
+
let upperOpen = upper == null;
|
|
5034
|
+
for (;;) {
|
|
5035
|
+
const lo = before != null && i < before.length ? digitOf(before[i]) : 0;
|
|
5036
|
+
const hi = upperOpen || upper == null ? BASE : i < upper.length ? digitOf(upper[i]) : 0;
|
|
5037
|
+
if (hi - lo >= 2)
|
|
5038
|
+
return warnIfLong(out + DIGITS[lo + ((hi - lo) >> 1)]);
|
|
5039
|
+
if (hi === lo) {
|
|
5040
|
+
// digits equal: no room yet, but `after` still constrains deeper digits, keep following it
|
|
5041
|
+
out += DIGITS[lo];
|
|
5042
|
+
i++;
|
|
5043
|
+
continue;
|
|
5044
|
+
}
|
|
5045
|
+
// gap of 1: commit the lower digit and open the upper bound (deeper digits only exceed `before`)
|
|
5046
|
+
out += DIGITS[lo];
|
|
5047
|
+
i++;
|
|
5048
|
+
upperOpen = true;
|
|
5049
|
+
}
|
|
5050
|
+
}
|
|
5051
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
5052
|
+
/**
|
|
5053
|
+
* A keyed container's elements in reading order. Order is a pure function of the materialized
|
|
5054
|
+
* value: elements sort by their `~pos` string, ties broken by key. An element whose `~pos` is
|
|
5055
|
+
* missing or not a string is ordered as if its position were the empty string (it sorts first,
|
|
5056
|
+
* key breaking the tie), so a peer that dropped the position field still lands somewhere
|
|
5057
|
+
* deterministic on every replica.
|
|
5058
|
+
*/
|
|
5059
|
+
function orderedEntries(container) {
|
|
5060
|
+
const entries = [];
|
|
5061
|
+
for (const key of Object.keys(container)) {
|
|
5062
|
+
const value = container[key];
|
|
5063
|
+
const raw = isRecord(value) ? value[POS_SEGMENT] : undefined;
|
|
5064
|
+
entries.push({ key, pos: typeof raw === 'string' ? raw : '', value });
|
|
5065
|
+
}
|
|
5066
|
+
entries.sort((a, b) => a.pos < b.pos ? -1 : a.pos > b.pos ? 1 : a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
5067
|
+
return entries;
|
|
5068
|
+
}
|
|
5069
|
+
const devError = (msg) => {
|
|
5070
|
+
if (typeof ngDevMode !== 'undefined' && ngDevMode)
|
|
5071
|
+
console.error(`[keyed-container] ${msg}`);
|
|
5072
|
+
};
|
|
5073
|
+
const neighborPositions = (entries, index) => {
|
|
5074
|
+
const clamped = Math.max(0, Math.min(index, entries.length));
|
|
5075
|
+
return [entries[clamped - 1]?.pos || undefined, entries[clamped]?.pos || undefined];
|
|
5076
|
+
};
|
|
5077
|
+
/**
|
|
5078
|
+
* Insert `value` under `key` at `index` in reading order (default: append). The position is
|
|
5079
|
+
* computed from the neighbors at that index, so the element lands where asked without renumbering
|
|
5080
|
+
* any sibling. A keyed container is a RECORD, never an array, so this is a per-key write the sync
|
|
5081
|
+
* layer diffs on its own. Returns the assigned position. Re-inserting an existing key overwrites it.
|
|
5082
|
+
*/
|
|
5083
|
+
function insertElement(container, key, value, index) {
|
|
5084
|
+
if (POS_SEGMENT in value)
|
|
5085
|
+
devError(`insertElement: '${POS_SEGMENT}' is managed, drop it from the value`);
|
|
5086
|
+
const entries = orderedEntries(container()).filter((e) => e.key !== key);
|
|
5087
|
+
const [before, after] = neighborPositions(entries, index ?? entries.length);
|
|
5088
|
+
const pos = posBetween(before, after);
|
|
5089
|
+
container.update((c) => ({ ...c, [key]: { ...value, [POS_SEGMENT]: pos } }));
|
|
5090
|
+
return pos;
|
|
5091
|
+
}
|
|
5092
|
+
/**
|
|
5093
|
+
* Move the element at `key` to `index` in reading order. This writes ONLY the element's `~pos`
|
|
5094
|
+
* field, so it never conflicts with a concurrent edit to the same element's data (they land on
|
|
5095
|
+
* different paths and both survive). Returns the new position, or `undefined` if `key` is absent.
|
|
5096
|
+
*/
|
|
5097
|
+
function moveElement(container, key, index) {
|
|
5098
|
+
const current = container()[key];
|
|
5099
|
+
if (current == null)
|
|
5100
|
+
return undefined;
|
|
5101
|
+
const entries = orderedEntries(container()).filter((e) => e.key !== key);
|
|
5102
|
+
const [before, after] = neighborPositions(entries, index);
|
|
5103
|
+
const pos = posBetween(before, after);
|
|
5104
|
+
container.update((c) => ({ ...c, [key]: { ...c[key], [POS_SEGMENT]: pos } }));
|
|
5105
|
+
return pos;
|
|
5106
|
+
}
|
|
5107
|
+
/** Remove the element at `key`. Deletes the whole element (a per-key delete the sync layer folds). */
|
|
5108
|
+
function removeElement(container, key) {
|
|
5109
|
+
container.update((c) => {
|
|
5110
|
+
if (!(key in c))
|
|
5111
|
+
return c;
|
|
5112
|
+
const next = { ...c };
|
|
5113
|
+
delete next[key];
|
|
5114
|
+
return next;
|
|
5115
|
+
});
|
|
5116
|
+
}
|
|
5117
|
+
/**
|
|
5118
|
+
* Reassign every element's position to a fresh, evenly spaced sequence, as an authority write:
|
|
5119
|
+
* each `~pos` set is epoch-bumped so it wins the merge against any concurrent move, while leaving
|
|
5120
|
+
* concurrent edits to element DATA untouched (only the `~pos` fields are written). Use this to
|
|
5121
|
+
* reclaim precision after many same-gap inserts. Existing reading order is preserved.
|
|
5122
|
+
*/
|
|
5123
|
+
function rebalanceContainer(sync, container) {
|
|
5124
|
+
const order = orderedEntries(container());
|
|
5125
|
+
const positions = evenPositions(order.length);
|
|
5126
|
+
sync.override(() => {
|
|
5127
|
+
container.update((c) => {
|
|
5128
|
+
const next = { ...c };
|
|
5129
|
+
order.forEach(({ key }, i) => {
|
|
5130
|
+
next[key] = { ...c[key], [POS_SEGMENT]: positions[i] };
|
|
5131
|
+
});
|
|
5132
|
+
return next;
|
|
5133
|
+
});
|
|
5134
|
+
});
|
|
5135
|
+
}
|
|
5136
|
+
// `n` evenly spaced, order-preserving positions in (0, 1): fraction (i+1)/(n+1) encoded to enough
|
|
5137
|
+
// base-62 digits that consecutive fractions never collide. Compact, so precision is reclaimed.
|
|
5138
|
+
function evenPositions(n) {
|
|
5139
|
+
if (n === 0)
|
|
5140
|
+
return [];
|
|
5141
|
+
const digits = Math.floor(Math.log(n + 1) / Math.log(BASE)) + 2;
|
|
5142
|
+
const out = [];
|
|
5143
|
+
for (let i = 0; i < n; i++) {
|
|
5144
|
+
let f = (i + 1) / (n + 1);
|
|
5145
|
+
let s = '';
|
|
5146
|
+
for (let k = 0; k < digits; k++) {
|
|
5147
|
+
f *= BASE;
|
|
5148
|
+
const d = Math.min(BASE - 1, Math.floor(f));
|
|
5149
|
+
s += DIGITS[d];
|
|
5150
|
+
f -= d;
|
|
5151
|
+
}
|
|
5152
|
+
out.push(s);
|
|
5153
|
+
}
|
|
5154
|
+
return out;
|
|
5155
|
+
}
|
|
5156
|
+
|
|
5157
|
+
/**
|
|
5158
|
+
* Wire protocol version. Version 2 ops carry `cites` + `epoch` (the dot-citation register);
|
|
5159
|
+
* envelopes from other versions are dropped loudly: an op without citations cannot be merged
|
|
5160
|
+
* soundly (it would supersede nothing and its siblings would accumulate forever), so versions
|
|
5161
|
+
* are never silently mixed.
|
|
5162
|
+
*/
|
|
5163
|
+
const OP_PROTO_VERSION = 2;
|
|
4985
5164
|
const CONFLICT_BRAND = '~mmstackConflict';
|
|
4986
5165
|
function isConflicted(value) {
|
|
4987
5166
|
return typeof value === 'object' && value !== null && CONFLICT_BRAND in value;
|
|
4988
5167
|
}
|
|
5168
|
+
const hasControlChar = (s) => {
|
|
5169
|
+
for (let i = 0; i < s.length; i++)
|
|
5170
|
+
if (s.charCodeAt(i) < 0x20)
|
|
5171
|
+
return true;
|
|
5172
|
+
return false;
|
|
5173
|
+
};
|
|
5174
|
+
const isCleanId = (v) => typeof v === 'string' && v.length > 0 && !hasControlChar(v);
|
|
5175
|
+
const isFiniteHlc = (h) => !!h &&
|
|
5176
|
+
typeof h === 'object' &&
|
|
5177
|
+
Number.isFinite(h.p) &&
|
|
5178
|
+
Number.isFinite(h.l);
|
|
5179
|
+
/**
|
|
5180
|
+
* Deterministic, total well-formedness check for a received envelope. Returns a short reason
|
|
5181
|
+
* string when the envelope must be rejected WHOLE, or `null` when it is well-formed. It reads only
|
|
5182
|
+
* the envelope (no clock, no local state), so every replica accepts or rejects a given envelope
|
|
5183
|
+
* identically. This validates SHAPE, not authority: it closes malformed input (control characters
|
|
5184
|
+
* in an id or path segment that could forge a path-key separator, a non-integer version, an unknown
|
|
5185
|
+
* op kind, a negative epoch, forged cites, a root delete, two ops racing on one path). Authority and
|
|
5186
|
+
* access control stay at the relay; direct peer-to-peer rooms are trust-full for authority, so this
|
|
5187
|
+
* shape check is a peer's only line against a malformed neighbor.
|
|
5188
|
+
*/
|
|
5189
|
+
function validateEnvelope(env) {
|
|
5190
|
+
if (!env || typeof env !== 'object')
|
|
5191
|
+
return 'envelope';
|
|
5192
|
+
if (!isCleanId(env.origin))
|
|
5193
|
+
return 'origin';
|
|
5194
|
+
if (!isCleanId(env.writer))
|
|
5195
|
+
return 'writer';
|
|
5196
|
+
if (!isFiniteHlc(env.hlc))
|
|
5197
|
+
return 'hlc';
|
|
5198
|
+
if (!Number.isInteger(env.version) || env.version <= 0)
|
|
5199
|
+
return 'version';
|
|
5200
|
+
if (!Array.isArray(env.ops))
|
|
5201
|
+
return 'ops';
|
|
5202
|
+
const seenPaths = new Set();
|
|
5203
|
+
for (const op of env.ops) {
|
|
5204
|
+
if (!op || typeof op !== 'object')
|
|
5205
|
+
return 'op';
|
|
5206
|
+
if (op.kind !== 'set' && op.kind !== 'delete' && op.kind !== 'clear')
|
|
5207
|
+
return 'kind';
|
|
5208
|
+
if (!Array.isArray(op.path))
|
|
5209
|
+
return 'path';
|
|
5210
|
+
for (const seg of op.path) {
|
|
5211
|
+
if (typeof seg === 'string' && hasControlChar(seg))
|
|
5212
|
+
return 'path-control';
|
|
5213
|
+
if (seg === '__proto__')
|
|
5214
|
+
return 'path-proto';
|
|
5215
|
+
}
|
|
5216
|
+
if (op.path.length === 0 && op.kind !== 'set')
|
|
5217
|
+
return 'root-op';
|
|
5218
|
+
const epoch = op.epoch;
|
|
5219
|
+
if (typeof epoch !== 'number' || !Number.isFinite(epoch) || epoch < 0)
|
|
5220
|
+
return 'epoch';
|
|
5221
|
+
const cites = op.cites;
|
|
5222
|
+
if (!Array.isArray(cites))
|
|
5223
|
+
return 'cites';
|
|
5224
|
+
for (const c of cites) {
|
|
5225
|
+
if (!c ||
|
|
5226
|
+
typeof c !== 'object' ||
|
|
5227
|
+
!isCleanId(c.origin) ||
|
|
5228
|
+
!isFiniteHlc(c.hlc)) {
|
|
5229
|
+
return 'cites';
|
|
5230
|
+
}
|
|
5231
|
+
}
|
|
5232
|
+
// one op per path per envelope: a dot is (origin, hlc), so two ops on one path in one envelope
|
|
5233
|
+
// would share a dot and break the register's per-origin bookkeeping. Segments with control
|
|
5234
|
+
// characters are already rejected above, so this join is unambiguous.
|
|
5235
|
+
const key = op.path.map(String).join(String.fromCharCode(0x1f));
|
|
5236
|
+
if (seenPaths.has(key))
|
|
5237
|
+
return 'dup-path';
|
|
5238
|
+
seenPaths.add(key);
|
|
5239
|
+
}
|
|
5240
|
+
return null;
|
|
5241
|
+
}
|
|
4989
5242
|
const lww = (_ancestor, mine) => mine;
|
|
4990
5243
|
const mergeThree = (ancestor, mine, theirs) => merge3(ancestor, mine, theirs);
|
|
4991
|
-
const preserve = (ancestor, mine, theirs) => ({
|
|
5244
|
+
const preserve = (ancestor, mine, theirs) => ({
|
|
5245
|
+
[CONFLICT_BRAND]: true,
|
|
5246
|
+
siblings: [mine, theirs],
|
|
5247
|
+
mine,
|
|
5248
|
+
theirs,
|
|
5249
|
+
ancestor,
|
|
5250
|
+
});
|
|
4992
5251
|
/**
|
|
4993
5252
|
* Identity-aware array merge: reconciles two concurrent versions of
|
|
4994
5253
|
* an array item-wise by a user-provided identity, instead of last-writer-wins on the whole
|
|
4995
5254
|
* array. Items are matched by key; per-item fields merge via `merge3` against the ancestor
|
|
4996
5255
|
* item; items added on either side survive; an item removed on either side and unedited on
|
|
4997
5256
|
* the other stays removed. Item ORDER follows `mine` (the total-order winner), with `theirs`-
|
|
4998
|
-
* only additions appended
|
|
4999
|
-
*
|
|
5000
|
-
*
|
|
5257
|
+
* only additions appended, and arrays still TRAVEL as whole-value sets. For a list whose elements
|
|
5258
|
+
* move and edit concurrently, model it as a keyed container (a record of elements ordered by
|
|
5259
|
+
* `posBetween`) instead: `insertElement`/`moveElement`/`removeElement` write per element, so a
|
|
5260
|
+
* reorder and a concurrent edit both survive and elements travel one at a time.
|
|
5001
5261
|
*/
|
|
5002
5262
|
function keyedArray(identity, opt) {
|
|
5003
5263
|
const mergeItem = opt?.item ?? mergeThree;
|
|
@@ -5045,19 +5305,23 @@ function compilePolicies(entries) {
|
|
|
5045
5305
|
merge: e.merge,
|
|
5046
5306
|
}));
|
|
5047
5307
|
}
|
|
5308
|
+
function matchSegments(segments, path) {
|
|
5309
|
+
if (segments.length !== path.length)
|
|
5310
|
+
return false;
|
|
5311
|
+
for (let i = 0; i < path.length; i++) {
|
|
5312
|
+
if (segments[i] !== '*' && segments[i] !== String(path[i]))
|
|
5313
|
+
return false;
|
|
5314
|
+
}
|
|
5315
|
+
return true;
|
|
5316
|
+
}
|
|
5048
5317
|
function policyFor(policies, path) {
|
|
5049
|
-
|
|
5050
|
-
if (p.segments
|
|
5051
|
-
|
|
5052
|
-
for (let i = 0; i < path.length; i++) {
|
|
5053
|
-
if (p.segments[i] !== '*' && p.segments[i] !== String(path[i]))
|
|
5054
|
-
continue outer;
|
|
5055
|
-
}
|
|
5056
|
-
return p.merge;
|
|
5318
|
+
for (const p of policies) {
|
|
5319
|
+
if (matchSegments(p.segments, path))
|
|
5320
|
+
return p.merge;
|
|
5057
5321
|
}
|
|
5058
5322
|
return lww;
|
|
5059
5323
|
}
|
|
5060
|
-
const SEP = '';
|
|
5324
|
+
const SEP = ''; // unit separator: keeps joined path keys prefix-unambiguous
|
|
5061
5325
|
const keyOf$1 = (path) => path.map(String).join(SEP);
|
|
5062
5326
|
function structuralEq(a, b) {
|
|
5063
5327
|
if (Object.is(a, b))
|
|
@@ -5082,108 +5346,539 @@ function structuralEq(a, b) {
|
|
|
5082
5346
|
}
|
|
5083
5347
|
return true;
|
|
5084
5348
|
}
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5349
|
+
const kindClass = (k) => (k === 'clear' ? 0 : 1);
|
|
5350
|
+
/**
|
|
5351
|
+
* The register's total order: max by `(epoch, kind-class, hlc, writer, origin)`, where `set`
|
|
5352
|
+
* and `delete` outrank `clear` at equal epoch. Epoch first makes an authority bump decisive
|
|
5353
|
+
* regardless of clocks (and closes stale-value resurrection); the kind-class tier makes a
|
|
5354
|
+
* concurrent edit's survival of a subtree replace categorical rather than a clock race; origin
|
|
5355
|
+
* last keeps the order strict when two replicas share a writer and a stamp.
|
|
5356
|
+
*/
|
|
5357
|
+
function compareSiblings(a, b) {
|
|
5358
|
+
if (a.epoch !== b.epoch)
|
|
5359
|
+
return a.epoch - b.epoch;
|
|
5360
|
+
const kc = kindClass(a.kind) - kindClass(b.kind);
|
|
5361
|
+
if (kc !== 0)
|
|
5362
|
+
return kc;
|
|
5088
5363
|
const byTotal = compareTotal(a.hlc, a.writer, b.hlc, b.writer);
|
|
5089
5364
|
if (byTotal !== 0)
|
|
5090
5365
|
return byTotal;
|
|
5091
5366
|
return a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0;
|
|
5367
|
+
}
|
|
5368
|
+
const maxSibling = (siblings) => siblings.reduce((a, b) => (compareSiblings(a, b) >= 0 ? a : b));
|
|
5369
|
+
/** Last-writer-wins over the live sibling set: the {@link compareSiblings} maximum, as-is. */
|
|
5370
|
+
const defaultFold = (siblings) => {
|
|
5371
|
+
const winner = maxSibling(siblings);
|
|
5372
|
+
return winner.kind === 'set'
|
|
5373
|
+
? { kind: 'set', value: winner.value }
|
|
5374
|
+
: { kind: winner.kind };
|
|
5092
5375
|
};
|
|
5093
|
-
|
|
5376
|
+
// preserve on the register seam: every top-precedence live sibling survives as data. A delete
|
|
5377
|
+
// competes as a value (it may surface inside the conflict as `undefined`); lower-epoch siblings
|
|
5378
|
+
// never surface (the epoch gate stays outermost).
|
|
5379
|
+
const preserveFold = (siblings) => {
|
|
5380
|
+
const winner = maxSibling(siblings);
|
|
5381
|
+
if (winner.kind === 'clear')
|
|
5382
|
+
return { kind: 'clear' };
|
|
5383
|
+
const top = siblings.filter((s) => s.epoch === winner.epoch && s.kind !== 'clear');
|
|
5384
|
+
if (top.length === 1) {
|
|
5385
|
+
return top[0].kind === 'set'
|
|
5386
|
+
? { kind: 'set', value: top[0].value }
|
|
5387
|
+
: { kind: 'delete' };
|
|
5388
|
+
}
|
|
5389
|
+
const ordered = [...top].sort((a, b) => compareSiblings(b, a));
|
|
5390
|
+
const values = ordered.map((s) => (s.kind === 'set' ? s.value : undefined));
|
|
5391
|
+
const conflicted = {
|
|
5392
|
+
[CONFLICT_BRAND]: true,
|
|
5393
|
+
siblings: values,
|
|
5394
|
+
mine: values[0],
|
|
5395
|
+
theirs: values[1],
|
|
5396
|
+
ancestor: ordered[1].prev,
|
|
5397
|
+
};
|
|
5398
|
+
return { kind: 'set', value: conflicted };
|
|
5399
|
+
};
|
|
5400
|
+
// A two-sided MergeFn generalized to N siblings: reduce over the canonically-ordered
|
|
5401
|
+
// top-precedence set, winner first, each step merging the next sibling against its own `prev`
|
|
5402
|
+
// as the ancestor. The iteration order is a pure function of the set, so the result converges
|
|
5403
|
+
// even for merges that are not associative (the reason pairwise-at-arrival diverged).
|
|
5404
|
+
const mergeFold = (merge) => {
|
|
5405
|
+
return (siblings, ctx) => {
|
|
5406
|
+
const ordered = [...siblings].sort((a, b) => compareSiblings(b, a));
|
|
5407
|
+
const winner = ordered[0];
|
|
5408
|
+
if (winner.kind !== 'set')
|
|
5409
|
+
return { kind: winner.kind };
|
|
5410
|
+
let acc = winner.value;
|
|
5411
|
+
for (let i = 1; i < ordered.length; i++) {
|
|
5412
|
+
const s = ordered[i];
|
|
5413
|
+
if (s.kind !== 'set' || s.epoch !== winner.epoch)
|
|
5414
|
+
continue;
|
|
5415
|
+
acc = merge(s.prev, acc, s.value, ctx);
|
|
5416
|
+
}
|
|
5417
|
+
return { kind: 'set', value: acc };
|
|
5418
|
+
};
|
|
5419
|
+
};
|
|
5420
|
+
const isContainer = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
5094
5421
|
/**
|
|
5095
|
-
* The unsequenced-topology convergence core: a
|
|
5096
|
-
*
|
|
5097
|
-
*
|
|
5422
|
+
* The unsequenced-topology convergence core: a dot-citation multi-value register per path.
|
|
5423
|
+
* An op supersedes exactly the sibling dots it cites; uncited concurrent writes stay live; a
|
|
5424
|
+
* pluggable fold resolves the live set at read. Both the live set and any pure fold over it
|
|
5425
|
+
* are functions of the delivered op SET, so any arrival order of the same envelopes (split,
|
|
5426
|
+
* duplicated, cites-before-ops) yields the same state.
|
|
5098
5427
|
*/
|
|
5099
5428
|
function createConvergingApply(opt) {
|
|
5100
5429
|
const registers = new Map();
|
|
5430
|
+
// per-path floor of this replica's own emitted epochs: monotone, survives reset() so a
|
|
5431
|
+
// rehydrated replica can never re-emit below an epoch it already exposed
|
|
5432
|
+
const floors = new Map();
|
|
5433
|
+
// monotone ingest counter + the seq at which each live sibling arrived (keyed pathKey → origin).
|
|
5434
|
+
// captureFrontier() reads the counter in O(1); a frontier-scoped stamp cites only siblings at or
|
|
5435
|
+
// below the captured seq. Side-mapped so the public sibling/checkpoint shapes stay unchanged.
|
|
5436
|
+
let ingestSeq = 0;
|
|
5437
|
+
const seqs = new Map();
|
|
5438
|
+
const setSeq = (key, origin, seq) => {
|
|
5439
|
+
let sm = seqs.get(key);
|
|
5440
|
+
if (!sm)
|
|
5441
|
+
seqs.set(key, (sm = new Map()));
|
|
5442
|
+
sm.set(origin, seq);
|
|
5443
|
+
};
|
|
5101
5444
|
const policies = compilePolicies(opt?.policies ?? []);
|
|
5102
|
-
const
|
|
5445
|
+
const customFolds = (opt?.folds ?? []).map((e) => ({
|
|
5446
|
+
segments: typeof e.path === 'string' ? e.path.split('.') : e.path.map(String),
|
|
5447
|
+
fold: e.fold,
|
|
5448
|
+
}));
|
|
5449
|
+
const foldFor = (path) => {
|
|
5450
|
+
for (const f of customFolds) {
|
|
5451
|
+
if (matchSegments(f.segments, path))
|
|
5452
|
+
return f.fold;
|
|
5453
|
+
}
|
|
5103
5454
|
const merge = policyFor(policies, path);
|
|
5104
|
-
if (merge === lww
|
|
5105
|
-
return
|
|
5455
|
+
if (merge === lww)
|
|
5456
|
+
return defaultFold;
|
|
5457
|
+
if (merge === preserve)
|
|
5458
|
+
return preserveFold;
|
|
5459
|
+
return mergeFold(merge);
|
|
5460
|
+
};
|
|
5461
|
+
const regAt = (path) => {
|
|
5462
|
+
const key = keyOf$1(path);
|
|
5463
|
+
let reg = registers.get(key);
|
|
5464
|
+
if (!reg) {
|
|
5465
|
+
reg = { path, siblings: new Map(), water: new Map(), sig: '' };
|
|
5466
|
+
registers.set(key, reg);
|
|
5106
5467
|
}
|
|
5107
|
-
|
|
5108
|
-
if (Object.is(resolved, winner.next))
|
|
5109
|
-
return winner;
|
|
5110
|
-
return { kind: 'set', path, next: resolved, prev: winner.next };
|
|
5468
|
+
return reg;
|
|
5111
5469
|
};
|
|
5112
|
-
const
|
|
5113
|
-
|
|
5470
|
+
const liveOf = (reg) => {
|
|
5471
|
+
const out = [];
|
|
5472
|
+
for (const [o, s] of reg.siblings) {
|
|
5473
|
+
const w = reg.water.get(o);
|
|
5474
|
+
if (!w || compareHlc(s.hlc, w) > 0)
|
|
5475
|
+
out.push(s);
|
|
5476
|
+
}
|
|
5477
|
+
return out.sort((a, b) => a.origin < b.origin ? -1 : a.origin > b.origin ? 1 : 0);
|
|
5478
|
+
};
|
|
5479
|
+
// The live siblings a frontier had observed: those that arrived at or below its captured seq.
|
|
5480
|
+
// Used by a fork commit so it supersedes only what it saw when it forked, not later writes.
|
|
5481
|
+
const liveObserved = (reg, frontier) => {
|
|
5482
|
+
const live = liveOf(reg);
|
|
5483
|
+
if (!frontier)
|
|
5484
|
+
return live;
|
|
5485
|
+
const sm = seqs.get(keyOf$1(reg.path));
|
|
5486
|
+
return live.filter((s) => (sm?.get(s.origin) ?? 0) <= frontier.seq);
|
|
5487
|
+
};
|
|
5488
|
+
// JSON of a tuple array, not a separator-joined string: `origin` is a caller-supplied value on a
|
|
5489
|
+
// P2P peer, so a naive `origin@p.l#epoch` join lets a crafted origin collide the signatures of two
|
|
5490
|
+
// distinct live sets. A collision makes refresh() skip a fold update, and since that skip is
|
|
5491
|
+
// arrival-order-sensitive it breaks convergence. JSON.stringify escapes the strings and the array
|
|
5492
|
+
// structure is unambiguous, so the signature is injective in the live set.
|
|
5493
|
+
const sigOf = (live) => JSON.stringify(live.map((s) => [s.origin, s.hlc.p, s.hlc.l, s.epoch, s.kind]));
|
|
5494
|
+
/** Recompute the fold cache; true iff the materialized result meaningfully changed. */
|
|
5495
|
+
const refresh = (reg) => {
|
|
5496
|
+
const live = liveOf(reg);
|
|
5497
|
+
const sig = sigOf(live);
|
|
5498
|
+
if (sig === reg.sig)
|
|
5114
5499
|
return false;
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5500
|
+
reg.sig = sig;
|
|
5501
|
+
const next = live.length
|
|
5502
|
+
? foldFor(reg.path)(live, { path: reg.path })
|
|
5503
|
+
: undefined;
|
|
5504
|
+
const prev = reg.result;
|
|
5505
|
+
const same = prev === next ||
|
|
5506
|
+
(!!prev &&
|
|
5507
|
+
!!next &&
|
|
5508
|
+
prev.kind === next.kind &&
|
|
5509
|
+
(prev.kind !== 'set' ||
|
|
5510
|
+
next.kind !== 'set' ||
|
|
5511
|
+
Object.is(prev.value, next.value) ||
|
|
5512
|
+
structuralEq(prev.value, next.value)));
|
|
5513
|
+
if (same)
|
|
5514
|
+
return false; // keep the previous result object: reference identity is the contract
|
|
5515
|
+
reg.result = next;
|
|
5516
|
+
return true;
|
|
5517
|
+
};
|
|
5518
|
+
const descendantsOf = (key) => {
|
|
5519
|
+
const out = [];
|
|
5520
|
+
for (const [k, r] of registers) {
|
|
5521
|
+
if (k === key)
|
|
5522
|
+
continue;
|
|
5523
|
+
if (key === '' ? k !== '' : k.startsWith(key + SEP))
|
|
5524
|
+
out.push(r);
|
|
5525
|
+
}
|
|
5526
|
+
return out.sort((a, b) => a.path.length - b.path.length ||
|
|
5527
|
+
(keyOf$1(a.path) < keyOf$1(b.path) ? -1 : 1));
|
|
5528
|
+
};
|
|
5529
|
+
/** Does `value` still hold a key at `rel` (present, not merely undefined)? */
|
|
5530
|
+
const holdsKey = (value, rel) => {
|
|
5531
|
+
let cur = value;
|
|
5532
|
+
for (const seg of rel) {
|
|
5533
|
+
if (cur === null ||
|
|
5534
|
+
typeof cur !== 'object' ||
|
|
5535
|
+
!Object.hasOwn(cur, String(seg))) {
|
|
5536
|
+
return false;
|
|
5537
|
+
}
|
|
5538
|
+
cur = cur[String(seg)];
|
|
5539
|
+
}
|
|
5540
|
+
return true;
|
|
5541
|
+
};
|
|
5542
|
+
// A lone tombstone is droppable only if nothing else still materializes its key: no live
|
|
5543
|
+
// descendant register would resurface, and no live ancestor `set` value still holds it. Mirrors
|
|
5544
|
+
// the relay's retention twin so a client that prunes converges with a joiner seeded from the relay.
|
|
5545
|
+
const tombstoneDroppable = (key, reg) => {
|
|
5546
|
+
for (const [k, other] of registers) {
|
|
5547
|
+
if (k === key)
|
|
5548
|
+
continue;
|
|
5549
|
+
if (k.startsWith(key + SEP)) {
|
|
5550
|
+
if (liveOf(other).length > 0)
|
|
5551
|
+
return false;
|
|
5552
|
+
}
|
|
5553
|
+
else if (key.startsWith(k === '' ? '' : k + SEP)) {
|
|
5554
|
+
const rel = reg.path.slice(other.path.length);
|
|
5555
|
+
for (const s of liveOf(other)) {
|
|
5556
|
+
if (s.kind === 'set' && holdsKey(s.value, rel))
|
|
5557
|
+
return false;
|
|
5558
|
+
}
|
|
5559
|
+
}
|
|
5560
|
+
}
|
|
5561
|
+
return true;
|
|
5562
|
+
};
|
|
5563
|
+
/** Nearest ancestor register that contributes a value or a deletion (clears abstain). */
|
|
5564
|
+
const nearestContributing = (path) => {
|
|
5565
|
+
for (let len = path.length - 1; len >= 0; len--) {
|
|
5566
|
+
const reg = registers.get(keyOf$1(path.slice(0, len)));
|
|
5567
|
+
if (reg?.result && reg.result.kind !== 'clear')
|
|
5568
|
+
return reg;
|
|
5569
|
+
}
|
|
5570
|
+
return undefined;
|
|
5571
|
+
};
|
|
5572
|
+
// graft with the deterministic type-change rule: a graft whose parent location is not a plain
|
|
5573
|
+
// record is DROPPED (the register stays intact and resurfaces if the container is restored)
|
|
5574
|
+
const graft = (tree, rel, res) => {
|
|
5575
|
+
if (!isContainer(tree))
|
|
5576
|
+
return tree;
|
|
5577
|
+
const head = String(rel[0]);
|
|
5578
|
+
if (rel.length === 1) {
|
|
5579
|
+
if (res.kind === 'delete') {
|
|
5580
|
+
if (!Object.hasOwn(tree, head))
|
|
5581
|
+
return tree;
|
|
5582
|
+
const copy = { ...tree };
|
|
5583
|
+
delete copy[head];
|
|
5584
|
+
return copy;
|
|
5585
|
+
}
|
|
5586
|
+
return { ...tree, [head]: res.value };
|
|
5587
|
+
}
|
|
5588
|
+
if (!Object.hasOwn(tree, head)) {
|
|
5589
|
+
// vivify an absent middle container so a checkpoint-seeded materialization matches a peer that
|
|
5590
|
+
// applied the ops incrementally (incremental apply creates missing parents). A numeric next
|
|
5591
|
+
// segment vivifies an array, else an object, mirroring the incremental apply path.
|
|
5592
|
+
const vivified = typeof rel[1] === 'number' ? [] : {};
|
|
5593
|
+
return { ...tree, [head]: graft(vivified, rel.slice(1), res) };
|
|
5594
|
+
}
|
|
5595
|
+
const child = graft(tree[head], rel.slice(1), res);
|
|
5596
|
+
return child === tree[head] ? tree : { ...tree, [head]: child };
|
|
5597
|
+
};
|
|
5598
|
+
/** Would a value at `rel` under `value` materialize, per the graft rules? */
|
|
5599
|
+
const graftable = (value, rel) => {
|
|
5600
|
+
let cur = value;
|
|
5601
|
+
for (let i = 0; i < rel.length - 1; i++) {
|
|
5602
|
+
if (!isContainer(cur) || !Object.hasOwn(cur, String(rel[i])))
|
|
5603
|
+
return false;
|
|
5604
|
+
cur = cur[String(rel[i])];
|
|
5605
|
+
}
|
|
5606
|
+
return isContainer(cur);
|
|
5607
|
+
};
|
|
5608
|
+
/**
|
|
5609
|
+
* Whether a value at `path` materializes: every contributing ancestor register down the
|
|
5610
|
+
* chain must be a `set` whose value composes containers to the next one. The drop rule is
|
|
5611
|
+
* checked against the WHOLE chain, since a graft fine under its nearest ancestor can still drop
|
|
5612
|
+
* at a scalar further up.
|
|
5613
|
+
*/
|
|
5614
|
+
const shows = (path) => {
|
|
5615
|
+
let holder;
|
|
5616
|
+
for (let len = 0; len < path.length; len++) {
|
|
5617
|
+
const reg = registers.get(keyOf$1(path.slice(0, len)));
|
|
5618
|
+
if (!reg?.result || reg.result.kind === 'clear')
|
|
5619
|
+
continue;
|
|
5620
|
+
if (holder) {
|
|
5621
|
+
const hres = holder.result;
|
|
5622
|
+
if (!hres || hres.kind !== 'set')
|
|
5623
|
+
return false;
|
|
5624
|
+
if (!graftable(hres.value, reg.path.slice(holder.path.length))) {
|
|
5625
|
+
return false;
|
|
5626
|
+
}
|
|
5627
|
+
}
|
|
5628
|
+
holder = reg;
|
|
5629
|
+
}
|
|
5630
|
+
if (!holder)
|
|
5631
|
+
return true; // nothing above constrains → vivify semantics
|
|
5632
|
+
const hres = holder.result;
|
|
5633
|
+
if (!hres || hres.kind !== 'set')
|
|
5634
|
+
return false;
|
|
5635
|
+
return graftable(hres.value, path.slice(holder.path.length));
|
|
5636
|
+
};
|
|
5637
|
+
/** Deepest-live-wins subtree value: the register's fold value with every live descendant fold grafted on. */
|
|
5638
|
+
const materializeAt = (base) => {
|
|
5639
|
+
const res = base.result;
|
|
5640
|
+
let tree = res && res.kind === 'set' ? res.value : undefined;
|
|
5641
|
+
for (const d of descendantsOf(keyOf$1(base.path))) {
|
|
5642
|
+
const r = d.result;
|
|
5643
|
+
if (!r || r.kind === 'clear')
|
|
5644
|
+
continue;
|
|
5645
|
+
if (!shows(d.path))
|
|
5646
|
+
continue; // dropped under a deleted/scalar ancestor (matches applied deltas)
|
|
5647
|
+
tree = graft(tree, d.path.slice(base.path.length), r);
|
|
5648
|
+
}
|
|
5649
|
+
return tree;
|
|
5650
|
+
};
|
|
5651
|
+
const deltas = (changed) => {
|
|
5652
|
+
changed.sort((a, b) => a.reg.path.length - b.reg.path.length ||
|
|
5653
|
+
(keyOf$1(a.reg.path) < keyOf$1(b.reg.path) ? -1 : 1));
|
|
5654
|
+
const out = [];
|
|
5655
|
+
const regions = [];
|
|
5656
|
+
const covered = (key) => regions.some((r) => key === r || (r === '' ? true : key.startsWith(r + SEP)));
|
|
5657
|
+
for (const { reg, before } of changed) {
|
|
5658
|
+
const key = keyOf$1(reg.path);
|
|
5659
|
+
if (covered(key))
|
|
5660
|
+
continue;
|
|
5661
|
+
const res = reg.result;
|
|
5662
|
+
if (!res || res.kind === 'clear') {
|
|
5663
|
+
// the register now abstains: re-materialize the nearest ancestor region it cleared out of
|
|
5664
|
+
if (!reg.path.length)
|
|
5665
|
+
continue;
|
|
5666
|
+
const anc = nearestContributing(reg.path);
|
|
5667
|
+
const ares = anc?.result;
|
|
5668
|
+
if (!anc || !ares || ares.kind !== 'set' || !shows(anc.path))
|
|
5669
|
+
continue;
|
|
5670
|
+
out.push({ kind: 'set', path: anc.path, next: materializeAt(anc) });
|
|
5671
|
+
regions.push(keyOf$1(anc.path));
|
|
5672
|
+
continue;
|
|
5673
|
+
}
|
|
5674
|
+
if (!shows(reg.path))
|
|
5675
|
+
continue; // dropped by the type-change rule or a deleted parent
|
|
5676
|
+
if (res.kind === 'delete') {
|
|
5677
|
+
if (!reg.path.length)
|
|
5678
|
+
continue; // a root delete is meaningless
|
|
5679
|
+
out.push({
|
|
5680
|
+
kind: 'delete',
|
|
5681
|
+
path: reg.path,
|
|
5682
|
+
prev: before?.kind === 'set' ? before.value : undefined,
|
|
5683
|
+
});
|
|
5684
|
+
}
|
|
5685
|
+
else {
|
|
5686
|
+
out.push({ kind: 'set', path: reg.path, next: materializeAt(reg) });
|
|
5687
|
+
}
|
|
5688
|
+
regions.push(key);
|
|
5689
|
+
}
|
|
5690
|
+
return out;
|
|
5118
5691
|
};
|
|
5119
5692
|
return {
|
|
5120
5693
|
ingest: (env, o) => {
|
|
5121
|
-
const
|
|
5122
|
-
|
|
5123
|
-
writer: env.writer,
|
|
5124
|
-
origin: env.origin,
|
|
5125
|
-
};
|
|
5126
|
-
const out = [];
|
|
5694
|
+
const touched = new Map();
|
|
5695
|
+
const seq = ++ingestSeq;
|
|
5127
5696
|
for (const op of env.ops) {
|
|
5697
|
+
if (o?.frontier && compareHlc(env.hlc, o.frontier) <= 0)
|
|
5698
|
+
continue; // below the pruned horizon
|
|
5699
|
+
// a delete or clear at the root has no parent register to abstain to; it can only blank the
|
|
5700
|
+
// whole document, and materialize would then disagree with the delta path, so drop it
|
|
5701
|
+
if (!op.path.length && op.kind !== 'set')
|
|
5702
|
+
continue;
|
|
5703
|
+
const reg = regAt(op.path);
|
|
5128
5704
|
const key = keyOf$1(op.path);
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5705
|
+
if (!touched.has(key))
|
|
5706
|
+
touched.set(key, { reg, before: reg.result });
|
|
5707
|
+
const sop = op;
|
|
5708
|
+
for (const c of sop.cites ?? []) {
|
|
5709
|
+
// a self-citation (the op citing its own dot) would born-dead the write; ignore it
|
|
5710
|
+
if (c.origin === env.origin && compareHlc(c.hlc, env.hlc) === 0)
|
|
5134
5711
|
continue;
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
dominated = true;
|
|
5139
|
-
break;
|
|
5140
|
-
}
|
|
5712
|
+
const cur = reg.water.get(c.origin);
|
|
5713
|
+
if (!cur || compareHlc(c.hlc, cur) > 0)
|
|
5714
|
+
reg.water.set(c.origin, c.hlc);
|
|
5141
5715
|
}
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5716
|
+
const best = reg.siblings.get(env.origin);
|
|
5717
|
+
if (!best || compareHlc(env.hlc, best.hlc) > 0) {
|
|
5718
|
+
const sib = {
|
|
5719
|
+
kind: op.kind,
|
|
5720
|
+
writer: env.writer,
|
|
5721
|
+
origin: env.origin,
|
|
5722
|
+
hlc: env.hlc,
|
|
5723
|
+
epoch: sop.epoch ?? 0,
|
|
5724
|
+
};
|
|
5725
|
+
if (op.kind === 'set')
|
|
5726
|
+
sib.value = op.next;
|
|
5727
|
+
if (op.kind !== 'clear' && Object.hasOwn(op, 'prev')) {
|
|
5728
|
+
sib.prev = op.prev;
|
|
5152
5729
|
}
|
|
5153
|
-
|
|
5730
|
+
reg.siblings.set(env.origin, sib);
|
|
5731
|
+
setSeq(key, env.origin, seq);
|
|
5154
5732
|
}
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
accepted = resolveConcurrent(op, exact.op, op.path);
|
|
5733
|
+
if (o?.local && (sop.epoch ?? 0) > 0) {
|
|
5734
|
+
floors.set(key, Math.max(floors.get(key) ?? 0, sop.epoch));
|
|
5158
5735
|
}
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5736
|
+
}
|
|
5737
|
+
const changed = [];
|
|
5738
|
+
for (const c of touched.values()) {
|
|
5739
|
+
if (refresh(c.reg))
|
|
5740
|
+
changed.push(c);
|
|
5741
|
+
}
|
|
5742
|
+
if ((o?.local && !o?.reconcile) || !changed.length)
|
|
5743
|
+
return [];
|
|
5744
|
+
return deltas(changed);
|
|
5745
|
+
},
|
|
5746
|
+
stamp: (ops, o) => {
|
|
5747
|
+
const out = [];
|
|
5748
|
+
const bump = o?.bump ? 1 : 0;
|
|
5749
|
+
const frontier = o?.frontier;
|
|
5750
|
+
const epochFor = (key, live) => {
|
|
5751
|
+
let e = floors.get(key) ?? 0;
|
|
5752
|
+
for (const s of live)
|
|
5753
|
+
if (s.epoch > e)
|
|
5754
|
+
e = s.epoch;
|
|
5755
|
+
return e + bump;
|
|
5756
|
+
};
|
|
5757
|
+
for (const op of ops) {
|
|
5758
|
+
const key = keyOf$1(op.path);
|
|
5759
|
+
const reg = registers.get(key);
|
|
5760
|
+
const live = reg ? liveObserved(reg, frontier) : [];
|
|
5761
|
+
out.push({
|
|
5762
|
+
...op,
|
|
5763
|
+
cites: live.map((s) => ({ origin: s.origin, hlc: s.hlc })),
|
|
5764
|
+
epoch: epochFor(key, live),
|
|
5765
|
+
});
|
|
5766
|
+
if (op.kind === 'clear')
|
|
5767
|
+
continue;
|
|
5768
|
+
for (const d of descendantsOf(key)) {
|
|
5769
|
+
if (d.result?.kind === 'clear')
|
|
5770
|
+
continue; // already abstaining
|
|
5771
|
+
const dlive = liveObserved(d, frontier);
|
|
5772
|
+
if (!dlive.length)
|
|
5165
5773
|
continue;
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5774
|
+
out.push({
|
|
5775
|
+
kind: 'clear',
|
|
5776
|
+
path: d.path,
|
|
5777
|
+
cites: dlive.map((s) => ({ origin: s.origin, hlc: s.hlc })),
|
|
5778
|
+
epoch: epochFor(keyOf$1(d.path), dlive),
|
|
5779
|
+
});
|
|
5170
5780
|
}
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5781
|
+
}
|
|
5782
|
+
return out;
|
|
5783
|
+
},
|
|
5784
|
+
captureFrontier: () => ({ seq: ingestSeq }),
|
|
5785
|
+
liveAt: (path) => {
|
|
5786
|
+
const reg = registers.get(keyOf$1(path));
|
|
5787
|
+
return reg ? liveOf(reg) : [];
|
|
5788
|
+
},
|
|
5789
|
+
materialize: () => {
|
|
5790
|
+
const root = registers.get('');
|
|
5791
|
+
const res = root?.result;
|
|
5792
|
+
let tree = res && res.kind === 'set' ? res.value : undefined;
|
|
5793
|
+
for (const d of descendantsOf('')) {
|
|
5794
|
+
const r = d.result;
|
|
5795
|
+
if (!r || r.kind === 'clear')
|
|
5796
|
+
continue;
|
|
5797
|
+
if (!shows(d.path))
|
|
5798
|
+
continue; // dropped under a deleted/scalar ancestor (matches applied deltas)
|
|
5799
|
+
if (tree === undefined)
|
|
5800
|
+
tree = {}; // vivify: deeper registers materialize without a root write
|
|
5801
|
+
tree = graft(tree, d.path, r);
|
|
5802
|
+
}
|
|
5803
|
+
return tree;
|
|
5804
|
+
},
|
|
5805
|
+
checkpoint: () => {
|
|
5806
|
+
const out = [];
|
|
5807
|
+
for (const reg of registers.values()) {
|
|
5808
|
+
out.push({
|
|
5809
|
+
path: reg.path,
|
|
5810
|
+
siblings: [...reg.siblings.values()],
|
|
5811
|
+
water: Object.fromEntries(reg.water),
|
|
5177
5812
|
});
|
|
5178
|
-
if (!o?.local) {
|
|
5179
|
-
out.push(accepted);
|
|
5180
|
-
for (const r of replays)
|
|
5181
|
-
out.push(r.op);
|
|
5182
|
-
}
|
|
5183
5813
|
}
|
|
5184
5814
|
return out;
|
|
5185
5815
|
},
|
|
5186
|
-
|
|
5816
|
+
load: (regs) => {
|
|
5817
|
+
const seq = ++ingestSeq;
|
|
5818
|
+
for (const r of regs) {
|
|
5819
|
+
const reg = regAt(r.path);
|
|
5820
|
+
const key = keyOf$1(r.path);
|
|
5821
|
+
for (const s of r.siblings) {
|
|
5822
|
+
const cur = reg.siblings.get(s.origin);
|
|
5823
|
+
if (!cur || compareHlc(s.hlc, cur.hlc) > 0) {
|
|
5824
|
+
reg.siblings.set(s.origin, s);
|
|
5825
|
+
setSeq(key, s.origin, seq);
|
|
5826
|
+
}
|
|
5827
|
+
}
|
|
5828
|
+
for (const [o, h] of Object.entries(r.water)) {
|
|
5829
|
+
const cur = reg.water.get(o);
|
|
5830
|
+
if (!cur || compareHlc(h, cur) > 0)
|
|
5831
|
+
reg.water.set(o, h);
|
|
5832
|
+
}
|
|
5833
|
+
if (opt?.origin) {
|
|
5834
|
+
const own = reg.siblings.get(opt.origin);
|
|
5835
|
+
if (own) {
|
|
5836
|
+
floors.set(key, Math.max(floors.get(key) ?? 0, own.epoch));
|
|
5837
|
+
}
|
|
5838
|
+
}
|
|
5839
|
+
refresh(reg);
|
|
5840
|
+
}
|
|
5841
|
+
},
|
|
5842
|
+
prune: (frontier) => {
|
|
5843
|
+
for (const [key, reg] of [...registers]) {
|
|
5844
|
+
const sm = seqs.get(key);
|
|
5845
|
+
for (const [o, s] of [...reg.siblings]) {
|
|
5846
|
+
const w = reg.water.get(o);
|
|
5847
|
+
if (compareHlc(s.hlc, frontier) <= 0 &&
|
|
5848
|
+
w &&
|
|
5849
|
+
compareHlc(s.hlc, w) <= 0) {
|
|
5850
|
+
reg.siblings.delete(o);
|
|
5851
|
+
sm?.delete(o);
|
|
5852
|
+
}
|
|
5853
|
+
}
|
|
5854
|
+
for (const [o, h] of [...reg.water]) {
|
|
5855
|
+
if (compareHlc(h, frontier) <= 0)
|
|
5856
|
+
reg.water.delete(o);
|
|
5857
|
+
}
|
|
5858
|
+
if (reg.siblings.size === 0 && reg.water.size === 0) {
|
|
5859
|
+
registers.delete(key);
|
|
5860
|
+
seqs.delete(key);
|
|
5861
|
+
floors.delete(key);
|
|
5862
|
+
}
|
|
5863
|
+
}
|
|
5864
|
+
const byDepth = [...registers.entries()].sort((a, b) => b[1].path.length - a[1].path.length);
|
|
5865
|
+
for (const [key, reg] of byDepth) {
|
|
5866
|
+
const live = liveOf(reg);
|
|
5867
|
+
if (live.length === 1 &&
|
|
5868
|
+
live[0].kind === 'delete' &&
|
|
5869
|
+
reg.siblings.size === 1 &&
|
|
5870
|
+
compareHlc(live[0].hlc, frontier) <= 0 &&
|
|
5871
|
+
tombstoneDroppable(key, reg)) {
|
|
5872
|
+
registers.delete(key);
|
|
5873
|
+
seqs.delete(key);
|
|
5874
|
+
floors.delete(key);
|
|
5875
|
+
}
|
|
5876
|
+
}
|
|
5877
|
+
},
|
|
5878
|
+
reset: () => {
|
|
5879
|
+
registers.clear();
|
|
5880
|
+
seqs.clear();
|
|
5881
|
+
},
|
|
5187
5882
|
};
|
|
5188
5883
|
}
|
|
5189
5884
|
function getAtPath(root, path) {
|
|
@@ -5211,6 +5906,10 @@ function rebaseOps(root, pending, remote, policies) {
|
|
|
5211
5906
|
for (const batch of pending) {
|
|
5212
5907
|
const next = [];
|
|
5213
5908
|
for (const op of batch) {
|
|
5909
|
+
if (op.kind === 'clear') {
|
|
5910
|
+
next.push(op); // a register intent, not a value change: passes through untouched
|
|
5911
|
+
continue;
|
|
5912
|
+
}
|
|
5214
5913
|
const cur = getAtPath(base, op.path);
|
|
5215
5914
|
if (op.kind === 'delete') {
|
|
5216
5915
|
next.push({ kind: 'delete', path: op.path, prev: cur });
|
|
@@ -5249,20 +5948,30 @@ function generateOrigin() {
|
|
|
5249
5948
|
}
|
|
5250
5949
|
/**
|
|
5251
5950
|
* Wires a copy-on-write signal (a `store` root) to the op protocol: local writes emit
|
|
5252
|
-
* stamped envelopes, received envelopes fold in
|
|
5253
|
-
* unsequenced-topology client core that
|
|
5951
|
+
* stamped envelopes (citing the sibling dots they observed), received envelopes fold in
|
|
5952
|
+
* through the converging register. The unsequenced-topology client core that
|
|
5953
|
+
* `tabSync(store)` and P2P transports build on.
|
|
5254
5954
|
*/
|
|
5255
5955
|
const RECENT_LOCAL_CAP = 64;
|
|
5256
5956
|
function opSync(source, opt) {
|
|
5257
5957
|
const origin = opt.origin ?? generateOrigin();
|
|
5258
5958
|
const clock = opt.clock ?? createHlcClock();
|
|
5259
|
-
const conv = createConvergingApply({
|
|
5959
|
+
const conv = createConvergingApply({
|
|
5960
|
+
policies: opt.policies,
|
|
5961
|
+
folds: opt.folds,
|
|
5962
|
+
origin,
|
|
5963
|
+
});
|
|
5260
5964
|
const subscribers = new Set();
|
|
5261
5965
|
// per-origin high-watermark; `versions.get(origin)` IS the local emit counter, so a hydrate/restore
|
|
5262
5966
|
// that raises our own watermark also advances the next mint — no separate counter to drift out of
|
|
5263
5967
|
// sync and collide with a version acked before a reboot but dropped from a debounced outbox.
|
|
5264
5968
|
const versions = new Map();
|
|
5265
5969
|
const recentLocal = [];
|
|
5970
|
+
// highest stability frontier this peer has pruned to. A remote envelope at or below it is a settled
|
|
5971
|
+
// straggler (its state is compacted away); re-admitting one could resurrect a value below the
|
|
5972
|
+
// frontier, and per-origin version dedup cannot catch a FIRST-CONTACT straggler (no prior entry),
|
|
5973
|
+
// so the frontier is the admission gate that closes that hole on the receive path.
|
|
5974
|
+
let prunedFrontier;
|
|
5266
5975
|
const resolvedInjector = opt.driver
|
|
5267
5976
|
? null
|
|
5268
5977
|
: (opt.injector ?? inject(Injector));
|
|
@@ -5283,6 +5992,9 @@ function opSync(source, opt) {
|
|
|
5283
5992
|
const canDefer = !opt.driver;
|
|
5284
5993
|
const outbox = [];
|
|
5285
5994
|
let receiving = false;
|
|
5995
|
+
let bumping = false;
|
|
5996
|
+
// set while a synced fork's commit is emitting: freezes emission cites to what the fork observed
|
|
5997
|
+
let scopeFrontier;
|
|
5286
5998
|
// a signal the drain reaction tracks; bumping it schedules an outbox drain for the next tick
|
|
5287
5999
|
const drainTick = signal(0, ...(ngDevMode ? [{ debugName: "drainTick" }] : []));
|
|
5288
6000
|
const scheduleDrain = () => drainTick.update((v) => v + 1);
|
|
@@ -5295,6 +6007,8 @@ function opSync(source, opt) {
|
|
|
5295
6007
|
notify(env);
|
|
5296
6008
|
};
|
|
5297
6009
|
const emitLocal = (ops) => {
|
|
6010
|
+
const frontier = scopeFrontier;
|
|
6011
|
+
const stamped = conv.stamp(ops, { bump: bumping, frontier });
|
|
5298
6012
|
const nextVersion = (versions.get(origin) ?? 0) + 1;
|
|
5299
6013
|
const env = {
|
|
5300
6014
|
proto: OP_PROTO_VERSION,
|
|
@@ -5303,10 +6017,19 @@ function opSync(source, opt) {
|
|
|
5303
6017
|
version: nextVersion,
|
|
5304
6018
|
hlc: clock.next(),
|
|
5305
6019
|
policyVersion: opt.policyVersion ?? 0,
|
|
5306
|
-
ops,
|
|
6020
|
+
ops: stamped,
|
|
5307
6021
|
};
|
|
5308
6022
|
versions.set(origin, nextVersion);
|
|
5309
|
-
|
|
6023
|
+
if (frontier) {
|
|
6024
|
+
// a fork commit: its ops are concurrent siblings (they cite only the fork-time frontier), so
|
|
6025
|
+
// move the store to the fold winner rather than leaving the raw committed value in place
|
|
6026
|
+
const reconciled = conv.ingest(env, { local: true, reconcile: true });
|
|
6027
|
+
if (reconciled.length)
|
|
6028
|
+
log.apply(reconciled);
|
|
6029
|
+
}
|
|
6030
|
+
else {
|
|
6031
|
+
conv.ingest(env, { local: true });
|
|
6032
|
+
}
|
|
5310
6033
|
recentLocal.push(env);
|
|
5311
6034
|
if (recentLocal.length > RECENT_LOCAL_CAP)
|
|
5312
6035
|
recentLocal.shift();
|
|
@@ -5338,10 +6061,25 @@ function opSync(source, opt) {
|
|
|
5338
6061
|
return;
|
|
5339
6062
|
if (env.proto !== OP_PROTO_VERSION) {
|
|
5340
6063
|
if (isDevMode()) {
|
|
5341
|
-
console.warn(`[@mmstack/primitives] dropped envelope with proto ${env.proto} (expected ${OP_PROTO_VERSION})`);
|
|
6064
|
+
console.warn(`[@mmstack/primitives] dropped envelope with proto ${env.proto} (expected ${OP_PROTO_VERSION}: ops must carry cites + epoch; emitters from another protocol version are rejected rather than silently mixed)`);
|
|
6065
|
+
}
|
|
6066
|
+
return;
|
|
6067
|
+
}
|
|
6068
|
+
const reason = validateEnvelope(env);
|
|
6069
|
+
if (reason !== null) {
|
|
6070
|
+
if (isDevMode()) {
|
|
6071
|
+
console.warn(`[@mmstack/primitives] dropped malformed envelope (${reason}) from origin ${String(env.origin)}`);
|
|
5342
6072
|
}
|
|
6073
|
+
opt.onReject?.(env, reason);
|
|
5343
6074
|
return;
|
|
5344
6075
|
}
|
|
6076
|
+
// a settled straggler at or below the pruned stability frontier: reject it (its state is
|
|
6077
|
+
// compacted, re-admitting could resurrect a below-frontier value). All ops in an envelope share
|
|
6078
|
+
// its stamp, so the envelope hlc is the dot for every op. The live relay path never delivers a
|
|
6079
|
+
// below-frontier op (a lagging client gets a snapshot, not a delta), so this only fires on a
|
|
6080
|
+
// stray re-broadcast, e.g. over a P2P/multi-path topology.
|
|
6081
|
+
if (prunedFrontier && compareHlc(env.hlc, prunedFrontier) <= 0)
|
|
6082
|
+
return;
|
|
5345
6083
|
const known = versions.get(env.origin);
|
|
5346
6084
|
if (known !== undefined && env.version <= known)
|
|
5347
6085
|
return; // duplicate/covered — idempotent
|
|
@@ -5351,14 +6089,9 @@ function opSync(source, opt) {
|
|
|
5351
6089
|
versions.set(env.origin, env.version);
|
|
5352
6090
|
receiving = true;
|
|
5353
6091
|
try {
|
|
5354
|
-
// Freeze pending local FIRST — stamped by a clock that has NOT yet observed this remote, so
|
|
5355
|
-
// the local write keeps its causally-independent (original) stamp rather than being lifted
|
|
5356
|
-
// above the remote and always winning. Emission is deferred to a tick via the outbox; this
|
|
5357
|
-
// only registers + stamps.
|
|
5358
6092
|
log.flush();
|
|
5359
6093
|
clock.observe(env.hlc);
|
|
5360
6094
|
const ops = conv.ingest(env);
|
|
5361
|
-
// apply the converged result — a local write that LOST its path rolls back visibly here
|
|
5362
6095
|
if (ops.length)
|
|
5363
6096
|
log.apply(ops);
|
|
5364
6097
|
}
|
|
@@ -5370,45 +6103,88 @@ function opSync(source, opt) {
|
|
|
5370
6103
|
drainOutbox();
|
|
5371
6104
|
log.flush();
|
|
5372
6105
|
},
|
|
6106
|
+
override: (fn) => {
|
|
6107
|
+
log.flush(); // earlier pending writes emit un-bumped
|
|
6108
|
+
bumping = true;
|
|
6109
|
+
try {
|
|
6110
|
+
fn();
|
|
6111
|
+
log.flush();
|
|
6112
|
+
}
|
|
6113
|
+
finally {
|
|
6114
|
+
bumping = false;
|
|
6115
|
+
}
|
|
6116
|
+
},
|
|
6117
|
+
captureFrontier: () => {
|
|
6118
|
+
log.flush(); // fold pending base writes in first, so they count as observed
|
|
6119
|
+
return conv.captureFrontier();
|
|
6120
|
+
},
|
|
6121
|
+
commitScope: (frontier, fn) => {
|
|
6122
|
+
log.flush(); // earlier pending writes emit against the live frontier, not this one
|
|
6123
|
+
scopeFrontier = frontier;
|
|
6124
|
+
try {
|
|
6125
|
+
fn();
|
|
6126
|
+
log.flush(); // stamp + register the scoped writes now, while the frontier is frozen
|
|
6127
|
+
}
|
|
6128
|
+
finally {
|
|
6129
|
+
scopeFrontier = undefined;
|
|
6130
|
+
}
|
|
6131
|
+
},
|
|
5373
6132
|
watermark: () => Object.fromEntries(versions),
|
|
6133
|
+
prune: (frontier) => {
|
|
6134
|
+
if (!prunedFrontier || compareHlc(frontier, prunedFrontier) > 0) {
|
|
6135
|
+
prunedFrontier = frontier;
|
|
6136
|
+
}
|
|
6137
|
+
conv.prune(frontier);
|
|
6138
|
+
},
|
|
5374
6139
|
snapshot: () => {
|
|
5375
6140
|
log.flush();
|
|
5376
|
-
return {
|
|
6141
|
+
return {
|
|
6142
|
+
root: untracked(source),
|
|
6143
|
+
registers: conv.checkpoint(),
|
|
6144
|
+
wm: Object.fromEntries(versions),
|
|
6145
|
+
};
|
|
5377
6146
|
},
|
|
5378
6147
|
seed: () => {
|
|
5379
6148
|
log.flush();
|
|
5380
6149
|
emitLocal([{ kind: 'set', path: [], next: untracked(source) }]);
|
|
5381
6150
|
},
|
|
5382
|
-
hydrate: (
|
|
6151
|
+
hydrate: (state, pending) => {
|
|
5383
6152
|
log.flush();
|
|
5384
|
-
|
|
5385
|
-
|
|
6153
|
+
// rebase this origin's uncovered local writes on top. A caller that keeps a durable outbox
|
|
6154
|
+
// (meshSync, the worker replica) passes its full unacked set, so a long offline burst larger
|
|
6155
|
+
// than the in-memory `recentLocal` cap is never dropped from the rebase; without it, fall back
|
|
6156
|
+
// to the recent-local ring.
|
|
6157
|
+
const source = pending ?? recentLocal;
|
|
6158
|
+
const toReplay = source.filter((e) => e.version > (state.wm?.[e.origin] ?? 0));
|
|
5386
6159
|
conv.reset();
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
6160
|
+
conv.load(state.registers ?? []);
|
|
6161
|
+
const deltas = [];
|
|
6162
|
+
for (const e of toReplay) {
|
|
6163
|
+
deltas.push(...conv.ingest(e, { local: true, reconcile: true }));
|
|
6164
|
+
}
|
|
6165
|
+
log.apply([
|
|
6166
|
+
{ kind: 'set', path: [], next: applyOps(state.root, deltas) },
|
|
6167
|
+
]);
|
|
6168
|
+
for (const [o, v] of Object.entries(state.wm ?? {})) {
|
|
5392
6169
|
versions.set(o, Math.max(versions.get(o) ?? 0, v));
|
|
5393
6170
|
}
|
|
5394
|
-
for (const e of pending)
|
|
5395
|
-
conv.ingest(e, { local: true });
|
|
5396
6171
|
},
|
|
5397
6172
|
restore: (envs, highWater) => {
|
|
5398
|
-
let
|
|
6173
|
+
let tailOrigin;
|
|
5399
6174
|
for (const env of envs) {
|
|
5400
|
-
if (env.origin !== origin)
|
|
5401
|
-
continue; // only this origin's own durable outbox
|
|
5402
6175
|
clock.observe(env.hlc); // keep the clock ≥ restored stamps before any future mint
|
|
5403
6176
|
log.apply(env.ops); // reflect the offline edit in the store, echo-free
|
|
5404
6177
|
conv.ingest(env, { local: true }); // register as a local winner (survives a reconnect merge)
|
|
5405
6178
|
recentLocal.push(env);
|
|
5406
6179
|
if (recentLocal.length > RECENT_LOCAL_CAP)
|
|
5407
6180
|
recentLocal.shift();
|
|
5408
|
-
|
|
6181
|
+
versions.set(env.origin, Math.max(versions.get(env.origin) ?? 0, env.version));
|
|
6182
|
+
tailOrigin = env.origin;
|
|
5409
6183
|
notify(env); // hand to the transport to resend the unacknowledged tail
|
|
5410
6184
|
}
|
|
5411
|
-
|
|
6185
|
+
if (highWater != null && tailOrigin != null) {
|
|
6186
|
+
versions.set(tailOrigin, Math.max(versions.get(tailOrigin) ?? 0, highWater));
|
|
6187
|
+
}
|
|
5412
6188
|
},
|
|
5413
6189
|
destroy: () => {
|
|
5414
6190
|
drainOutbox(); // don't silently drop frozen-but-unsent local writes
|
|
@@ -5419,6 +6195,32 @@ function opSync(source, opt) {
|
|
|
5419
6195
|
},
|
|
5420
6196
|
};
|
|
5421
6197
|
}
|
|
6198
|
+
/**
|
|
6199
|
+
* Fork a synced store for isolated edits (an agent branch, a staged review), keeping the correct
|
|
6200
|
+
* emission semantics on commit. The fork observes the base as it was when this call ran; committing
|
|
6201
|
+
* emits its diff citing only those observed dots, so an edit that landed on the base mid-flight
|
|
6202
|
+
* stays a concurrent sibling and the configured fold decides between them, rather than the commit
|
|
6203
|
+
* overwriting a write it never saw. `rebase()` re-observes the base (a following commit then
|
|
6204
|
+
* supersedes what is visible now, the reviewed-and-apply step). Pass the same `store` and `sync`
|
|
6205
|
+
* that are wired together; the fork is a plain {@link Fork} otherwise, so `forkStore` itself stays
|
|
6206
|
+
* sync-agnostic.
|
|
6207
|
+
*/
|
|
6208
|
+
function syncedFork(sync, store, opt) {
|
|
6209
|
+
let frontier = sync.captureFrontier();
|
|
6210
|
+
const f = forkStore(store, opt);
|
|
6211
|
+
return {
|
|
6212
|
+
store: f.store,
|
|
6213
|
+
ops: f.ops,
|
|
6214
|
+
commit: () => sync.commitScope(frontier, () => f.commit()),
|
|
6215
|
+
discard: () => {
|
|
6216
|
+
f.discard();
|
|
6217
|
+
frontier = sync.captureFrontier();
|
|
6218
|
+
},
|
|
6219
|
+
rebase: () => {
|
|
6220
|
+
frontier = sync.captureFrontier();
|
|
6221
|
+
},
|
|
6222
|
+
};
|
|
6223
|
+
}
|
|
5422
6224
|
|
|
5423
6225
|
/**
|
|
5424
6226
|
* Undo/redo for a copy-on-write store, built on the op-log: each tracked change is stored as
|
|
@@ -5689,7 +6491,7 @@ const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
|
|
|
5689
6491
|
function keyOf(item, key) {
|
|
5690
6492
|
if (typeof key === 'function')
|
|
5691
6493
|
return key(item);
|
|
5692
|
-
return isRecord(item) ? item[key] : item;
|
|
6494
|
+
return isRecord$1(item) ? item[key] : item;
|
|
5693
6495
|
}
|
|
5694
6496
|
/**
|
|
5695
6497
|
* Produces a value equal to `next` but sharing as much of `prev`'s reference structure as possible:
|
|
@@ -5718,7 +6520,7 @@ function reconcileValue(prev, next, key) {
|
|
|
5718
6520
|
});
|
|
5719
6521
|
return changed ? out : prev;
|
|
5720
6522
|
}
|
|
5721
|
-
if (isRecord(prev) && isRecord(next)) {
|
|
6523
|
+
if (isRecord$1(prev) && isRecord$1(next)) {
|
|
5722
6524
|
const nextKeys = Object.keys(next);
|
|
5723
6525
|
let changed = Object.keys(prev).length !== nextKeys.length;
|
|
5724
6526
|
const out = {};
|
|
@@ -6022,7 +6824,7 @@ function storeTabSync(sig, opt, bus, injector) {
|
|
|
6022
6824
|
const covered = Object.entries(snap.wm).every(([origin, v]) => (msg.wm[origin] ?? 0) >= v);
|
|
6023
6825
|
post(covered
|
|
6024
6826
|
? { t: 'uptodate', to: msg.from }
|
|
6025
|
-
: { t: 'state', to: msg.from,
|
|
6827
|
+
: { t: 'state', to: msg.from, state: snap });
|
|
6026
6828
|
}, Math.random() * jitterMs);
|
|
6027
6829
|
responseTimers.set(msg.from, timer);
|
|
6028
6830
|
return;
|
|
@@ -6037,7 +6839,7 @@ function storeTabSync(sig, opt, bus, injector) {
|
|
|
6037
6839
|
if (msg.to !== sync.origin || phase !== 'joining')
|
|
6038
6840
|
return;
|
|
6039
6841
|
if (msg.t === 'state')
|
|
6040
|
-
sync.hydrate(msg.
|
|
6842
|
+
sync.hydrate(msg.state);
|
|
6041
6843
|
goLive();
|
|
6042
6844
|
return;
|
|
6043
6845
|
}
|
|
@@ -6411,5 +7213,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
6411
7213
|
* Generated bundle index. Do not edit.
|
|
6412
7214
|
*/
|
|
6413
7215
|
|
|
6414
|
-
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 };
|
|
7216
|
+
export { CONCURRENCY_INSTRUMENTATION, MmActivity, MmTransition, MmViewTransitionName, OP_PROTO_VERSION, PAUSABLE_OPTIONS, PERSISTED_STORE_OPTIONS, POS_SEGMENT, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, applyOps, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, compareHlc, compareSiblings, compareTotal, createAttributedPending, createConvergingApply, createForwardingScope, createHlcClock, createStoreContext, createTransaction, createTransitionScope, debounce, debounced, defaultFold, deferredValue, derived, diffOps, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, insertElement, invertBatch, isConflicted, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, keyedArray, latest, lww, map, mapArray, mapObject, mediaQuery, merge3, mergeThree, mousePosition, moveElement, mutable, mutableStore, nestedEffect, networkStatus, opLog, opSync, opaque, orderedEntries, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, perfCustomTracks, persist, persistedStore, pipeable, piped, pointerDrag, policyStrategy, pooled, pooledArray, pooledMap, pooledSet, posBetween, prefersDarkMode, prefersReducedMotion, preserve, projection, provideConcurrencyInstrumentation, provideForwardingTransitionScope, providePausableOptions, providePaused, providePersistedStoreOptions, provideTransitionScope, rebalanceContainer, rebaseOps, reconcile, registerResource, removeElement, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, storeHistory, stored, syncedFork, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, use, validateEnvelope, windowSize, withHistory };
|
|
6415
7217
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|