@mmstack/primitives 20.11.1 → 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 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 (op-protocol RFC §12 v0): reconciles two concurrent versions of
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 (op-protocol RFC §4): a per-path last-writer-wins
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 = { hlc: env.hlc, writer: env.writer, origin: env.origin };
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, { hlc: env.hlc, writer: env.writer, origin: env.origin, op: accepted });
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 (op-protocol RFC §5): invert pending, apply remote, re-apply
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
- let version = 0;
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: opt.injector ?? inject(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: ++version,
5303
+ version: nextVersion,
5269
5304
  hlc: clock.next(),
5270
5305
  policyVersion: opt.policyVersion ?? 0,
5271
5306
  ops,
5272
5307
  };
5273
- versions.set(origin, env.version);
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
- for (const cb of [...subscribers])
5279
- cb(env);
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 (RFC §6 tab flavor). */
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);