@mmstack/primitives 21.10.3 → 21.11.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
@@ -21,7 +21,7 @@ npm install @mmstack/primitives
21
21
  - [Effects](#effects) — `nestedEffect`
22
22
  - [Concurrency & transitions](#concurrency--transitions) — `keepPrevious`, keep-alive (`MmActivity`), `pausable*` / `providePausableOptions`, Suspense (`mm-suspense`), hold-and-swap (`*mmTransition`), per-element morphs (`mmViewTransitionName`), async derivations (`latest` / `use`), `deferredValue`, `startTransition` / `startTransaction`, `holdUntilReady`
23
23
  - [History & persistence](#history--persistence) — `withHistory`, `storeHistory`, `stored`, `persistedStore`, `tabSync`, `opLog`
24
- - [Sync & convergence](#sync--convergence) — `opSync`, `tabSync(store)`, merge policies (`lww`, `mergeThree`, `keyedArray`, `preserve`), `Conflicted`, keyed containers (`orderedEntries`, `insertElement`, `moveElement`, `rebalanceContainer`), `rebaseOps`, `policyStrategy`, `syncedFork`
24
+ - [Sync & convergence](#sync--convergence) — `opSync`, `tabSync(store)`, merge policies (`lww`, `mergeThree`, `keyedArray`, `preserve`), `Conflicted`, keyed containers (`keyedContainer`, `wrappedContainer`, `orderedEntries`, `posBetween`), `rebaseOps`, `policyStrategy`, `syncedFork`
25
25
  - [Observability](#observability) — `provideConcurrencyInstrumentation`, `perfCustomTracks`
26
26
  - [Performance helpers](#performance-helpers) — `chunked`, `pooled` / `pooledArray` / `pooledMap` / `pooledSet`
27
27
  - [Sensors](#sensors) — `sensor()` facade + browser-state signals
@@ -369,8 +369,11 @@ The foundation of stale-while-revalidate. Wraps a signal so it **holds its last
369
369
  import { keepPrevious } from '@mmstack/primitives';
370
370
 
371
371
  const held = keepPrevious(resource.value); // drops to undefined mid-reload → keeps last value
372
+ const rows = keepPrevious(resource.value, { fallback: [] }); // [] only until the first value lands
372
373
  ```
373
374
 
375
+ `fallback` is yielded only while nothing has ever been defined; after the first defined value the previous value covers every gap, never the fallback. Like any linked signal the hold is lazy: it carries a value it has computed with, so place it over the value your readers read — reading is what feeds it. `@mmstack/resource` does exactly that for its `keepPrevious` option.
376
+
374
377
  If the source is writable, `set` / `update` / `asReadonly` (and `mutate` / `inline` / `from` for mutable / derived sources) are forwarded through, so it stays a drop-in replacement. `@mmstack/resource` uses it under the hood for its `keepPrevious` option.
375
378
 
376
379
  ### Keep-alive — `MmActivity` / `injectPaused` / `providePaused`
@@ -792,6 +795,24 @@ const board = tabSync(store({ title: 'Board', todos: [] }), {
792
795
 
793
796
  A **merge policy** decides the result when two peers change one path at once: `lww` (default), `mergeThree` (three-way against the common ancestor), `keyedArray(idFn)` (list reconcile by identity), or `preserve` (both sides survive as a `Conflicted` value; `isConflicted(v)` narrows it, resolution is a later write). `rebaseOps(root, pending, remote, policies)` is the pure invert-apply-reapply routine behind optimistic updates and offline queues, and `policyStrategy(policies)` gives a `forkStore` the same per-path resolution. This is what [`@mmstack/mesh`](https://www.npmjs.com/package/@mmstack/mesh) wraps for multiplayer.
794
797
 
798
+ ### Keyed containers
799
+
800
+ **A list several peers reorder is a record keyed by element id, never an array.** Each element carries a fractional position at `~pos`, so an insert is one write at `[list, id]` and a move is one write at `[list, id, '~pos']` — two peers inserting into the same list at once keep both elements, where one whole-array write would have folded over the other.
801
+
802
+ ```typescript
803
+ import { keyedContainer } from '@mmstack/primitives';
804
+
805
+ const board = store<{ todos: Record<string, Todo> }>({ todos: {} });
806
+ const todos = keyedContainer({ key: (t: Todo) => t.id }); // or pass the key to insert
807
+
808
+ todos.insert(board.todos, { id: 't1', title: 'Ship it' }, 0);
809
+ todos.move(board.todos, 't1', 3); // writes the position and nothing else
810
+ todos.entries(board.todos()); // reading order: by ~pos, key breaking ties
811
+ todos.rebalance(sync, board.todos); // authority sweep when positions grow long
812
+ ```
813
+
814
+ Reading order is a pure function of the materialized value, so every replica agrees without consulting the op log. `wrappedContainer` stores elements as `{ '~pos', value }` instead, keeping the payload a closed record a schema can validate; the choice is fixed when the container is created and never inferred from data, so peers of a synced container must agree on it. `posBetween(before, after)` is the fractional index underneath.
815
+
795
816
  ## Observability
796
817
 
797
818
  An optional listener seam on the concurrency layer. `provideConcurrencyInstrumentation(listener)` receives events as transition scopes coordinate pending, suspense, and transaction windows; with no listener the taps are no-ops. `perfCustomTracks()` is a ready listener that writes each window to a Chrome DevTools Performance track, and the window hooks are span-shaped, so forwarding to [`@mmstack/telemetry-core`](https://www.npmjs.com/package/@mmstack/telemetry-core) is a direct mapping.
@@ -1871,14 +1871,19 @@ function isDerivation(sig) {
1871
1871
 
1872
1872
  function keepPrevious(src, opt) {
1873
1873
  const mutableSrc = isWritableSignal$2(src) && isMutable(src);
1874
+ const { fallback, ...signalOpt } = opt ?? {};
1874
1875
  let cnt = 0;
1875
1876
  const baseEqual = opt?.equal;
1876
1877
  const equal = mutableSrc
1877
1878
  ? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
1878
1879
  : baseEqual;
1879
- const persisted = linkedSignal({ ...(ngDevMode ? { debugName: "persisted" } : /* istanbul ignore next */ {}), ...opt,
1880
+ const persisted = linkedSignal({ ...(ngDevMode ? { debugName: "persisted" } : /* istanbul ignore next */ {}), ...signalOpt,
1880
1881
  source: () => src(),
1881
- computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
1882
+ computation: (next, prev) => {
1883
+ if (next !== undefined)
1884
+ return next;
1885
+ return prev !== undefined ? prev.value : fallback;
1886
+ },
1882
1887
  equal });
1883
1888
  if (isWritableSignal$2(src)) {
1884
1889
  persisted.set = src.set;
@@ -5112,6 +5117,81 @@ function validateEnvelope(env) {
5112
5117
  }
5113
5118
  return null;
5114
5119
  }
5120
+ function wireValueViolation(value) {
5121
+ return violationAt(value, false, []);
5122
+ }
5123
+ function violationAt(value, nested, ancestors) {
5124
+ if (value === undefined)
5125
+ return nested ? 'undefined-in-container' : null;
5126
+ if (value === null)
5127
+ return null;
5128
+ const t = typeof value;
5129
+ if (t === 'boolean' || t === 'string')
5130
+ return null;
5131
+ if (t === 'number') {
5132
+ if (!Number.isFinite(value))
5133
+ return 'non-finite-number';
5134
+ return Object.is(value, -0) ? 'negative-zero' : null;
5135
+ }
5136
+ if (t === 'bigint')
5137
+ return 'bigint';
5138
+ if (t === 'function')
5139
+ return 'function';
5140
+ if (t === 'symbol')
5141
+ return 'symbol';
5142
+ if (ancestors.includes(value))
5143
+ return 'cycle';
5144
+ ancestors.push(value);
5145
+ try {
5146
+ if (Array.isArray(value)) {
5147
+ for (let i = 0; i < value.length; i++) {
5148
+ if (!Object.hasOwn(value, i))
5149
+ return 'sparse-array';
5150
+ const reason = violationAt(value[i], true, ancestors);
5151
+ if (reason)
5152
+ return reason;
5153
+ }
5154
+ const keys = Object.keys(value);
5155
+ if (keys.length !== value.length)
5156
+ return 'array-named-property';
5157
+ for (const key of keys) {
5158
+ const idx = Number(key);
5159
+ if (String(idx) !== key ||
5160
+ !Number.isInteger(idx) ||
5161
+ idx < 0 ||
5162
+ idx >= value.length) {
5163
+ return 'array-named-property';
5164
+ }
5165
+ }
5166
+ return null;
5167
+ }
5168
+ const proto = Object.getPrototypeOf(value);
5169
+ if (proto !== Object.prototype && proto !== null)
5170
+ return 'non-plain-object';
5171
+ for (const key of Object.keys(value)) {
5172
+ const reason = violationAt(value[key], true, ancestors);
5173
+ if (reason)
5174
+ return reason;
5175
+ }
5176
+ return null;
5177
+ }
5178
+ finally {
5179
+ ancestors.pop();
5180
+ }
5181
+ }
5182
+ // dev-only: one warning per offending batch names the first violating op, then stops
5183
+ const lintWireValues = (ops) => {
5184
+ for (const op of ops) {
5185
+ const reason = (op.kind === 'set' ? wireValueViolation(op.next) : null) ??
5186
+ (op.kind !== 'clear' && Object.hasOwn(op, 'prev')
5187
+ ? wireValueViolation(op.prev)
5188
+ : null);
5189
+ if (!reason)
5190
+ continue;
5191
+ console.warn(`[@mmstack/primitives] op value at "${op.path.join('.')}" will not survive a JSON transport (${reason}): peers materialize a different value than this emitter, and the room can diverge. Op values must round-trip JSON unchanged; encode rich leaves as strings.`);
5192
+ return;
5193
+ }
5194
+ };
5115
5195
  const lww = (_ancestor, mine) => mine;
5116
5196
  const mergeThree = (ancestor, mine, theirs) => merge3(ancestor, mine, theirs);
5117
5197
  const preserve = (ancestor, mine, theirs) => ({
@@ -5865,6 +5945,8 @@ function opSync(source, opt) {
5865
5945
  const emitLocal = (ops) => {
5866
5946
  const frontier = scopeFrontier;
5867
5947
  const stamped = conv.stamp(ops, { bump: bumping, frontier });
5948
+ if (isDevMode())
5949
+ lintWireValues(stamped);
5868
5950
  const nextVersion = (versions.get(origin) ?? 0) + 1;
5869
5951
  const env = {
5870
5952
  proto: OP_PROTO_VERSION,
@@ -6136,11 +6218,14 @@ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
6136
6218
  * deterministic on every replica.
6137
6219
  */
6138
6220
  function orderedEntries(container) {
6221
+ return entriesOf(container, (element) => element);
6222
+ }
6223
+ function entriesOf(container, payloadOf) {
6139
6224
  const entries = [];
6140
6225
  for (const key of Object.keys(container)) {
6141
- const value = container[key];
6142
- const raw = isRecord(value) ? value[POS_SEGMENT] : undefined;
6143
- entries.push({ key, pos: typeof raw === 'string' ? raw : '', value });
6226
+ const element = container[key];
6227
+ const raw = isRecord(element) ? element[POS_SEGMENT] : undefined;
6228
+ entries.push({ key, pos: typeof raw === 'string' ? raw : '', value: payloadOf(element) });
6144
6229
  }
6145
6230
  entries.sort((a, b) => a.pos < b.pos ? -1 : a.pos > b.pos ? 1 : a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
6146
6231
  return entries;
@@ -6160,12 +6245,18 @@ const neighborPositions = (entries, index) => {
6160
6245
  * layer diffs on its own. Returns the assigned position. Re-inserting an existing key overwrites it.
6161
6246
  */
6162
6247
  function insertElement(container, key, value, index) {
6248
+ return insertInto(container, key, value, index, inlineElement);
6249
+ }
6250
+ const inlineElement = (value, pos) => {
6163
6251
  if (POS_SEGMENT in value)
6164
- devError(`insertElement: '${POS_SEGMENT}' is managed, drop it from the value`);
6252
+ devError(`insert: '${POS_SEGMENT}' is managed, drop it from the value`);
6253
+ return { ...value, [POS_SEGMENT]: pos };
6254
+ };
6255
+ function insertInto(container, key, value, index, elementOf) {
6165
6256
  const entries = orderedEntries(container()).filter((e) => e.key !== key);
6166
6257
  const [before, after] = neighborPositions(entries, index ?? entries.length);
6167
6258
  const pos = posBetween(before, after);
6168
- container.update((c) => ({ ...c, [key]: { ...value, [POS_SEGMENT]: pos } }));
6259
+ container.update((c) => ({ ...c, [key]: elementOf(value, pos) }));
6169
6260
  return pos;
6170
6261
  }
6171
6262
  /**
@@ -6232,6 +6323,23 @@ function evenPositions(n) {
6232
6323
  }
6233
6324
  return out;
6234
6325
  }
6326
+ function keyedContainer(config = {}) {
6327
+ return helpersFor(config.key, inlineElement, (element) => element);
6328
+ }
6329
+ function wrappedContainer(config = {}) {
6330
+ return helpersFor(config.key, (value, pos) => ({ [POS_SEGMENT]: pos, value }), (element) => element.value);
6331
+ }
6332
+ function helpersFor(extract, elementOf, payloadOf) {
6333
+ return {
6334
+ entries: (container) => entriesOf(container, payloadOf),
6335
+ insert: (container, a, b, c) => extract
6336
+ ? insertInto(container, extract(a), a, b, elementOf)
6337
+ : insertInto(container, a, b, c, elementOf),
6338
+ move: (container, key, index) => moveElement(container, key, index),
6339
+ remove: (container, key) => removeElement(container, key),
6340
+ rebalance: (sync, container) => rebalanceContainer(sync, container),
6341
+ };
6342
+ }
6235
6343
 
6236
6344
  const PATH_SEP = '';
6237
6345
  const OP_SEP = '';
@@ -7402,5 +7510,5 @@ function withHistory(sourceOrValue, opt) {
7402
7510
  * Generated bundle index. Do not edit.
7403
7511
  */
7404
7512
 
7405
- export { CONCURRENCY_INSTRUMENTATION, MmActivity, MmTransition, MmViewTransitionName, OP_PROTO_VERSION, PAUSABLE_OPTIONS, PERSISTED_STORE_OPTIONS, POS_SEGMENT, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, applyOps, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, compareHlc, compareSiblings, compareTotal, createAttributedPending, createConvergingApply, createForwardingScope, createHlcClock, createStoreContext, createTransaction, createTransitionScope, debounce, debounced, defaultFold, deferredValue, derived, diffOps, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, insertElement, invertBatch, isConflicted, isDerivation, isLeaf, isMutable, isOpaque, isStore, isStored, keepPrevious, keyArray, keyedArray, latest, lww, map, mapArray, mapObject, mediaQuery, merge3, mergeThree, mousePosition, moveElement, mutable, mutableStore, nestedEffect, networkStatus, opLog, opSync, opaque, orderedEntries, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, perfCustomTracks, persist, persistedStore, pipeable, piped, pointerDrag, policyStrategy, pooled, pooledArray, pooledKeys, pooledMap, pooledSet, posBetween, prefersDarkMode, prefersReducedMotion, preserve, projection, provideConcurrencyInstrumentation, provideForwardingTransitionScope, providePausableOptions, providePaused, providePersistedStoreOptions, provideTransitionScope, rebalanceContainer, rebaseOps, reconcile, registerResource, removeElement, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, storeHistory, stored, syncedFork, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, traced, until, use, validateEnvelope, windowSize, withHistory };
7513
+ export { CONCURRENCY_INSTRUMENTATION, MmActivity, MmTransition, MmViewTransitionName, OP_PROTO_VERSION, PAUSABLE_OPTIONS, PERSISTED_STORE_OPTIONS, POS_SEGMENT, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, applyOps, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, compareHlc, compareSiblings, compareTotal, createAttributedPending, createConvergingApply, createForwardingScope, createHlcClock, createStoreContext, createTransaction, createTransitionScope, debounce, debounced, defaultFold, deferredValue, derived, diffOps, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, insertElement, invertBatch, isConflicted, isDerivation, isLeaf, isMutable, isOpaque, isStore, isStored, keepPrevious, keyArray, keyedArray, keyedContainer, latest, lww, map, mapArray, mapObject, mediaQuery, merge3, mergeThree, mousePosition, moveElement, mutable, mutableStore, nestedEffect, networkStatus, opLog, opSync, opaque, orderedEntries, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, perfCustomTracks, persist, persistedStore, pipeable, piped, pointerDrag, policyStrategy, pooled, pooledArray, pooledKeys, pooledMap, pooledSet, posBetween, prefersDarkMode, prefersReducedMotion, preserve, projection, provideConcurrencyInstrumentation, provideForwardingTransitionScope, providePausableOptions, providePaused, providePersistedStoreOptions, provideTransitionScope, rebalanceContainer, rebaseOps, reconcile, registerResource, removeElement, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, storeHistory, stored, syncedFork, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, traced, until, use, validateEnvelope, windowSize, withHistory, wrappedContainer };
7406
7514
  //# sourceMappingURL=mmstack-primitives.mjs.map