@mmstack/primitives 21.6.1 → 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.
@@ -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 (op-protocol RFC §12 v0): reconciles two concurrent versions of
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 (op-protocol RFC §4): a per-path last-writer-wins
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 = { hlc: env.hlc, writer: env.writer, origin: env.origin };
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, { hlc: env.hlc, writer: env.writer, origin: env.origin, op: accepted });
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 (op-protocol RFC §5): invert pending, apply remote, re-apply
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
- let version = 0;
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: opt.injector ?? inject(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: ++version,
5273
+ version: nextVersion,
5239
5274
  hlc: clock.next(),
5240
5275
  policyVersion: opt.policyVersion ?? 0,
5241
5276
  ops,
5242
5277
  };
5243
- versions.set(origin, env.version);
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
- for (const cb of [...subscribers])
5249
- cb(env);
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 (RFC §6 tab flavor). */
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);