@mmstack/primitives 21.6.0 → 21.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.
|
|
@@ -3597,7 +3597,7 @@ function createPointerDrag(opt) {
|
|
|
3597
3597
|
return;
|
|
3598
3598
|
const matched = handleSelector
|
|
3599
3599
|
? e.target?.closest?.(handleSelector)
|
|
3600
|
-
: el;
|
|
3600
|
+
: (e.target ?? el); // no selector: the pressed element itself
|
|
3601
3601
|
if (!matched)
|
|
3602
3602
|
return; // handleSelector set but pointerdown landed outside a handle
|
|
3603
3603
|
if (stopPropagation)
|
|
@@ -4960,7 +4960,7 @@ const lww = (_ancestor, mine) => mine;
|
|
|
4960
4960
|
const mergeThree = (ancestor, mine, theirs) => merge3(ancestor, mine, theirs);
|
|
4961
4961
|
const preserve = (ancestor, mine, theirs) => ({ [CONFLICT_BRAND]: true, mine, theirs, ancestor });
|
|
4962
4962
|
/**
|
|
4963
|
-
* Identity-aware array merge
|
|
4963
|
+
* Identity-aware array merge: reconciles two concurrent versions of
|
|
4964
4964
|
* an array item-wise by a user-provided identity, instead of last-writer-wins on the whole
|
|
4965
4965
|
* array. Items are matched by key; per-item fields merge via `merge3` against the ancestor
|
|
4966
4966
|
* item; items added on either side survive; an item removed on either side and unedited on
|
|
@@ -4991,9 +4991,7 @@ function keyedArray(identity, opt) {
|
|
|
4991
4991
|
const other = theirsMap.get(key);
|
|
4992
4992
|
const base = ancMap.get(key);
|
|
4993
4993
|
if (theirsMap.has(key)) {
|
|
4994
|
-
out.push(structuralEq(item, other)
|
|
4995
|
-
? item
|
|
4996
|
-
: mergeItem(base, item, other, ctx));
|
|
4994
|
+
out.push(structuralEq(item, other) ? item : mergeItem(base, item, other, ctx));
|
|
4997
4995
|
}
|
|
4998
4996
|
else if (!ancMap.has(key) || !structuralEq(item, base)) {
|
|
4999
4997
|
out.push(item); // added by mine, or edited by mine while theirs removed it → keep
|
|
@@ -5064,7 +5062,7 @@ const compareStamp = (a, b) => {
|
|
|
5064
5062
|
};
|
|
5065
5063
|
const beats = (a, b) => compareStamp(a, b) > 0;
|
|
5066
5064
|
/**
|
|
5067
|
-
* The unsequenced-topology convergence core
|
|
5065
|
+
* The unsequenced-topology convergence core: a per-path last-writer-wins
|
|
5068
5066
|
* register map over the total order (hlc, writer), with subtree dominance. Order-independent:
|
|
5069
5067
|
* any arrival order of the same envelope set yields the same state.
|
|
5070
5068
|
*/
|
|
@@ -5081,9 +5079,6 @@ function createConvergingApply(opt) {
|
|
|
5081
5079
|
return winner;
|
|
5082
5080
|
return { kind: 'set', path, next: resolved, prev: winner.next };
|
|
5083
5081
|
};
|
|
5084
|
-
// a sequential edit carries the value it overwrote; a mismatch means neither saw the other.
|
|
5085
|
-
// Structural, not referential: identity never survives the wire, so a peer that built on
|
|
5086
|
-
// the replicated copy of a value must still count as sequential.
|
|
5087
5082
|
const concurrentWith = (incoming, registered) => {
|
|
5088
5083
|
if (incoming.kind === 'delete' || registered.kind === 'delete')
|
|
5089
5084
|
return false;
|
|
@@ -5093,7 +5088,11 @@ function createConvergingApply(opt) {
|
|
|
5093
5088
|
};
|
|
5094
5089
|
return {
|
|
5095
5090
|
ingest: (env, o) => {
|
|
5096
|
-
const stamp = {
|
|
5091
|
+
const stamp = {
|
|
5092
|
+
hlc: env.hlc,
|
|
5093
|
+
writer: env.writer,
|
|
5094
|
+
origin: env.origin,
|
|
5095
|
+
};
|
|
5097
5096
|
const out = [];
|
|
5098
5097
|
for (const op of env.ops) {
|
|
5099
5098
|
const key = keyOf$1(op.path);
|
|
@@ -5140,7 +5139,12 @@ function createConvergingApply(opt) {
|
|
|
5140
5139
|
replays.push(reg);
|
|
5141
5140
|
}
|
|
5142
5141
|
replays.sort(compareStamp);
|
|
5143
|
-
registers.set(key, {
|
|
5142
|
+
registers.set(key, {
|
|
5143
|
+
hlc: env.hlc,
|
|
5144
|
+
writer: env.writer,
|
|
5145
|
+
origin: env.origin,
|
|
5146
|
+
op: accepted,
|
|
5147
|
+
});
|
|
5144
5148
|
if (!o?.local) {
|
|
5145
5149
|
out.push(accepted);
|
|
5146
5150
|
for (const r of replays)
|
|
@@ -5162,7 +5166,7 @@ function getAtPath(root, path) {
|
|
|
5162
5166
|
return cur;
|
|
5163
5167
|
}
|
|
5164
5168
|
/**
|
|
5165
|
-
* The shared rebase routine
|
|
5169
|
+
* The shared rebase routine: invert pending, apply remote, re-apply
|
|
5166
5170
|
* pending through the merge policies. Pure — branching's `rebase()` and the sequenced relay
|
|
5167
5171
|
* client both call this.
|
|
5168
5172
|
*/
|
|
@@ -5224,31 +5228,75 @@ function opSync(source, opt) {
|
|
|
5224
5228
|
const clock = opt.clock ?? createHlcClock();
|
|
5225
5229
|
const conv = createConvergingApply({ policies: opt.policies });
|
|
5226
5230
|
const subscribers = new Set();
|
|
5231
|
+
// per-origin high-watermark; `versions.get(origin)` IS the local emit counter, so a hydrate/restore
|
|
5232
|
+
// that raises our own watermark also advances the next mint — no separate counter to drift out of
|
|
5233
|
+
// sync and collide with a version acked before a reboot but dropped from a debounced outbox.
|
|
5227
5234
|
const versions = new Map();
|
|
5228
5235
|
const recentLocal = [];
|
|
5229
|
-
|
|
5236
|
+
const resolvedInjector = opt.driver
|
|
5237
|
+
? null
|
|
5238
|
+
: (opt.injector ?? inject(Injector));
|
|
5230
5239
|
const log = opLog(source, opt.driver
|
|
5231
5240
|
? { origin, driver: opt.driver }
|
|
5232
|
-
: { origin, injector:
|
|
5241
|
+
: { origin, injector: resolvedInjector });
|
|
5242
|
+
// Local envelopes stamped + registered but not yet handed to the transport. A `receive` freezes
|
|
5243
|
+
// this peer's pending writes here so it can ingest the remote WITHOUT emitting mid-receive — the
|
|
5244
|
+
// synchronous re-entrant emission that used to scramble the relay's commit order. The outbox
|
|
5245
|
+
// drains on a LATER tick, so emission always lands outside any receive callstack. Writes made
|
|
5246
|
+
// while a drain is still owed queue here too, keeping wire order == version order (else a receiver
|
|
5247
|
+
// would dedup the older, still-frozen envelope).
|
|
5248
|
+
//
|
|
5249
|
+
// Deferral rides an Angular effect, so it arms only on the injector path — the transport
|
|
5250
|
+
// topologies (`tabSync`, `meshSync`) that actually re-enter through a relay. A custom `driver`
|
|
5251
|
+
// (worker mirror, pure sim, multi-reader) owns its own scheduling and has no such re-entrancy, so
|
|
5252
|
+
// it emits synchronously; the freeze-before-observe STAMPING fix below is identical either way.
|
|
5253
|
+
const canDefer = !opt.driver;
|
|
5254
|
+
const outbox = [];
|
|
5255
|
+
let receiving = false;
|
|
5256
|
+
// a signal the drain reaction tracks; bumping it schedules an outbox drain for the next tick
|
|
5257
|
+
const drainTick = signal(0, ...(ngDevMode ? [{ debugName: "drainTick" }] : /* istanbul ignore next */ []));
|
|
5258
|
+
const scheduleDrain = () => drainTick.update((v) => v + 1);
|
|
5259
|
+
const notify = (env) => {
|
|
5260
|
+
for (const cb of [...subscribers])
|
|
5261
|
+
cb(env);
|
|
5262
|
+
};
|
|
5263
|
+
const drainOutbox = () => {
|
|
5264
|
+
for (const env of outbox.splice(0))
|
|
5265
|
+
notify(env);
|
|
5266
|
+
};
|
|
5233
5267
|
const emitLocal = (ops) => {
|
|
5268
|
+
const nextVersion = (versions.get(origin) ?? 0) + 1;
|
|
5234
5269
|
const env = {
|
|
5235
5270
|
proto: OP_PROTO_VERSION,
|
|
5236
5271
|
origin,
|
|
5237
5272
|
writer: opt.writer,
|
|
5238
|
-
version:
|
|
5273
|
+
version: nextVersion,
|
|
5239
5274
|
hlc: clock.next(),
|
|
5240
5275
|
policyVersion: opt.policyVersion ?? 0,
|
|
5241
5276
|
ops,
|
|
5242
5277
|
};
|
|
5243
|
-
versions.set(origin,
|
|
5278
|
+
versions.set(origin, nextVersion);
|
|
5244
5279
|
conv.ingest(env, { local: true });
|
|
5245
5280
|
recentLocal.push(env);
|
|
5246
5281
|
if (recentLocal.length > RECENT_LOCAL_CAP)
|
|
5247
5282
|
recentLocal.shift();
|
|
5248
|
-
|
|
5249
|
-
|
|
5283
|
+
// mid-receive, or a drain still owed → queue (frozen stamp verbatim) instead of emitting now
|
|
5284
|
+
if (canDefer && (receiving || outbox.length)) {
|
|
5285
|
+
outbox.push(env);
|
|
5286
|
+
scheduleDrain();
|
|
5287
|
+
return;
|
|
5288
|
+
}
|
|
5289
|
+
notify(env);
|
|
5250
5290
|
};
|
|
5251
5291
|
const unsub = log.subscribe((batch) => emitLocal(batch.ops));
|
|
5292
|
+
// fires on the tick after `scheduleDrain`: emits frozen local envelopes outside any receive frame
|
|
5293
|
+
const drainRun = () => {
|
|
5294
|
+
drainTick();
|
|
5295
|
+
untracked(drainOutbox);
|
|
5296
|
+
};
|
|
5297
|
+
const drainRef = canDefer
|
|
5298
|
+
? effect(drainRun, { injector: resolvedInjector })
|
|
5299
|
+
: null;
|
|
5252
5300
|
return {
|
|
5253
5301
|
origin,
|
|
5254
5302
|
subscribe: (cb) => {
|
|
@@ -5264,7 +5312,6 @@ function opSync(source, opt) {
|
|
|
5264
5312
|
}
|
|
5265
5313
|
return;
|
|
5266
5314
|
}
|
|
5267
|
-
clock.observe(env.hlc);
|
|
5268
5315
|
const known = versions.get(env.origin);
|
|
5269
5316
|
if (known !== undefined && env.version <= known)
|
|
5270
5317
|
return; // duplicate/covered — idempotent
|
|
@@ -5272,12 +5319,27 @@ function opSync(source, opt) {
|
|
|
5272
5319
|
opt.onGap?.(env.origin, known + 1, env.version);
|
|
5273
5320
|
}
|
|
5274
5321
|
versions.set(env.origin, env.version);
|
|
5322
|
+
receiving = true;
|
|
5323
|
+
try {
|
|
5324
|
+
// Freeze pending local FIRST — stamped by a clock that has NOT yet observed this remote, so
|
|
5325
|
+
// the local write keeps its causally-independent (original) stamp rather than being lifted
|
|
5326
|
+
// above the remote and always winning. Emission is deferred to a tick via the outbox; this
|
|
5327
|
+
// only registers + stamps.
|
|
5328
|
+
log.flush();
|
|
5329
|
+
clock.observe(env.hlc);
|
|
5330
|
+
const ops = conv.ingest(env);
|
|
5331
|
+
// apply the converged result — a local write that LOST its path rolls back visibly here
|
|
5332
|
+
if (ops.length)
|
|
5333
|
+
log.apply(ops);
|
|
5334
|
+
}
|
|
5335
|
+
finally {
|
|
5336
|
+
receiving = false;
|
|
5337
|
+
}
|
|
5338
|
+
},
|
|
5339
|
+
flush: () => {
|
|
5340
|
+
drainOutbox();
|
|
5275
5341
|
log.flush();
|
|
5276
|
-
const ops = conv.ingest(env);
|
|
5277
|
-
if (ops.length)
|
|
5278
|
-
log.apply(ops);
|
|
5279
5342
|
},
|
|
5280
|
-
flush: () => log.flush(),
|
|
5281
5343
|
watermark: () => Object.fromEntries(versions),
|
|
5282
5344
|
snapshot: () => {
|
|
5283
5345
|
log.flush();
|
|
@@ -5302,9 +5364,27 @@ function opSync(source, opt) {
|
|
|
5302
5364
|
for (const e of pending)
|
|
5303
5365
|
conv.ingest(e, { local: true });
|
|
5304
5366
|
},
|
|
5367
|
+
restore: (envs, highWater) => {
|
|
5368
|
+
let maxV = versions.get(origin) ?? 0;
|
|
5369
|
+
for (const env of envs) {
|
|
5370
|
+
if (env.origin !== origin)
|
|
5371
|
+
continue; // only this origin's own durable outbox
|
|
5372
|
+
clock.observe(env.hlc); // keep the clock ≥ restored stamps before any future mint
|
|
5373
|
+
log.apply(env.ops); // reflect the offline edit in the store, echo-free
|
|
5374
|
+
conv.ingest(env, { local: true }); // register as a local winner (survives a reconnect merge)
|
|
5375
|
+
recentLocal.push(env);
|
|
5376
|
+
if (recentLocal.length > RECENT_LOCAL_CAP)
|
|
5377
|
+
recentLocal.shift();
|
|
5378
|
+
maxV = Math.max(maxV, env.version);
|
|
5379
|
+
notify(env); // hand to the transport to resend the unacknowledged tail
|
|
5380
|
+
}
|
|
5381
|
+
versions.set(origin, Math.max(maxV, highWater ?? 0));
|
|
5382
|
+
},
|
|
5305
5383
|
destroy: () => {
|
|
5384
|
+
drainOutbox(); // don't silently drop frozen-but-unsent local writes
|
|
5306
5385
|
unsub();
|
|
5307
5386
|
subscribers.clear();
|
|
5387
|
+
drainRef?.destroy();
|
|
5308
5388
|
log.destroy();
|
|
5309
5389
|
},
|
|
5310
5390
|
};
|
|
@@ -5850,7 +5930,7 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
|
|
|
5850
5930
|
return writable;
|
|
5851
5931
|
}
|
|
5852
5932
|
|
|
5853
|
-
/** Op-mode sync for a writable store: hello exchange, then live envelopes
|
|
5933
|
+
/** Op-mode sync for a writable store: hello exchange, then live envelopes. */
|
|
5854
5934
|
function storeTabSync(sig, opt, bus, injector) {
|
|
5855
5935
|
const sync = opSync(sig, {
|
|
5856
5936
|
writer: opt.writer ?? 'local',
|
|
@@ -5932,6 +6012,12 @@ function storeTabSync(sig, opt, bus, injector) {
|
|
|
5932
6012
|
class MessageBus {
|
|
5933
6013
|
channel = new BroadcastChannel('mmstack-tab-sync-bus');
|
|
5934
6014
|
listeners = new Map();
|
|
6015
|
+
constructor() {
|
|
6016
|
+
inject(DestroyRef).onDestroy(() => {
|
|
6017
|
+
this.channel.close();
|
|
6018
|
+
this.listeners.clear();
|
|
6019
|
+
});
|
|
6020
|
+
}
|
|
5935
6021
|
subscribe(id, listener) {
|
|
5936
6022
|
const wrapped = (ev) => {
|
|
5937
6023
|
try {
|
|
@@ -5962,10 +6048,6 @@ class MessageBus {
|
|
|
5962
6048
|
post: (value) => this.channel.postMessage({ id, value }),
|
|
5963
6049
|
};
|
|
5964
6050
|
}
|
|
5965
|
-
ngOnDestroy() {
|
|
5966
|
-
this.channel.close();
|
|
5967
|
-
this.listeners.clear();
|
|
5968
|
-
}
|
|
5969
6051
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
5970
6052
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessageBus, providedIn: 'root' });
|
|
5971
6053
|
}
|
|
@@ -5974,7 +6056,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
|
|
|
5974
6056
|
args: [{
|
|
5975
6057
|
providedIn: 'root',
|
|
5976
6058
|
}]
|
|
5977
|
-
}] });
|
|
6059
|
+
}], ctorParameters: () => [] });
|
|
5978
6060
|
/**
|
|
5979
6061
|
* @deprecated The generated id hashes the call-site stack line, which collides when a shared
|
|
5980
6062
|
* helper calls {@link tabSync} for multiple signals and diverges across minified builds during
|
|
@@ -6039,7 +6121,7 @@ function tabSync(sig, opt) {
|
|
|
6039
6121
|
if (isPlatformServer(injector.get(PLATFORM_ID)))
|
|
6040
6122
|
return sig;
|
|
6041
6123
|
const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
|
|
6042
|
-
const bus = injector.get(MessageBus);
|
|
6124
|
+
const bus = optObj?.bus ?? injector.get(MessageBus);
|
|
6043
6125
|
const storeKind = sig[STORE_KIND];
|
|
6044
6126
|
if (storeKind === 'writable') {
|
|
6045
6127
|
storeTabSync(sig, { ...optObj, id }, bus, injector);
|
|
@@ -6056,6 +6138,7 @@ function tabSync(sig, opt) {
|
|
|
6056
6138
|
}
|
|
6057
6139
|
const NONE = Symbol();
|
|
6058
6140
|
let received = NONE;
|
|
6141
|
+
let last = untracked(sig);
|
|
6059
6142
|
const { unsub, post } = bus.subscribe(id, (next) => {
|
|
6060
6143
|
const before = untracked(sig);
|
|
6061
6144
|
received = next;
|
|
@@ -6063,13 +6146,12 @@ function tabSync(sig, opt) {
|
|
|
6063
6146
|
if (untracked(sig) === before)
|
|
6064
6147
|
received = NONE;
|
|
6065
6148
|
});
|
|
6066
|
-
let firstDone = false;
|
|
6067
6149
|
const effectRef = effect(() => {
|
|
6068
6150
|
const val = sig();
|
|
6069
|
-
if (
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6151
|
+
if (val === last)
|
|
6152
|
+
return; // unchanged since last seen → nothing to post
|
|
6153
|
+
last = val;
|
|
6154
|
+
// came from bus → don't echo
|
|
6073
6155
|
if (val === received) {
|
|
6074
6156
|
received = NONE;
|
|
6075
6157
|
return;
|