@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 (op-protocol RFC §12 v0): reconciles two concurrent versions of
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 (op-protocol RFC §4): a per-path last-writer-wins
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 = { hlc: env.hlc, writer: env.writer, origin: env.origin };
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, { hlc: env.hlc, writer: env.writer, origin: env.origin, op: accepted });
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 (op-protocol RFC §5): invert pending, apply remote, re-apply
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
- let version = 0;
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: opt.injector ?? inject(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: ++version,
5314
+ version: nextVersion,
5279
5315
  hlc: clock.next(),
5280
5316
  policyVersion: opt.policyVersion ?? 0,
5281
5317
  ops,
5282
5318
  };
5283
- versions.set(origin, env.version);
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
- for (const cb of [...subscribers])
5289
- cb(env);
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 (RFC §6 tab flavor). */
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);