@mmstack/primitives 20.11.0 → 20.12.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 -0
- package/fesm2022/mmstack-primitives.mjs +116 -34
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +31 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -183,6 +183,8 @@ The fork is a full store (`draft.store.user.name(...)`, `extendStore`, deep read
|
|
|
183
183
|
|
|
184
184
|
> The fork inherits the base's `vivify` / `noUnionLeaves` and its injector-scoped proxy cache automatically, so its write semantics match the base. Pass them explicitly only to override (advanced).
|
|
185
185
|
|
|
186
|
+
When the base is a synced store from `@mmstack/mesh`, a fork is also how an agent proposes a change for review. The agent writes to the fork, `ops()` is the staged change to render for a person, and `commit()` merges the approved change into the synced store. See the `@mmstack/mesh` README for the pattern.
|
|
187
|
+
|
|
186
188
|
### `toWritable`
|
|
187
189
|
|
|
188
190
|
Turn any read-only `Signal<T>` into a `WritableSignal<T>` by providing custom `set` / `update` implementations. Powers `derived` internally; use it directly when you have a `computed` you want to expose as writable.
|
|
@@ -4990,7 +4990,7 @@ const lww = (_ancestor, mine) => mine;
|
|
|
4990
4990
|
const mergeThree = (ancestor, mine, theirs) => merge3(ancestor, mine, theirs);
|
|
4991
4991
|
const preserve = (ancestor, mine, theirs) => ({ [CONFLICT_BRAND]: true, mine, theirs, ancestor });
|
|
4992
4992
|
/**
|
|
4993
|
-
* Identity-aware array merge
|
|
4993
|
+
* Identity-aware array merge: reconciles two concurrent versions of
|
|
4994
4994
|
* an array item-wise by a user-provided identity, instead of last-writer-wins on the whole
|
|
4995
4995
|
* array. Items are matched by key; per-item fields merge via `merge3` against the ancestor
|
|
4996
4996
|
* item; items added on either side survive; an item removed on either side and unedited on
|
|
@@ -5021,9 +5021,7 @@ function keyedArray(identity, opt) {
|
|
|
5021
5021
|
const other = theirsMap.get(key);
|
|
5022
5022
|
const base = ancMap.get(key);
|
|
5023
5023
|
if (theirsMap.has(key)) {
|
|
5024
|
-
out.push(structuralEq(item, other)
|
|
5025
|
-
? item
|
|
5026
|
-
: mergeItem(base, item, other, ctx));
|
|
5024
|
+
out.push(structuralEq(item, other) ? item : mergeItem(base, item, other, ctx));
|
|
5027
5025
|
}
|
|
5028
5026
|
else if (!ancMap.has(key) || !structuralEq(item, base)) {
|
|
5029
5027
|
out.push(item); // added by mine, or edited by mine while theirs removed it → keep
|
|
@@ -5094,7 +5092,7 @@ const compareStamp = (a, b) => {
|
|
|
5094
5092
|
};
|
|
5095
5093
|
const beats = (a, b) => compareStamp(a, b) > 0;
|
|
5096
5094
|
/**
|
|
5097
|
-
* The unsequenced-topology convergence core
|
|
5095
|
+
* The unsequenced-topology convergence core: a per-path last-writer-wins
|
|
5098
5096
|
* register map over the total order (hlc, writer), with subtree dominance. Order-independent:
|
|
5099
5097
|
* any arrival order of the same envelope set yields the same state.
|
|
5100
5098
|
*/
|
|
@@ -5111,9 +5109,6 @@ function createConvergingApply(opt) {
|
|
|
5111
5109
|
return winner;
|
|
5112
5110
|
return { kind: 'set', path, next: resolved, prev: winner.next };
|
|
5113
5111
|
};
|
|
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
5112
|
const concurrentWith = (incoming, registered) => {
|
|
5118
5113
|
if (incoming.kind === 'delete' || registered.kind === 'delete')
|
|
5119
5114
|
return false;
|
|
@@ -5123,7 +5118,11 @@ function createConvergingApply(opt) {
|
|
|
5123
5118
|
};
|
|
5124
5119
|
return {
|
|
5125
5120
|
ingest: (env, o) => {
|
|
5126
|
-
const stamp = {
|
|
5121
|
+
const stamp = {
|
|
5122
|
+
hlc: env.hlc,
|
|
5123
|
+
writer: env.writer,
|
|
5124
|
+
origin: env.origin,
|
|
5125
|
+
};
|
|
5127
5126
|
const out = [];
|
|
5128
5127
|
for (const op of env.ops) {
|
|
5129
5128
|
const key = keyOf$1(op.path);
|
|
@@ -5170,7 +5169,12 @@ function createConvergingApply(opt) {
|
|
|
5170
5169
|
replays.push(reg);
|
|
5171
5170
|
}
|
|
5172
5171
|
replays.sort(compareStamp);
|
|
5173
|
-
registers.set(key, {
|
|
5172
|
+
registers.set(key, {
|
|
5173
|
+
hlc: env.hlc,
|
|
5174
|
+
writer: env.writer,
|
|
5175
|
+
origin: env.origin,
|
|
5176
|
+
op: accepted,
|
|
5177
|
+
});
|
|
5174
5178
|
if (!o?.local) {
|
|
5175
5179
|
out.push(accepted);
|
|
5176
5180
|
for (const r of replays)
|
|
@@ -5192,7 +5196,7 @@ function getAtPath(root, path) {
|
|
|
5192
5196
|
return cur;
|
|
5193
5197
|
}
|
|
5194
5198
|
/**
|
|
5195
|
-
* The shared rebase routine
|
|
5199
|
+
* The shared rebase routine: invert pending, apply remote, re-apply
|
|
5196
5200
|
* pending through the merge policies. Pure — branching's `rebase()` and the sequenced relay
|
|
5197
5201
|
* client both call this.
|
|
5198
5202
|
*/
|
|
@@ -5254,31 +5258,75 @@ function opSync(source, opt) {
|
|
|
5254
5258
|
const clock = opt.clock ?? createHlcClock();
|
|
5255
5259
|
const conv = createConvergingApply({ policies: opt.policies });
|
|
5256
5260
|
const subscribers = new Set();
|
|
5261
|
+
// per-origin high-watermark; `versions.get(origin)` IS the local emit counter, so a hydrate/restore
|
|
5262
|
+
// that raises our own watermark also advances the next mint — no separate counter to drift out of
|
|
5263
|
+
// sync and collide with a version acked before a reboot but dropped from a debounced outbox.
|
|
5257
5264
|
const versions = new Map();
|
|
5258
5265
|
const recentLocal = [];
|
|
5259
|
-
|
|
5266
|
+
const resolvedInjector = opt.driver
|
|
5267
|
+
? null
|
|
5268
|
+
: (opt.injector ?? inject(Injector));
|
|
5260
5269
|
const log = opLog(source, opt.driver
|
|
5261
5270
|
? { origin, driver: opt.driver }
|
|
5262
|
-
: { origin, injector:
|
|
5271
|
+
: { origin, injector: resolvedInjector });
|
|
5272
|
+
// Local envelopes stamped + registered but not yet handed to the transport. A `receive` freezes
|
|
5273
|
+
// this peer's pending writes here so it can ingest the remote WITHOUT emitting mid-receive — the
|
|
5274
|
+
// synchronous re-entrant emission that used to scramble the relay's commit order. The outbox
|
|
5275
|
+
// drains on a LATER tick, so emission always lands outside any receive callstack. Writes made
|
|
5276
|
+
// while a drain is still owed queue here too, keeping wire order == version order (else a receiver
|
|
5277
|
+
// would dedup the older, still-frozen envelope).
|
|
5278
|
+
//
|
|
5279
|
+
// Deferral rides an Angular effect, so it arms only on the injector path — the transport
|
|
5280
|
+
// topologies (`tabSync`, `meshSync`) that actually re-enter through a relay. A custom `driver`
|
|
5281
|
+
// (worker mirror, pure sim, multi-reader) owns its own scheduling and has no such re-entrancy, so
|
|
5282
|
+
// it emits synchronously; the freeze-before-observe STAMPING fix below is identical either way.
|
|
5283
|
+
const canDefer = !opt.driver;
|
|
5284
|
+
const outbox = [];
|
|
5285
|
+
let receiving = false;
|
|
5286
|
+
// a signal the drain reaction tracks; bumping it schedules an outbox drain for the next tick
|
|
5287
|
+
const drainTick = signal(0, ...(ngDevMode ? [{ debugName: "drainTick" }] : []));
|
|
5288
|
+
const scheduleDrain = () => drainTick.update((v) => v + 1);
|
|
5289
|
+
const notify = (env) => {
|
|
5290
|
+
for (const cb of [...subscribers])
|
|
5291
|
+
cb(env);
|
|
5292
|
+
};
|
|
5293
|
+
const drainOutbox = () => {
|
|
5294
|
+
for (const env of outbox.splice(0))
|
|
5295
|
+
notify(env);
|
|
5296
|
+
};
|
|
5263
5297
|
const emitLocal = (ops) => {
|
|
5298
|
+
const nextVersion = (versions.get(origin) ?? 0) + 1;
|
|
5264
5299
|
const env = {
|
|
5265
5300
|
proto: OP_PROTO_VERSION,
|
|
5266
5301
|
origin,
|
|
5267
5302
|
writer: opt.writer,
|
|
5268
|
-
version:
|
|
5303
|
+
version: nextVersion,
|
|
5269
5304
|
hlc: clock.next(),
|
|
5270
5305
|
policyVersion: opt.policyVersion ?? 0,
|
|
5271
5306
|
ops,
|
|
5272
5307
|
};
|
|
5273
|
-
versions.set(origin,
|
|
5308
|
+
versions.set(origin, nextVersion);
|
|
5274
5309
|
conv.ingest(env, { local: true });
|
|
5275
5310
|
recentLocal.push(env);
|
|
5276
5311
|
if (recentLocal.length > RECENT_LOCAL_CAP)
|
|
5277
5312
|
recentLocal.shift();
|
|
5278
|
-
|
|
5279
|
-
|
|
5313
|
+
// mid-receive, or a drain still owed → queue (frozen stamp verbatim) instead of emitting now
|
|
5314
|
+
if (canDefer && (receiving || outbox.length)) {
|
|
5315
|
+
outbox.push(env);
|
|
5316
|
+
scheduleDrain();
|
|
5317
|
+
return;
|
|
5318
|
+
}
|
|
5319
|
+
notify(env);
|
|
5280
5320
|
};
|
|
5281
5321
|
const unsub = log.subscribe((batch) => emitLocal(batch.ops));
|
|
5322
|
+
// fires on the tick after `scheduleDrain`: emits frozen local envelopes outside any receive frame
|
|
5323
|
+
const drainRun = () => {
|
|
5324
|
+
drainTick();
|
|
5325
|
+
untracked(drainOutbox);
|
|
5326
|
+
};
|
|
5327
|
+
const drainRef = canDefer
|
|
5328
|
+
? effect(drainRun, { injector: resolvedInjector })
|
|
5329
|
+
: null;
|
|
5282
5330
|
return {
|
|
5283
5331
|
origin,
|
|
5284
5332
|
subscribe: (cb) => {
|
|
@@ -5294,7 +5342,6 @@ function opSync(source, opt) {
|
|
|
5294
5342
|
}
|
|
5295
5343
|
return;
|
|
5296
5344
|
}
|
|
5297
|
-
clock.observe(env.hlc);
|
|
5298
5345
|
const known = versions.get(env.origin);
|
|
5299
5346
|
if (known !== undefined && env.version <= known)
|
|
5300
5347
|
return; // duplicate/covered — idempotent
|
|
@@ -5302,12 +5349,27 @@ function opSync(source, opt) {
|
|
|
5302
5349
|
opt.onGap?.(env.origin, known + 1, env.version);
|
|
5303
5350
|
}
|
|
5304
5351
|
versions.set(env.origin, env.version);
|
|
5352
|
+
receiving = true;
|
|
5353
|
+
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
|
+
log.flush();
|
|
5359
|
+
clock.observe(env.hlc);
|
|
5360
|
+
const ops = conv.ingest(env);
|
|
5361
|
+
// apply the converged result — a local write that LOST its path rolls back visibly here
|
|
5362
|
+
if (ops.length)
|
|
5363
|
+
log.apply(ops);
|
|
5364
|
+
}
|
|
5365
|
+
finally {
|
|
5366
|
+
receiving = false;
|
|
5367
|
+
}
|
|
5368
|
+
},
|
|
5369
|
+
flush: () => {
|
|
5370
|
+
drainOutbox();
|
|
5305
5371
|
log.flush();
|
|
5306
|
-
const ops = conv.ingest(env);
|
|
5307
|
-
if (ops.length)
|
|
5308
|
-
log.apply(ops);
|
|
5309
5372
|
},
|
|
5310
|
-
flush: () => log.flush(),
|
|
5311
5373
|
watermark: () => Object.fromEntries(versions),
|
|
5312
5374
|
snapshot: () => {
|
|
5313
5375
|
log.flush();
|
|
@@ -5332,9 +5394,27 @@ function opSync(source, opt) {
|
|
|
5332
5394
|
for (const e of pending)
|
|
5333
5395
|
conv.ingest(e, { local: true });
|
|
5334
5396
|
},
|
|
5397
|
+
restore: (envs, highWater) => {
|
|
5398
|
+
let maxV = versions.get(origin) ?? 0;
|
|
5399
|
+
for (const env of envs) {
|
|
5400
|
+
if (env.origin !== origin)
|
|
5401
|
+
continue; // only this origin's own durable outbox
|
|
5402
|
+
clock.observe(env.hlc); // keep the clock ≥ restored stamps before any future mint
|
|
5403
|
+
log.apply(env.ops); // reflect the offline edit in the store, echo-free
|
|
5404
|
+
conv.ingest(env, { local: true }); // register as a local winner (survives a reconnect merge)
|
|
5405
|
+
recentLocal.push(env);
|
|
5406
|
+
if (recentLocal.length > RECENT_LOCAL_CAP)
|
|
5407
|
+
recentLocal.shift();
|
|
5408
|
+
maxV = Math.max(maxV, env.version);
|
|
5409
|
+
notify(env); // hand to the transport to resend the unacknowledged tail
|
|
5410
|
+
}
|
|
5411
|
+
versions.set(origin, Math.max(maxV, highWater ?? 0));
|
|
5412
|
+
},
|
|
5335
5413
|
destroy: () => {
|
|
5414
|
+
drainOutbox(); // don't silently drop frozen-but-unsent local writes
|
|
5336
5415
|
unsub();
|
|
5337
5416
|
subscribers.clear();
|
|
5417
|
+
drainRef?.destroy();
|
|
5338
5418
|
log.destroy();
|
|
5339
5419
|
},
|
|
5340
5420
|
};
|
|
@@ -5898,7 +5978,7 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
5898
5978
|
return writable;
|
|
5899
5979
|
}
|
|
5900
5980
|
|
|
5901
|
-
/** Op-mode sync for a writable store: hello exchange, then live envelopes
|
|
5981
|
+
/** Op-mode sync for a writable store: hello exchange, then live envelopes. */
|
|
5902
5982
|
function storeTabSync(sig, opt, bus, injector) {
|
|
5903
5983
|
const sync = opSync(sig, {
|
|
5904
5984
|
writer: opt.writer ?? 'local',
|
|
@@ -5980,6 +6060,12 @@ function storeTabSync(sig, opt, bus, injector) {
|
|
|
5980
6060
|
class MessageBus {
|
|
5981
6061
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
5982
6062
|
listeners = new Map();
|
|
6063
|
+
constructor() {
|
|
6064
|
+
inject(DestroyRef).onDestroy(() => {
|
|
6065
|
+
this.channel.close();
|
|
6066
|
+
this.listeners.clear();
|
|
6067
|
+
});
|
|
6068
|
+
}
|
|
5983
6069
|
subscribe(id, listener) {
|
|
5984
6070
|
const wrapped = (ev) => {
|
|
5985
6071
|
try {
|
|
@@ -6010,10 +6096,6 @@ class MessageBus {
|
|
|
6010
6096
|
post: (value) => this.channel.postMessage({ id, value }),
|
|
6011
6097
|
};
|
|
6012
6098
|
}
|
|
6013
|
-
ngOnDestroy() {
|
|
6014
|
-
this.channel.close();
|
|
6015
|
-
this.listeners.clear();
|
|
6016
|
-
}
|
|
6017
6099
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
6018
6100
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
6019
6101
|
}
|
|
@@ -6022,7 +6104,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
|
|
|
6022
6104
|
args: [{
|
|
6023
6105
|
providedIn: 'root',
|
|
6024
6106
|
}]
|
|
6025
|
-
}] });
|
|
6107
|
+
}], ctorParameters: () => [] });
|
|
6026
6108
|
/**
|
|
6027
6109
|
* @deprecated The generated id hashes the call-site stack line, which collides when a shared
|
|
6028
6110
|
* helper calls {@link tabSync} for multiple signals and diverges across minified builds during
|
|
@@ -6087,7 +6169,7 @@ function tabSync(sig, opt) {
|
|
|
6087
6169
|
if (isPlatformServer(injector.get(PLATFORM_ID)))
|
|
6088
6170
|
return sig;
|
|
6089
6171
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
6090
|
-
const bus = injector.get(MessageBus);
|
|
6172
|
+
const bus = optObj?.bus ?? injector.get(MessageBus);
|
|
6091
6173
|
const storeKind = sig[STORE_KIND];
|
|
6092
6174
|
if (storeKind === 'writable') {
|
|
6093
6175
|
storeTabSync(sig, { ...optObj, id }, bus, injector);
|
|
@@ -6104,6 +6186,7 @@ function tabSync(sig, opt) {
|
|
|
6104
6186
|
}
|
|
6105
6187
|
const NONE = Symbol();
|
|
6106
6188
|
let received = NONE;
|
|
6189
|
+
let last = untracked(sig);
|
|
6107
6190
|
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
6108
6191
|
const before = untracked(sig);
|
|
6109
6192
|
received = next;
|
|
@@ -6111,13 +6194,12 @@ function tabSync(sig, opt) {
|
|
|
6111
6194
|
if (untracked(sig) === before)
|
|
6112
6195
|
received = NONE;
|
|
6113
6196
|
});
|
|
6114
|
-
let firstDone = false;
|
|
6115
6197
|
const effectRef = effect(() => {
|
|
6116
6198
|
const val = sig();
|
|
6117
|
-
if (
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6199
|
+
if (val === last)
|
|
6200
|
+
return; // unchanged since last seen → nothing to post
|
|
6201
|
+
last = val;
|
|
6202
|
+
// came from bus → don't echo
|
|
6121
6203
|
if (val === received) {
|
|
6122
6204
|
received = NONE;
|
|
6123
6205
|
return;
|