@oimdb/core 3.9.0 → 3.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 +18 -1
- package/dist/index.cjs +761 -458
- package/dist/index.d.cts +341 -125
- package/dist/index.d.ts +341 -125
- package/dist/index.js +755 -457
- package/llms.txt +82 -2
- package/package.json +1 -1
package/llms.txt
CHANGED
|
@@ -30,6 +30,7 @@ import { OIMEventQueue, OIMEventQueueSchedulerImmediate } from '@oimdb/core';
|
|
|
30
30
|
|
|
31
31
|
const queue = new OIMEventQueue({
|
|
32
32
|
scheduler: new OIMEventQueueSchedulerImmediate(), // macrotask: setImmediate (Node), fallback MessageChannel / setTimeout(0) (browser)
|
|
33
|
+
onError: (e) => reportToSentry(e), // optional; see below
|
|
33
34
|
});
|
|
34
35
|
// or: OIMEventQueueSchedulerMicrotask (next microtask, before paint — lowest latency),
|
|
35
36
|
// OIMEventQueueSchedulerAnimationFrame, OIMEventQueueSchedulerTimeout
|
|
@@ -38,6 +39,8 @@ const queue = new OIMEventQueue({
|
|
|
38
39
|
// debug/diagnostic only: swap in at the root to test if a bug is coalescing-related.
|
|
39
40
|
```
|
|
40
41
|
|
|
42
|
+
**Errors during flush.** If a task (subscriber / effect / computed) throws while the queue is flushing, the other tasks in that flush still run (each is isolated) and the queue's internal state is always restored — one bad task never wedges the queue. By default the error is then **raised, not swallowed**: it propagates out of `flush()` (thrown to the caller of a manual flush, or uncaught in async/scheduled mode). Pass `onError` to handle it instead (then it is NOT re-raised). Regardless of `onError`, a `EOIMEventQueueEventType.FLUSH_ERROR` event (payload `TOIMFlushError = { error }`) is emitted per failing task — the channel `@oimdb/devtools` `trackFlushErrors` / MCP use to surface failed flushes.
|
|
43
|
+
|
|
41
44
|
### Collections
|
|
42
45
|
|
|
43
46
|
`OIMCollection` — base CRUD store. `OIMReactiveCollection` — same + emits events on flush.
|
|
@@ -83,6 +86,8 @@ byRole.subscribeOnKey('admin', () => {
|
|
|
83
86
|
queue.flush(); // delivers subscription
|
|
84
87
|
```
|
|
85
88
|
|
|
89
|
+
**`subscribeOnKeys(keys, handler)` delivery is coalesced per flush:** the handler fires at most ONCE per flush no matter how many of the subscribed keys changed in it (the callback gets no key, so per-key fan-out would be pure waste). This holds across all reactive primitives — sync (collection / index / object, via `OIMCarrierKeyedEmitter`) and the `@oimdb/async` nodes (via `OIMUpdateEventEmitterAsync`) alike. `subscribeOnKey` (single key) is unchanged. Handler identity is preserved, so `unsubscribeFromKeys(keys, handler)` still removes a `subscribeOnKeys` subscription.
|
|
90
|
+
|
|
86
91
|
Membership writes are incremental (touch only the changed pks):
|
|
87
92
|
- `addPks(key, pks)` / `removePks(key, pks)` — **set-based**: O(1) per pk for both. **array-based**: `addPks` O(1), `removePks` O(bucket) (ordered, must scan).
|
|
88
93
|
- For frequently changing membership (e.g. a **virtual/windowed list** indexing the on-screen rows, adding/removing on scroll) prefer **set-based**.
|
|
@@ -194,6 +199,20 @@ const rows = createOIMIndexSlotMap(usersByTeam, (slot) => makeRow(slot.item));
|
|
|
194
199
|
rows.subscribeOnKey('team1', () => { for (const row of rows.getByKey('team1')) { /* stable per slot */ } });
|
|
195
200
|
```
|
|
196
201
|
|
|
202
|
+
### Derived collections
|
|
203
|
+
|
|
204
|
+
`OIMDerivedCollection(queue, source, derive, opts?)` is a reactive collection whose entities are derived **one-to-one** from a source collection — `derived[pk] = derive(source[pk], pk)`, keyed by the SOURCE pk — kept in sync (add / update / remove) automatically. It is itself a first-class `OIMReactiveCollection`, so it can be indexed, selected, and derived-from again (join → join); maintenance runs on the source's **synchronous** update channel, so a chain of derivations stays consistent within a flush (delivery to keyed subscribers is batched on flush as usual). It parallels `OIMDerivedCollectionIndex*` and needs no compute runtime.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
const nameView = new OIMDerivedCollection(queue, users,
|
|
208
|
+
(u) => ({ id: u.id, full: `${u.first} ${u.last}` }));
|
|
209
|
+
nameView.getOneByPk('u1'); // { id, full } — stays in sync with `users`
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- `opts.compare` (default `Object.is`) — skip a downstream update when a source change leaves the re-derived entity equal (pass a field/shallow compare to memoize finer; the default recomputes a fresh object each time, so it propagates every source change).
|
|
213
|
+
- `opts.store` — pass a trie-driven store when the source PK is composite (`TOIMKeyPath`).
|
|
214
|
+
- `opts.buildInitial` (default true).
|
|
215
|
+
|
|
197
216
|
### DX Layer — Collection Model
|
|
198
217
|
|
|
199
218
|
`createOIMCollectionKit` wires queue + collection + index factory + selectors together.
|
|
@@ -216,6 +235,31 @@ const u1 = select.byPk('u1');
|
|
|
216
235
|
u1.getValue(); // → User | undefined
|
|
217
236
|
```
|
|
218
237
|
|
|
238
|
+
The kit also fronts the compute system, so you don't hand-write dependency wrappers or manage a runtime. `on.*` builds dependencies; `kit.computed` / `kit.effect` create nodes on the queue's **shared** runtime (all computeds/effects/selectors on one queue form ONE dependency graph — so a computed can depend on selectors and on other collections) and register them in `kit.scope` for teardown:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const { collection, computed, effect, on } =
|
|
242
|
+
createOIMCollectionKit<User, string>(queue, { selectPk: (u) => u.id });
|
|
243
|
+
|
|
244
|
+
const isAdmin = computed(
|
|
245
|
+
[on.collection(collection, 'u1'), on.index(byRole, 'admin'), on.computed(orderCount)],
|
|
246
|
+
() => collection.getOneByPk('u1')?.role === 'admin',
|
|
247
|
+
);
|
|
248
|
+
effect([on.computed(isAdmin)], () => console.log(isAdmin.get()));
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`on` builders: `on.collection(collection, pk)`, `on.index(index, key)`, `on.object(object, key)`, `on.computed(computed)`. Need the runtime directly (e.g. outside a kit)? `getOIMComputeRuntime(queue)` returns the one shared runtime for a queue. The low-level primitives below are what these wrap.
|
|
252
|
+
|
|
253
|
+
**Runtime registry.** Every computed / effect / selector on a queue MUST share one runtime — they form one dependency graph, and topological levels only mean anything within a single runtime. Three functions manage that, all keyed by queue (there is no global instance; two queues get two runtimes, so tests need no reset):
|
|
254
|
+
|
|
255
|
+
| Function | Use |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `getOIMComputeRuntime(queue)` | The runtime for a queue, created on first use. A destroyed one is replaced, never handed back. |
|
|
258
|
+
| `peekOIMComputeRuntime(queue)` | The runtime **if one exists**, else `undefined` — never creates. For devtools/teardown/assertions that must not change what they observe. A destroyed runtime reads as `undefined`. |
|
|
259
|
+
| `setOIMComputeRuntime(queue, runtime)` | Install your own (instrumented subclass, test double). Throws if `runtime.queue !== queue`. Call BEFORE anything builds the graph — nodes capture their runtime at construction, so a late swap splits the graph in two. Does not destroy the previous runtime. |
|
|
260
|
+
|
|
261
|
+
`runtime.destroy()` detaches it from the queue's `AFTER_FLUSH` and drops everything pending; `runtime.isDestroyed` reports it. Destroy the nodes first, then the runtime — destroying the runtime does not destroy the computeds/effects scheduled on it. Idempotent. Without it the subscription lives as long as the queue, which is harmless (the handler early-returns) but cannot be unwound.
|
|
262
|
+
|
|
219
263
|
### Computed Values
|
|
220
264
|
|
|
221
265
|
Derived values that recompute lazily when dependencies change. Recomputation is scheduled on `queue.flush()`.
|
|
@@ -352,10 +396,10 @@ Trade-offs (use only when every reader is subscription-driven): NOT Concurrent-s
|
|
|
352
396
|
## Exodra (`@oimdb/exodra`)
|
|
353
397
|
|
|
354
398
|
```bash
|
|
355
|
-
npm install @oimdb/exodra @
|
|
399
|
+
npm install @oimdb/exodra @oimdb/core
|
|
356
400
|
```
|
|
357
401
|
|
|
358
|
-
The [Exodra](https://exodra.org) counterpart of `@oimdb/react`: instead of a hook + re-render, every OIMDB source becomes a read-only Exodra bindable (`TExoBindable<T> = { getValue(): T; subscribe(update): () => void }`). No React. Built on `@oimdb/core`'s `OIMSelector` (`{ getValue, watch }`) — the fine-grained subscription already lives there, so this is a thin adapter. Bindables are read-only, lazy (subscribe upstream only while they have a downstream subscriber → cost O(visible)) and SSR-safe (`getValue()` reads fresh, valid without an active subscription).
|
|
402
|
+
Depends only on the type-only `@exodra/reactivity-types` (never the reactivity runtime); peer dep `@oimdb/core`. Your Exodra app already provides `@exodra/reactivity` for the runtime (`bindable`, `derive`, `h`). The [Exodra](https://exodra.org) counterpart of `@oimdb/react`: instead of a hook + re-render, every OIMDB source becomes a read-only Exodra bindable (`TExoBindable<T> = { getValue(): T; subscribe(update): () => void }`). No React. Built on `@oimdb/core`'s `OIMSelector` (`{ getValue, watch }`) — the fine-grained subscription already lives there, so this is a thin adapter. Bindables are read-only, lazy (subscribe upstream only while they have a downstream subscriber → cost O(visible)) and SSR-safe (`getValue()` reads fresh, valid without an active subscription).
|
|
359
403
|
|
|
360
404
|
```ts
|
|
361
405
|
import { fromSelector, bindSelectors, fromComputed, combine, entityRows } from '@oimdb/exodra';
|
|
@@ -407,6 +451,42 @@ await persistor.hydrate(); // load from storage into collections
|
|
|
407
451
|
persistor.start(); // autosave on every queue.flush()
|
|
408
452
|
```
|
|
409
453
|
|
|
454
|
+
### Delta writes (persist only changed keys)
|
|
455
|
+
|
|
456
|
+
Default: any change writes the WHOLE snapshot (`takeSnapshot()` reads the whole
|
|
457
|
+
collection; `strategy.write` rewrites all of it) — `O(N)` per flush. A strategy can
|
|
458
|
+
opt into key-granular writes by implementing the optional
|
|
459
|
+
`writeDelta(persistor, delta)`. When it does AND the source is key-aware (an
|
|
460
|
+
`OIMReactiveCollection`, whose `subscribeOnAnyUpdate` reports changed PKs), the
|
|
461
|
+
engine sends only what changed in the flush instead of the full snapshot:
|
|
462
|
+
|
|
463
|
+
```ts
|
|
464
|
+
import { TOIMPersistDelta } from '@oimdb/persist';
|
|
465
|
+
// delta: { upserts: Array<{ key, value }>; deletedKeys: key[] }
|
|
466
|
+
async writeDelta(p, delta: TOIMPersistDelta<string, User>) {
|
|
467
|
+
for (const { key, value } of delta.upserts) p.storage.put(key, value);
|
|
468
|
+
for (const key of delta.deletedKeys) p.storage.delete(key);
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
- Opt-in and optional. A whole-blob backend (one localStorage key, one KV entry) OMITS
|
|
473
|
+
`writeDelta` → engine always full-rewrites (a single-key change still rewrites the
|
|
474
|
+
whole collection — by design / unavoidable there).
|
|
475
|
+
- A `writeDelta` strategy owns its per-key storage format; the resource-level
|
|
476
|
+
whole-snapshot `codec` is NOT applied on the delta path.
|
|
477
|
+
- Manual `persist()` always writes full snapshots; deltas apply to the autosave path
|
|
478
|
+
(`start()` + change + `flush`), where the changed keys are known.
|
|
479
|
+
- `collection.clear()` enumerates every removed key in its UPDATE / `subscribeOnAnyUpdate`
|
|
480
|
+
payload — it is NOT an empty-`pks` "reset" signal. So the delta path deletes the cleared
|
|
481
|
+
rows (they arrive as `deletedKeys`), snapshot-manager records them as deletions, and
|
|
482
|
+
keyed subscribers on any cleared key are notified. Clearing an empty collection emits
|
|
483
|
+
nothing. Same for `OIMObject.clear()` (enumerates removed keys).
|
|
484
|
+
- Built-in: the `records` strategies of `-memory` and `-idb` implement `writeDelta`
|
|
485
|
+
(IndexedDB batches changed rows into the shared tx instead of clearing+rewriting the
|
|
486
|
+
table). `entry`/`path` (localStorage, async-kv) do not.
|
|
487
|
+
- Exported types: `TOIMPersistDelta`, `TOIMPersistKeyedCapability` (source side),
|
|
488
|
+
`TOIMPersistBatchItem` (for backends overriding `batchPersist`).
|
|
489
|
+
|
|
410
490
|
### Error handling
|
|
411
491
|
|
|
412
492
|
```ts
|