@mmstack/primitives 22.6.1 → 22.7.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
|
@@ -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.
|
|
@@ -5000,7 +5000,7 @@ const lww = (_ancestor, mine) => mine;
|
|
|
5000
5000
|
const mergeThree = (ancestor, mine, theirs) => merge3(ancestor, mine, theirs);
|
|
5001
5001
|
const preserve = (ancestor, mine, theirs) => ({ [CONFLICT_BRAND]: true, mine, theirs, ancestor });
|
|
5002
5002
|
/**
|
|
5003
|
-
* Identity-aware array merge
|
|
5003
|
+
* Identity-aware array merge: reconciles two concurrent versions of
|
|
5004
5004
|
* an array item-wise by a user-provided identity, instead of last-writer-wins on the whole
|
|
5005
5005
|
* array. Items are matched by key; per-item fields merge via `merge3` against the ancestor
|
|
5006
5006
|
* item; items added on either side survive; an item removed on either side and unedited on
|
|
@@ -5031,9 +5031,7 @@ function keyedArray(identity, opt) {
|
|
|
5031
5031
|
const other = theirsMap.get(key);
|
|
5032
5032
|
const base = ancMap.get(key);
|
|
5033
5033
|
if (theirsMap.has(key)) {
|
|
5034
|
-
out.push(structuralEq(item, other)
|
|
5035
|
-
? item
|
|
5036
|
-
: mergeItem(base, item, other, ctx));
|
|
5034
|
+
out.push(structuralEq(item, other) ? item : mergeItem(base, item, other, ctx));
|
|
5037
5035
|
}
|
|
5038
5036
|
else if (!ancMap.has(key) || !structuralEq(item, base)) {
|
|
5039
5037
|
out.push(item); // added by mine, or edited by mine while theirs removed it → keep
|
|
@@ -5104,7 +5102,7 @@ const compareStamp = (a, b) => {
|
|
|
5104
5102
|
};
|
|
5105
5103
|
const beats = (a, b) => compareStamp(a, b) > 0;
|
|
5106
5104
|
/**
|
|
5107
|
-
* The unsequenced-topology convergence core
|
|
5105
|
+
* The unsequenced-topology convergence core: a per-path last-writer-wins
|
|
5108
5106
|
* register map over the total order (hlc, writer), with subtree dominance. Order-independent:
|
|
5109
5107
|
* any arrival order of the same envelope set yields the same state.
|
|
5110
5108
|
*/
|
|
@@ -5121,9 +5119,6 @@ function createConvergingApply(opt) {
|
|
|
5121
5119
|
return winner;
|
|
5122
5120
|
return { kind: 'set', path, next: resolved, prev: winner.next };
|
|
5123
5121
|
};
|
|
5124
|
-
// a sequential edit carries the value it overwrote; a mismatch means neither saw the other.
|
|
5125
|
-
// Structural, not referential: identity never survives the wire, so a peer that built on
|
|
5126
|
-
// the replicated copy of a value must still count as sequential.
|
|
5127
5122
|
const concurrentWith = (incoming, registered) => {
|
|
5128
5123
|
if (incoming.kind === 'delete' || registered.kind === 'delete')
|
|
5129
5124
|
return false;
|
|
@@ -5133,7 +5128,11 @@ function createConvergingApply(opt) {
|
|
|
5133
5128
|
};
|
|
5134
5129
|
return {
|
|
5135
5130
|
ingest: (env, o) => {
|
|
5136
|
-
const stamp = {
|
|
5131
|
+
const stamp = {
|
|
5132
|
+
hlc: env.hlc,
|
|
5133
|
+
writer: env.writer,
|
|
5134
|
+
origin: env.origin,
|
|
5135
|
+
};
|
|
5137
5136
|
const out = [];
|
|
5138
5137
|
for (const op of env.ops) {
|
|
5139
5138
|
const key = keyOf$1(op.path);
|
|
@@ -5180,7 +5179,12 @@ function createConvergingApply(opt) {
|
|
|
5180
5179
|
replays.push(reg);
|
|
5181
5180
|
}
|
|
5182
5181
|
replays.sort(compareStamp);
|
|
5183
|
-
registers.set(key, {
|
|
5182
|
+
registers.set(key, {
|
|
5183
|
+
hlc: env.hlc,
|
|
5184
|
+
writer: env.writer,
|
|
5185
|
+
origin: env.origin,
|
|
5186
|
+
op: accepted,
|
|
5187
|
+
});
|
|
5184
5188
|
if (!o?.local) {
|
|
5185
5189
|
out.push(accepted);
|
|
5186
5190
|
for (const r of replays)
|
|
@@ -5202,7 +5206,7 @@ function getAtPath(root, path) {
|
|
|
5202
5206
|
return cur;
|
|
5203
5207
|
}
|
|
5204
5208
|
/**
|
|
5205
|
-
* The shared rebase routine
|
|
5209
|
+
* The shared rebase routine: invert pending, apply remote, re-apply
|
|
5206
5210
|
* pending through the merge policies. Pure — branching's `rebase()` and the sequenced relay
|
|
5207
5211
|
* client both call this.
|
|
5208
5212
|
*/
|
|
@@ -5264,31 +5268,76 @@ function opSync(source, opt) {
|
|
|
5264
5268
|
const clock = opt.clock ?? createHlcClock();
|
|
5265
5269
|
const conv = createConvergingApply({ policies: opt.policies });
|
|
5266
5270
|
const subscribers = new Set();
|
|
5271
|
+
// per-origin high-watermark; `versions.get(origin)` IS the local emit counter, so a hydrate/restore
|
|
5272
|
+
// that raises our own watermark also advances the next mint — no separate counter to drift out of
|
|
5273
|
+
// sync and collide with a version acked before a reboot but dropped from a debounced outbox.
|
|
5267
5274
|
const versions = new Map();
|
|
5268
5275
|
const recentLocal = [];
|
|
5269
|
-
|
|
5276
|
+
const resolvedInjector = opt.driver
|
|
5277
|
+
? null
|
|
5278
|
+
: (opt.injector ?? inject(Injector));
|
|
5270
5279
|
const log = opLog(source, opt.driver
|
|
5271
5280
|
? { origin, driver: opt.driver }
|
|
5272
|
-
: { origin, injector:
|
|
5281
|
+
: { origin, injector: resolvedInjector });
|
|
5282
|
+
// Local envelopes stamped + registered but not yet handed to the transport. A `receive` freezes
|
|
5283
|
+
// this peer's pending writes here so it can ingest the remote WITHOUT emitting mid-receive — the
|
|
5284
|
+
// synchronous re-entrant emission that used to scramble the relay's commit order. The outbox
|
|
5285
|
+
// drains on a LATER tick, so emission always lands outside any receive callstack. Writes made
|
|
5286
|
+
// while a drain is still owed queue here too, keeping wire order == version order (else a receiver
|
|
5287
|
+
// would dedup the older, still-frozen envelope).
|
|
5288
|
+
//
|
|
5289
|
+
// Deferral rides an Angular effect, so it arms only on the injector path — the transport
|
|
5290
|
+
// topologies (`tabSync`, `meshSync`) that actually re-enter through a relay. A custom `driver`
|
|
5291
|
+
// (worker mirror, pure sim, multi-reader) owns its own scheduling and has no such re-entrancy, so
|
|
5292
|
+
// it emits synchronously; the freeze-before-observe STAMPING fix below is identical either way.
|
|
5293
|
+
const canDefer = !opt.driver;
|
|
5294
|
+
const outbox = [];
|
|
5295
|
+
let receiving = false;
|
|
5296
|
+
// a signal the drain reaction tracks; bumping it schedules an outbox drain for the next tick
|
|
5297
|
+
const drainTick = signal(0, /* @ts-ignore */
|
|
5298
|
+
...(ngDevMode ? [{ debugName: "drainTick" }] : /* istanbul ignore next */ []));
|
|
5299
|
+
const scheduleDrain = () => drainTick.update((v) => v + 1);
|
|
5300
|
+
const notify = (env) => {
|
|
5301
|
+
for (const cb of [...subscribers])
|
|
5302
|
+
cb(env);
|
|
5303
|
+
};
|
|
5304
|
+
const drainOutbox = () => {
|
|
5305
|
+
for (const env of outbox.splice(0))
|
|
5306
|
+
notify(env);
|
|
5307
|
+
};
|
|
5273
5308
|
const emitLocal = (ops) => {
|
|
5309
|
+
const nextVersion = (versions.get(origin) ?? 0) + 1;
|
|
5274
5310
|
const env = {
|
|
5275
5311
|
proto: OP_PROTO_VERSION,
|
|
5276
5312
|
origin,
|
|
5277
5313
|
writer: opt.writer,
|
|
5278
|
-
version:
|
|
5314
|
+
version: nextVersion,
|
|
5279
5315
|
hlc: clock.next(),
|
|
5280
5316
|
policyVersion: opt.policyVersion ?? 0,
|
|
5281
5317
|
ops,
|
|
5282
5318
|
};
|
|
5283
|
-
versions.set(origin,
|
|
5319
|
+
versions.set(origin, nextVersion);
|
|
5284
5320
|
conv.ingest(env, { local: true });
|
|
5285
5321
|
recentLocal.push(env);
|
|
5286
5322
|
if (recentLocal.length > RECENT_LOCAL_CAP)
|
|
5287
5323
|
recentLocal.shift();
|
|
5288
|
-
|
|
5289
|
-
|
|
5324
|
+
// mid-receive, or a drain still owed → queue (frozen stamp verbatim) instead of emitting now
|
|
5325
|
+
if (canDefer && (receiving || outbox.length)) {
|
|
5326
|
+
outbox.push(env);
|
|
5327
|
+
scheduleDrain();
|
|
5328
|
+
return;
|
|
5329
|
+
}
|
|
5330
|
+
notify(env);
|
|
5290
5331
|
};
|
|
5291
5332
|
const unsub = log.subscribe((batch) => emitLocal(batch.ops));
|
|
5333
|
+
// fires on the tick after `scheduleDrain`: emits frozen local envelopes outside any receive frame
|
|
5334
|
+
const drainRun = () => {
|
|
5335
|
+
drainTick();
|
|
5336
|
+
untracked(drainOutbox);
|
|
5337
|
+
};
|
|
5338
|
+
const drainRef = canDefer
|
|
5339
|
+
? effect(drainRun, { injector: resolvedInjector })
|
|
5340
|
+
: null;
|
|
5292
5341
|
return {
|
|
5293
5342
|
origin,
|
|
5294
5343
|
subscribe: (cb) => {
|
|
@@ -5304,7 +5353,6 @@ function opSync(source, opt) {
|
|
|
5304
5353
|
}
|
|
5305
5354
|
return;
|
|
5306
5355
|
}
|
|
5307
|
-
clock.observe(env.hlc);
|
|
5308
5356
|
const known = versions.get(env.origin);
|
|
5309
5357
|
if (known !== undefined && env.version <= known)
|
|
5310
5358
|
return; // duplicate/covered — idempotent
|
|
@@ -5312,12 +5360,27 @@ function opSync(source, opt) {
|
|
|
5312
5360
|
opt.onGap?.(env.origin, known + 1, env.version);
|
|
5313
5361
|
}
|
|
5314
5362
|
versions.set(env.origin, env.version);
|
|
5363
|
+
receiving = true;
|
|
5364
|
+
try {
|
|
5365
|
+
// Freeze pending local FIRST — stamped by a clock that has NOT yet observed this remote, so
|
|
5366
|
+
// the local write keeps its causally-independent (original) stamp rather than being lifted
|
|
5367
|
+
// above the remote and always winning. Emission is deferred to a tick via the outbox; this
|
|
5368
|
+
// only registers + stamps.
|
|
5369
|
+
log.flush();
|
|
5370
|
+
clock.observe(env.hlc);
|
|
5371
|
+
const ops = conv.ingest(env);
|
|
5372
|
+
// apply the converged result — a local write that LOST its path rolls back visibly here
|
|
5373
|
+
if (ops.length)
|
|
5374
|
+
log.apply(ops);
|
|
5375
|
+
}
|
|
5376
|
+
finally {
|
|
5377
|
+
receiving = false;
|
|
5378
|
+
}
|
|
5379
|
+
},
|
|
5380
|
+
flush: () => {
|
|
5381
|
+
drainOutbox();
|
|
5315
5382
|
log.flush();
|
|
5316
|
-
const ops = conv.ingest(env);
|
|
5317
|
-
if (ops.length)
|
|
5318
|
-
log.apply(ops);
|
|
5319
5383
|
},
|
|
5320
|
-
flush: () => log.flush(),
|
|
5321
5384
|
watermark: () => Object.fromEntries(versions),
|
|
5322
5385
|
snapshot: () => {
|
|
5323
5386
|
log.flush();
|
|
@@ -5342,9 +5405,27 @@ function opSync(source, opt) {
|
|
|
5342
5405
|
for (const e of pending)
|
|
5343
5406
|
conv.ingest(e, { local: true });
|
|
5344
5407
|
},
|
|
5408
|
+
restore: (envs, highWater) => {
|
|
5409
|
+
let maxV = versions.get(origin) ?? 0;
|
|
5410
|
+
for (const env of envs) {
|
|
5411
|
+
if (env.origin !== origin)
|
|
5412
|
+
continue; // only this origin's own durable outbox
|
|
5413
|
+
clock.observe(env.hlc); // keep the clock ≥ restored stamps before any future mint
|
|
5414
|
+
log.apply(env.ops); // reflect the offline edit in the store, echo-free
|
|
5415
|
+
conv.ingest(env, { local: true }); // register as a local winner (survives a reconnect merge)
|
|
5416
|
+
recentLocal.push(env);
|
|
5417
|
+
if (recentLocal.length > RECENT_LOCAL_CAP)
|
|
5418
|
+
recentLocal.shift();
|
|
5419
|
+
maxV = Math.max(maxV, env.version);
|
|
5420
|
+
notify(env); // hand to the transport to resend the unacknowledged tail
|
|
5421
|
+
}
|
|
5422
|
+
versions.set(origin, Math.max(maxV, highWater ?? 0));
|
|
5423
|
+
},
|
|
5345
5424
|
destroy: () => {
|
|
5425
|
+
drainOutbox(); // don't silently drop frozen-but-unsent local writes
|
|
5346
5426
|
unsub();
|
|
5347
5427
|
subscribers.clear();
|
|
5428
|
+
drainRef?.destroy();
|
|
5348
5429
|
log.destroy();
|
|
5349
5430
|
},
|
|
5350
5431
|
};
|
|
@@ -5892,7 +5973,7 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
5892
5973
|
return writable;
|
|
5893
5974
|
}
|
|
5894
5975
|
|
|
5895
|
-
/** Op-mode sync for a writable store: hello exchange, then live envelopes
|
|
5976
|
+
/** Op-mode sync for a writable store: hello exchange, then live envelopes. */
|
|
5896
5977
|
function storeTabSync(sig, opt, bus, injector) {
|
|
5897
5978
|
const sync = opSync(sig, {
|
|
5898
5979
|
writer: opt.writer ?? 'local',
|
|
@@ -5974,6 +6055,12 @@ function storeTabSync(sig, opt, bus, injector) {
|
|
|
5974
6055
|
class MessageBus {
|
|
5975
6056
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
5976
6057
|
listeners = new Map();
|
|
6058
|
+
constructor() {
|
|
6059
|
+
inject(DestroyRef).onDestroy(() => {
|
|
6060
|
+
this.channel.close();
|
|
6061
|
+
this.listeners.clear();
|
|
6062
|
+
});
|
|
6063
|
+
}
|
|
5977
6064
|
subscribe(id, listener) {
|
|
5978
6065
|
const wrapped = (ev) => {
|
|
5979
6066
|
try {
|
|
@@ -6004,10 +6091,6 @@ class MessageBus {
|
|
|
6004
6091
|
post: (value) => this.channel.postMessage({ id, value }),
|
|
6005
6092
|
};
|
|
6006
6093
|
}
|
|
6007
|
-
ngOnDestroy() {
|
|
6008
|
-
this.channel.close();
|
|
6009
|
-
this.listeners.clear();
|
|
6010
|
-
}
|
|
6011
6094
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
6012
6095
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
6013
6096
|
}
|
|
@@ -6016,7 +6099,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
|
|
|
6016
6099
|
args: [{
|
|
6017
6100
|
providedIn: 'root',
|
|
6018
6101
|
}]
|
|
6019
|
-
}] });
|
|
6102
|
+
}], ctorParameters: () => [] });
|
|
6020
6103
|
/**
|
|
6021
6104
|
* @deprecated The generated id hashes the call-site stack line, which collides when a shared
|
|
6022
6105
|
* helper calls {@link tabSync} for multiple signals and diverges across minified builds during
|
|
@@ -6081,7 +6164,7 @@ function tabSync(sig, opt) {
|
|
|
6081
6164
|
if (isPlatformServer(injector.get(PLATFORM_ID)))
|
|
6082
6165
|
return sig;
|
|
6083
6166
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
6084
|
-
const bus = injector.get(MessageBus);
|
|
6167
|
+
const bus = optObj?.bus ?? injector.get(MessageBus);
|
|
6085
6168
|
const storeKind = sig[STORE_KIND];
|
|
6086
6169
|
if (storeKind === 'writable') {
|
|
6087
6170
|
storeTabSync(sig, { ...optObj, id }, bus, injector);
|