@oimdb/react 2.2.0 → 3.0.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/llms.txt ADDED
@@ -0,0 +1,4156 @@
1
+ # OIMDB
2
+
3
+ > **For AI assistants: read this file before writing any OIMDB code.**
4
+ >
5
+ > `@oimdb/core` is an in-memory reactive engine. This file is its API reference,
6
+ > written against the current source.
7
+ >
8
+ > **Services hold no data; state lives on slots.** An `OIMDB` owns the clock, the collector slot and
9
+ > its `defaultChannelSlot`. A collection service (`OIMSlotCollectionService`) or a single slot service
10
+ > (`OIMSingleSlotService`) is built once per collection type, and `service.createStoreSlot(db)` makes an
11
+ > instance, a `TOIMStoreSlot`, which is NOT a slot (no owner, holds or effects) and carries its
12
+ > `channelSlot`.
13
+ > READS and minting are on the collection service and take the store slot first
14
+ > (`cardService.getSlotByKey(cards, key)`, `holdSlotByKey`, `findSlotByKey`, `getSlots`, `getSlotCount`, the
15
+ > column doors). WRITES are on `service.slotContentService` and take a slot
16
+ > (`cardService.slotContentService.updateEntity(slot, patch)`) or, for a batch, the store slot
17
+ > (`cardService.slotContentService.upsertEntities(cards, entities)`). There are NO by-key write doors:
18
+ > get or find the slot, then write through it.
19
+ > A single — one slot under no key — is an `OIMSingleSlotService`: `getSingleSlot(storeSlot)`,
20
+ > `holdSingleSlot(storeSlot)`, `findSingleSlot(storeSlot)`.
21
+ >
22
+ > For entities the base layer is `createOIMSlotCollectionService({ slotContentService })` /
23
+ > `createOIMSingleSlotService({ slotContentService })` over `createOIMSlotContentEntityService(db, …)`;
24
+ > `createOIMEntityService` and `createOIMSingleEntityService` are convenience wrappers over it. A bucket
25
+ > collection is `createOIMBucketService(db, { slotContentService })` (single:
26
+ > `createOIMSingleBucketService`), where the content service is `new OIMSlotContentBucketServiceSetDriven()`,
27
+ > `new OIMSlotContentBucketServiceArrayDriven()` (the default) or the sorted one; an index is
28
+ > `createOIMIndexService(db, { entityService, keyOf | columns, slotContentService? })`, and
29
+ > `createOIMIndexServiceByCompositeKey` is a convenience wrapper over it.
30
+ > Position reads and writes (`getBucketMemberSlotAt`, `getBucketMemberSlotPosition`, `insertBucketMemberSlotAt`,
31
+ > `removeBucketMemberSlotAt`, `removeBucketMemberSlotRange`, `moveBucketMemberSlotToPosition`,
32
+ > `moveBucketMemberSlotRangeToPosition`) are on `OIMSlotContentBucketServiceArrayDriven`, the default bucket
33
+ > content service; the set-driven one has none. A bucket sorts only on the sorted-at-write content service
34
+ > (`createOIMIndexServiceSortedArrayDriven`); on every other bucket its order is the caller's. A sorted list
35
+ > over any index is `createOIMComputedServiceSorted`. The render script is `OIMBucketCommands`.
36
+ > The channel has no class: its operations are on `OIMChannelService.INSTANCE` and take a channel slot.
37
+ > Effects are `OIMEffectService.INSTANCE.createEffect(db, sourceSlots, sourceComputeds, body)`.
38
+ > Every stateless singleton is a static `INSTANCE` on its class. Module-level defaults are on `OIMDefaults`.
39
+ >
40
+ > Check every symbol you write against [the full export list](#full-export-list).
41
+ >
42
+ > **Nothing in this repository is published to npm and nothing consumes it.**
43
+ > Backwards compatibility, semver and migration paths are not costs here.
44
+ >
45
+ > `packages/core/src/index.ts` is the entire public surface.
46
+
47
+ ## Contents
48
+
49
+ - [The three layers](#the-three-layers)
50
+ - [The doctrine](#the-doctrine) — the four doors, the injected seams, the idle windows, the collectors, forks
51
+ - [The runtime: the channel slot](#the-runtime-the-channel-slot)
52
+ - [`OIMDB` and the three doors](#oimdb-and-the-three-doors)
53
+ - [`OIMDefaults` — where an optional dependency comes from](#oimdefaults--where-an-optional-dependency-comes-from)
54
+ - [Entity services](#entity-services)
55
+ - [Writing](#writing)
56
+ - [Write strategies](#write-strategies)
57
+ - [Single entities](#single-entities)
58
+ - [Buckets](#buckets)
59
+ - [Derivations](#derivations)
60
+ - [Key stores](#key-stores)
61
+ - [Lifetime — the owner protocol](#lifetime--the-owner-protocol)
62
+ - [Ordered lists](#ordered-lists)
63
+ - [Types](#types)
64
+ - [Full export list](#full-export-list)
65
+ - [Known defects](#known-defects)
66
+ - [Other packages](#other-packages)
67
+ - [`@oimdb/persist`](#oimdbpersist)
68
+ - [`@oimdb/exodra`](#oimdbexodra)
69
+ - [Performance](#performance)
70
+
71
+ ---
72
+
73
+ ## The setup the examples share
74
+
75
+ Every `ts` example in this file compiles against `packages/*/src`, on top of this
76
+ block and the one under [One derivation per key](#one-derivation-per-key). Blocks
77
+ marked `ts signature` are listings of an API and are not compiled.
78
+
79
+ ```ts setup
80
+ import {
81
+ OIMChannelService,
82
+ OIMContentChangeListenerService,
83
+ OIMDB,
84
+ OIMDefaults,
85
+ OIMEffectService,
86
+ OIMSlotContentBucketServiceArrayDriven,
87
+ OIMSlotContentBucketServiceSetDriven,
88
+ OIMSlotStoreServiceMapDriven,
89
+ OIMSlotStoreServiceSingleSlotDriven,
90
+ OIMSlotStoreServiceTreeDriven,
91
+ OIMUnusedSlotCollectorService,
92
+ createOIMBucketService,
93
+ createOIMComputedServiceBucketMemo,
94
+ createOIMComputedService,
95
+ createOIMComputedServiceSorted,
96
+ createOIMEntityService,
97
+ createOIMEntityWriterInPlace,
98
+ createOIMEntityWriterReplacing,
99
+ createOIMIndexService,
100
+ createOIMIndexServiceSortedArrayDriven,
101
+ createOIMSingleBucketService,
102
+ createOIMSingleEntityService,
103
+ createOIMSlotCollectionService,
104
+ createOIMSlotContentEntityService,
105
+ createOIMUnusedSlotCollectorSlot,
106
+ oimIsSlotEmpty,
107
+ oimIsSlotReleased,
108
+ oimPatchEntityColumn,
109
+ oimPatchEntityColumns,
110
+ oimPutEntityColumns,
111
+ oimSameValueZeroGuard,
112
+ } from '@oimdb/core';
113
+ import type {
114
+ IOIMEntityWriter,
115
+ TOIMSlotEditable,
116
+ TOIMBucketSlotsArrayStore,
117
+ TOIMBucketSlot,
118
+ TOIMBucketSlotsSetStore,
119
+ TOIMComputedSlot,
120
+ TOIMDisposer,
121
+ TOIMEntitySlot,
122
+ TOIMOrderedListCommand,
123
+ TOIMSingleSlotStore,
124
+ TOIMStoreKey,
125
+ TOIMStoreSlot,
126
+ } from '@oimdb/core';
127
+
128
+ type Card = { id: string; title: string; rank: number; deck: string };
129
+
130
+ declare function paint(card: Card | undefined): void;
131
+ declare function measureAndScroll(): void;
132
+ declare function poll(): void;
133
+ declare function reindex(slot: TOIMEntitySlot<Card, TOIMStoreKey>): void;
134
+ declare function render(slots: readonly TOIMEntitySlot<Card>[]): void;
135
+ declare function applyToTheRenderedList(commands: readonly TOIMOrderedListCommand[]): void;
136
+ declare const payload: readonly Card[];
137
+
138
+ const db = new OIMDB();
139
+ const channelSlot = db.defaultChannelSlot;
140
+ const effectService = OIMEffectService.INSTANCE;
141
+ const cardService = createOIMEntityService<Card>(db, { keyOf: (card) => card.id });
142
+ const cards = cardService.createStoreSlot(db);
143
+ const slot = cardService.slotContentService.upsertEntity(cards, { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' });
144
+
145
+ const orderedService = createOIMBucketService<TOIMEntitySlot<Card>>(db, {
146
+ slotContentService: new OIMSlotContentBucketServiceArrayDriven<TOIMEntitySlot<Card>>(),
147
+ });
148
+ const ordered = orderedService.createStoreSlot(db);
149
+ const orderedBucketSlot = orderedService.holdSlotByKey(ordered, 'top');
150
+
151
+ const pageService = createOIMBucketService<TOIMEntitySlot<Card>>(db);
152
+ const page = pageService.createStoreSlot(db);
153
+ const todo = pageService.holdSlotByKey(page, 'todo');
154
+ const done = pageService.holdSlotByKey(page, 'done');
155
+ ```
156
+
157
+ ---
158
+
159
+ ## The three layers
160
+
161
+ | Layer | Contents |
162
+ |---|---|
163
+ | `src/engine/<domain>/` | the channel (`OIMChannelService`, `TOIMChannelSlot`), the flush scheduler (`OIMFlushSchedulerService`), the slot owner (`OIMSlotOwner`, and `OIMSlotOwnerService`, a type-only export), `OIMContentChangeListenerService`, the entity content service `OIMSlotContentEntityService`, `OIMComputedValueService`, `OIMEffectService`, the column owner (abstract `OIMColumnOwner` with the strategies `OIMColumnOwnerPushDriven` and `OIMColumnOwnerPullDriven`), the key stores and key tree walks, the index keys services, `TOIMStoreSlot`, `TOIMComputedSlot`, `TOIMEffect`, the collector services and slots, and the release policies |
164
+ | `src/facade/<domain>/` | `facade/slot/`: `OIMSlotCollectionService`, `OIMSingleSlotService`; `facade/bucket/`: `OIMSlotContentBucketService` (abstract), `OIMSlotContentBucketServiceSetDriven`, `OIMSlotContentBucketServiceArrayBased` (abstract), `OIMSlotContentBucketServiceArrayDriven`, `OIMSlotContentBucketServiceSortedArrayDriven`, `OIMBucketCommands`; `facade/index/`: `OIMIndexService`, `OIMIndexServiceSortedArrayDriven`; `facade/computed/`: `OIMComputedService`, `OIMComputedServiceSorted`, `OIMComputedServiceBucketMemo` |
165
+ | `src/factories/` | `OIMDB`, `OIMDefaults`, `OIMDefaultValue`; the base factories `createOIMSlotCollectionService`, `createOIMSingleSlotService`, `createOIMSlotContentEntityService`; the convenience wrappers `createOIMEntityService`, `createOIMSingleEntityService`, `createOIMBucketService`, `createOIMSingleBucketService`, `createOIMIndexServiceByCompositeKey`; `createOIMIndexService`, `createOIMIndexServiceSortedArrayDriven`, `createOIMComputedService`, `createOIMComputedServiceSorted`, `createOIMComputedServiceBucketMemo`, `createOIMEntityWriterInPlace`, `createOIMEntityWriterReplacing` |
166
+
167
+ **No class sits directly in `engine/` or `facade/`.** The directory is the
168
+ domain: `engine/bucket/`, `engine/channel/`, `engine/collector/`, `engine/column/`, `engine/computed/`,
169
+ `engine/entity/`, `engine/error/`, `engine/index/`, `engine/key/`, `engine/schedule/`, `engine/slot/`,
170
+ `engine/store/`, each with its own `constants/`, `interfaces/`, `types/` and `utils/` beside its classes; and
171
+ `facade/bucket/`, `facade/computed/`, `facade/index/`, `facade/slot/`.
172
+
173
+ **A service holds no data; state lives on slots.** A collection service is built once per collection type
174
+ and holds only configuration and other services: its slot owner, its store service and its content
175
+ service (`service.slotContentService`). `service.createStoreSlot(db)` makes an instance, a `TOIMStoreSlot`,
176
+ which is NOT a slot (no owner, holds or effects): its `store` is what the store service built, its
177
+ `channelSlot` is the db's channel, and it carries the per-store state (content-change listeners, index key
178
+ positions, bucket write queues, column readers). Every slot filed in it points back to it through
179
+ `slot.storeSlot`, which is why a slot door needs no store slot. Reads are called on the service
180
+ (`cardService.getSlotByKey(cards, pk)`, `cardService.getSlots(cards)`); writes on its content service
181
+ (`cardService.slotContentService.updateEntity(slot, patch)`). A store slot's `store` is the store service's
182
+ to read, never the caller's.
183
+
184
+ ---
185
+
186
+ ## The doctrine
187
+
188
+ ### Four doors mint an identity, and nothing else
189
+
190
+ **Writing a value, subscribing, taking a hold, and reading a key that has
191
+ none.** Each act leaves a **reference** behind on what it produced — a value, an
192
+ effect, a count, or a tracked mint — so nothing can unfile the identity under
193
+ the caller who just got it.
194
+
195
+ ```ts
196
+ cardService.slotContentService.upsertEntity(cards, { id: 'c2', title: 'Two', rank: 2, deck: 'spades' }); // write
197
+ effectService.createEffect(db, [slot], [], () => { paint(slot.entity); }); // subscribe
198
+ cardService.holdSlotByKey(cards, 'c1'); // hold, by key -> the slot, holdCount up by one
199
+ const rankColumn = cardService.holdColumn(slot, 'rank'); // hold, on a column
200
+ cardService.unholdColumn(rankColumn); // give the column back
201
+ cardService.getSlotByKey(cards, 'c1'); // read, by key -> the slot, tracked by the collector
202
+ ```
203
+
204
+ **The non-minting lookup leaves no reference.** `findSlotByKey(storeSlot, key)` (and
205
+ `findSingleSlot(storeSlot)` on a single) answers `undefined` on a miss and mints nothing, and the
206
+ slot it does hand back is protected by nothing the caller took. `slotContentService.findEntityByKey`
207
+ runs the same lookup. There are no by-key write doors: to change or remove an entity by key, find
208
+ the slot first and write through it. Do not write a helper over any of the four doors — it would be a
209
+ second spelling of a mint, not a fifth door.
210
+
211
+ **Subscribing leaves BOTH kinds of reference.** `OIMEffectService.INSTANCE.createEffect(db, …)` files
212
+ the effect in `slot.effects` *and* holds each root it subscribed to
213
+ and each computed source it was given (through `owner.subscribeToSlot`); `disposeEffect(effect)` gives both back. That hold
214
+ is why no idle window has to count subscribers — see
215
+ [where an idle window opens](#two-release-markers-one-per-policy). A root reached only
216
+ *through* a computed is subscribed but **not** held by the effect; a computed
217
+ minted by `OIMComputedService` holds its own `sourceSlots` and
218
+ `sourceComputeds` when it is minted and gives them back when its content is released.
219
+
220
+ **`getSlotByKey(storeSlot, key)` is the fourth door, and the collector is what admits it.** It
221
+ mints what is missing, then hands the slot to the unused-slot collector of the store slot's
222
+ channel (`OIMUnusedSlotCollectorService.INSTANCE` over `storeSlot.channelSlot.collectorSlot.unusedSlotCollectorSlot`),
223
+ which holds it for the caller and gives that hold back at the next `db.flush()`.
224
+ So a `getSlotByKey` inside one turn of work cannot be unfiled under you, and nothing
225
+ is left standing after it.
226
+
227
+ ### A slot goes when nothing refers to it
228
+
229
+ There is **no release API, no sweep a caller runs, and no owner a caller
230
+ assigns**. Two things ask. The release queue calls
231
+ `slot.owner.releaseSlotIfUnheld(slot)` at a deadline; `slot.owner.unholdSlot(slot)` asks
232
+ on the give-back of the last hold.
233
+
234
+ `releaseSlotIfUnheld` is PUBLIC on `IOIMSlotOwner`. Public
235
+ does not mean "release on demand" — `releaseSlotIfUnheld` is still a guard, and it
236
+ returns without doing anything unless the slot is unheld, unreferenced, still
237
+ owned by the object asked, and out of grace. Terms, and who gives each back:
238
+
239
+ | Term | Given back by |
240
+ |---|---|
241
+ | `holdCount` | `slot.owner.unholdSlot(slot)`, or `service.unholdColumn(columnSlot)` for a column; the unused-slot collector's flush; a slot leaving a bucket; `effectService.disposeEffect(effect)`, for the hold the effect took |
242
+ | `effects` | `effectService.disposeEffect(effect)` |
243
+ | a held column | its last column effect being disposed, through the service's column owner (`unsubscribeFromSlot`) |
244
+ | the value | `entityService.slotContentService.deleteEntity(slot)`, or `bucketService.slotContentService.clearBucketMemberSlots(bucketSlot)` |
245
+ | a composite-key edge | `OIMKeyTreeWalkComposite.deleteKey` unlinking that composite key part |
246
+
247
+ `deleteEntity(slot)` takes the **value**. Subscribers stay, holds
248
+ stay, and the identity stays with them. Slots you wrote into a bucket are held by
249
+ that bucket until you empty it.
250
+
251
+ ### A service COMPOSES; nothing is overridden
252
+
253
+ Optional behaviour is added without a branch on the hot path: a collection service is
254
+ built from three stateless parts, wired once at construction, plus the injected seams below.
255
+
256
+ | Part | Where | What it does |
257
+ |---|---|---|
258
+ | the slot owner, `OIMSlotOwnerService` (type-only export) | `slot.owner`, typed `IOIMSlotOwner` | holds, subscriptions, release, emptiness, `assertWritable`, the column owner; one per collection type |
259
+ | the store service, `IOIMSlotStoreService` | given to the factory (`entitySlotStoreService`, `bucketSlotStoreService`, …) or the Map default | files and finds the slots of each store slot's `store` |
260
+ | the content service | `service.slotContentService` | the writes and reads of one kind of content (`OIMSlotContentEntityService`, `OIMSlotContentBucketService*`) and the answers the owner asks of that kind |
261
+
262
+ The owner asks the content kind through one strategy object handed to it at construction, never
263
+ through a subclass of the service:
264
+
265
+ ```ts signature
266
+ interface IOIMSlotContentStrategy<TSlot, TKey, TSlotOwnerService> {
267
+ createSlot(key: TKey, storeSlot: TOIMStoreSlot | undefined, slotOwnerService: TSlotOwnerService): TSlot;
268
+ isSlotEmpty(slot: TSlot): boolean;
269
+ releaseSlotContent(slot: TSlot, slotOwnerService: TSlotOwnerService): void;
270
+ hasReference(slot: TSlot): boolean;
271
+ }
272
+ interface IOIMSlotContentFlushStrategy<TSlot, TSlotOwnerService> {
273
+ onSubframeClosed(storeSlot: TOIMStoreSlot<unknown, TSlot>, slotOwnerService: TSlotOwnerService): void;
274
+ }
275
+ ```
276
+
277
+ `hasReference` answers whether something outside `holdCount` and `effects` keeps the slot (a
278
+ bucket's members); the flush strategy is what a bucket content service delivers at the close of a
279
+ subframe. Every write door asserts the identity is still filed and opens the channel's subframe.
280
+
281
+ ### The injected seams
282
+
283
+ Each is one small interface handed to a constructor, and each answers one
284
+ question that the engine asks but does not decide. There is no flag and no option
285
+ object.
286
+
287
+ ```ts signature
288
+ interface IOIMSlotEmptiness<TSlot> { isEmpty(slot: TSlot): boolean; }
289
+
290
+ interface IOIMSlotStamp<TSlot> { stamp(slotEditable: TOIMSlotEditable<TSlot>): void; }
291
+ ```
292
+
293
+ **What a kind of content answers is not a seam.** Mint, write, clear and release
294
+ belong to the content service, which the factory wires into the owner as its content strategy.
295
+ "Does this still hold something" no part declares — the owner asks `oimIsSlotEmpty(slot)`,
296
+ `hasReference` and the release policy's `isSlotReleasable` separately.
297
+
298
+ **`IOIMSlotEmptiness`** decides what counts as empty, and the answer is written once
299
+ per write into the EMPTY flag of `slot.slotFlags` — never recomputed at a read. The entity content
300
+ service defaults to "the entity is `undefined`" (the `slotEmptiness` option of
301
+ `createOIMSlotContentEntityService` replaces it); a bucket content service answers "no members"; a
302
+ computed answers `false`, because a derivation always has a value to produce.
303
+
304
+ **Emptiness decides lifetime, never the write path.** An `upsertEntity` onto an entity slot that
305
+ still carries an entity merges into it even when emptiness calls the slot empty (a
306
+ soft delete, a seed planted by `IOIMSlotStamp`): a column the new entity drops is
307
+ woken, an unchanged one is not, and an upsert that repeats the entity exactly wakes
308
+ nothing. Only an entity slot with no entity at all is filled whole.
309
+
310
+ **`IOIMSlotStamp`** writes onto the slot at every write, just before emptiness is
311
+ settled. It is a chain, not a single: each stamp takes the `next` one and calls
312
+ it first. It is for stamps that are not about release.
313
+
314
+ ### The release policy
315
+
316
+ ```ts signature
317
+ interface IOIMSlotReleasePolicy {
318
+ readonly releasesWrittenSlots: boolean;
319
+ readonly schedulesReleaseOnWrite: boolean;
320
+ onSlotWritten(slot: TOIMSlot): void;
321
+ onSlotIdle(slot: TOIMSlot): boolean;
322
+ onSlotUnheld(slot: TOIMSlot): void;
323
+ isSlotReleasable(slot: TOIMSlot): boolean;
324
+ isEmptySlotReleasable(slot: TOIMSlot): boolean;
325
+ cancelSlotRelease(slot: TOIMSlot): void;
326
+ }
327
+
328
+ new OIMSlotReleasePolicyAfterWrite({ keepAfterWriteMs: number })
329
+ new OIMSlotReleasePolicyAfterIdle({ keepAfterIdleMs: number })
330
+ new OIMSlotReleasePolicyAfterUnhold({ keepAfterUnholdMs: number })
331
+ OIMSlotReleasePolicyAtUnhold.INSTANCE
332
+ OIMSlotReleasePolicyAtEmpty.INSTANCE
333
+ OIMSlotReleasePolicyNever.INSTANCE
334
+ new OIMSlotReleasePolicyEarliest(slotReleasePolicies: readonly IOIMSlotReleasePolicy[])
335
+ ```
336
+
337
+ `releasesWrittenSlots` is whether the policy can ever release a slot that holds
338
+ data; a service reads it once at construction and skips the after-write
339
+ release check when it is false (`OIMSlotReleasePolicyAtEmpty`, `OIMSlotReleasePolicyNever`: false;
340
+ `OIMSlotReleasePolicyAtUnhold` and the timed policies: true; `OIMSlotReleasePolicyEarliest`: true
341
+ when any policy it combines is).
342
+
343
+ `schedulesReleaseOnWrite` is whether a write can make the policy schedule a release; a service
344
+ reads it once at construction and, when it is false and no `slotStamp` is set, skips `onSlotWritten`
345
+ and the first-fill `onSlotIdle` on every write (`OIMSlotReleasePolicyAfterWrite`, `OIMSlotReleasePolicyAfterIdle`:
346
+ true; `OIMSlotReleasePolicyAtEmpty`, `OIMSlotReleasePolicyNever`, `OIMSlotReleasePolicyAtUnhold`,
347
+ `OIMSlotReleasePolicyAfterUnhold`: false; `OIMSlotReleasePolicyEarliest`: true when any policy it combines is).
348
+ `onSlotIdle` is still called when the last subscriber leaves.
349
+
350
+ A policy decides whether and when a slot goes; it does no work itself. A
351
+ factory, and `OIMDB` (whose `slotReleasePolicy` option every factory reads as its default), takes ONE
352
+ through the option `slotReleasePolicy`. With none, the slot owner is built with
353
+ `OIMSlotReleasePolicyAtEmpty`, so a slot with a value stays for good and an empty
354
+ one with nothing holding it goes; there is no "no policy" branch on any path.
355
+ Computed services default to `OIMSlotReleasePolicyAtUnhold`.
356
+
357
+ | Policy | A slot goes |
358
+ |---|---|
359
+ | `OIMSlotReleasePolicyAfterWrite` | once its last write is older than `keepAfterWriteMs` |
360
+ | `OIMSlotReleasePolicyAfterIdle` | once nothing has watched it for `keepAfterIdleMs` |
361
+ | `OIMSlotReleasePolicyAfterUnhold` | once `keepAfterUnholdMs` has passed since its LAST unhold; a hold taken again inside the window keeps it |
362
+ | `OIMSlotReleasePolicyAtUnhold` | at the last unhold, even with a value |
363
+ | `OIMSlotReleasePolicyAtEmpty` | once it has no value and nothing holds it; a computed, never empty, stays while its owner lives |
364
+ | `OIMSlotReleasePolicyNever` | never, empty or not; a deleted key's slot stays filed |
365
+ | `OIMSlotReleasePolicyEarliest` | when the earliest deadline of its members falls due — a `min` over deadlines |
366
+
367
+ The collectors' state lives on the db's collector slot, one per `OIMDB`:
368
+ `db.collectorSlot.afterWriteReleaseSlotCollectorSlot`, `afterIdleReleaseSlotCollectorSlot` and
369
+ `afterUnholdReleaseSlotCollectorSlot`, swept by the stateless
370
+ `OIMReleaseSlotCollectorServiceAfterWrite.INSTANCE` / `OIMReleaseSlotCollectorServiceAfterIdle.INSTANCE` /
371
+ `OIMReleaseSlotCollectorServiceAfterUnhold.INSTANCE`. The
372
+ clock and the timer are the db's: pass `new OIMDB({ getNowMs, releaseTimer })` to control time (tests),
373
+ and sweep with `OIMReleaseSlotCollectorServiceAfterIdle.INSTANCE.flush(db.collectorSlot.afterIdleReleaseSlotCollectorSlot)`.
374
+ `OIMSlotReleasePolicyAtUnhold` is the zero window and has no collector.
375
+
376
+ A zero window never touches a release queue or a timer: when a slot goes idle with
377
+ `onSlotIdle` answering `true`, it is released at once if nothing holds it, and
378
+ a freshly minted slot that is already due goes to the unused-slot collector and
379
+ leaves at the end of the frame. A window above zero is filed in that policy's own
380
+ queue.
381
+
382
+ ```ts
383
+ import {
384
+ OIMSlotReleasePolicyAfterIdle,
385
+ OIMSlotReleasePolicyAfterWrite,
386
+ OIMSlotReleasePolicyEarliest,
387
+ } from '@oimdb/core';
388
+
389
+ const releasingDb = new OIMDB({
390
+ slotReleasePolicy: new OIMSlotReleasePolicyEarliest([
391
+ new OIMSlotReleasePolicyAfterWrite({ keepAfterWriteMs: 60_000 }),
392
+ new OIMSlotReleasePolicyAfterIdle({ keepAfterIdleMs: 30_000 }),
393
+ ]),
394
+ });
395
+ ```
396
+
397
+ ### Two release markers, one per policy
398
+
399
+ `TOIMSlot` carries TWO deadlines, and each release policy moves its own and
400
+ no other:
401
+
402
+ | Marker | Moved by | Not moved by |
403
+ |---|---|---|
404
+ | `releaseDueAfterWriteMs` | every write, under `OIMSlotReleasePolicyAfterWrite` | a subscriber arriving or leaving |
405
+ | `releaseDueAfterIdleMs` | the first content of a slot with no subscriber, and the departure of the last subscriber, under `OIMSlotReleasePolicyAfterIdle` | a later write, and the give-back of a hold |
406
+ | `releaseDueAfterUnholdMs` | every last unhold, under `OIMSlotReleasePolicyAfterUnhold` | a write |
407
+
408
+ `-1` means unset. A slot goes on the SOONEST of those that are set — each
409
+ marker is an eviction deadline, so whichever says "out" first decides — and a
410
+ hold outranks both, so a subscribed entity slot is never taken for age and goes at the
411
+ give-back.
412
+
413
+ **ONE NUMBER CANNOT CARRY BOTH POLICIES.** A single field cannot say which event
414
+ is allowed to move it: a write arriving after the subscriber left would push the
415
+ deadline out and destroy the idle policy's verdict, and the last subscriber
416
+ leaving after a write would destroy the write policy's. Both directions are
417
+ pinned by
418
+ `engine/collector/tests/which-release-marker-each-collector-may-move.test.ts`;
419
+ collapse the two markers into one and six of its nine cases go red.
420
+
421
+ **The release policy's `isSlotReleasable(slot)`** is what the release guard asks
422
+ for a slot that is not empty and has nothing left keeping it. `true` releases it
423
+ now; `false` keeps it, and a policy with a finite deadline has itself filed the
424
+ slot into its release queue (a marker of `-1` is kept with nothing scheduled).
425
+ An **empty** slot is released without asking, and a service given no release
426
+ policy keeps a non-empty slot. `OIMComputedService` defaults its policy to
427
+ `OIMSlotReleasePolicyAtUnhold`, which always answers `true`, so a derivation
428
+ nothing refers to goes at once. `OIMSlotReleasePolicyEarliest` asks every member
429
+ and answers `true` if any does.
430
+
431
+ **AN IDLE WINDOW OPENS IN EXACTLY TWO PLACES, AND NOWHERE ELSE:**
432
+
433
+ 1. **when the slot first gets content with no subscriber** — the write that takes it
434
+ from empty to filled, in `settleEmptiness`. A slot minted empty (a `getSlotByKey`
435
+ miss) is on NO clock and costs the queue nothing;
436
+ 2. **when the LAST SUBSCRIBER leaves** — `OIMSlotOwner.unsubscribeFromSlot` empties
437
+ `slot.effects` and runs the idle step, which opens one.
438
+
439
+ Both ask the policy's `onSlotIdle(slot)`; under `OIMSlotReleasePolicyAfterIdle`
440
+ it stamps `releaseDueAfterIdleMs` and files the slot into the policy's queue. An
441
+ **empty** slot gets no window, and the release guard takes it without asking a
442
+ clock at all.
443
+
444
+ **A GIVE-BACK OPENS NONE.** `OIMSlotOwner.unholdSlot` drops `holdCount` and runs
445
+ `releaseSlotIfUnheld`; it stamps no marker. A slot whose window already ran out is
446
+ therefore taken at the give-back and not one window later.
447
+
448
+ **A HELD SLOT IS OFF THE CLOCK.** A sweep that meets one calls
449
+ `releaseSlotIfUnheld`, which stops at its first guard, `holdCount > 0`, and decides
450
+ nothing — and the queue has taken the slot off its slice, so nothing re-files it.
451
+ The give-back is where the guard is asked again.
452
+
453
+ ### The two release collectors
454
+
455
+ Each of `OIMSlotReleasePolicyAfterWrite`, `OIMSlotReleasePolicyAfterIdle` and
456
+ `OIMSlotReleasePolicyAfterUnhold` is swept by its own stateless collector service, and the difference
457
+ between them is WHICH MARKER it reads — there is no flag, and no branch on the sweep path. Their state is
458
+ one release collector slot each, on the db's collector slot.
459
+
460
+ ```ts signature
461
+ abstract class OIMReleaseSlotCollectorService {
462
+ readonly getReleaseDueMs: (slot: TOIMSlot) => number;
463
+ getReleaseSlotCollectorSlot(collectorSlot: TOIMCollectorSlot): TOIMReleaseSlotCollectorSlot;
464
+ getReleaseSlotCollectorSlotOf(slot: TOIMSlot): TOIMReleaseSlotCollectorSlot;
465
+ markSlotReleaseDue(releaseSlotCollectorSlot, slot, keepMs): number; // stamps now + keepMs on this marker, returns now
466
+ isSlotReleasable(releaseSlotCollectorSlot, slot): boolean; // due now; if not yet, files it on the queue
467
+ cancelSlotRelease(releaseSlotCollectorSlot, slot): void; // no-op for a slot whose marker is -1
468
+ flush(releaseSlotCollectorSlot): void;
469
+ cancelAllSlotReleases(releaseSlotCollectorSlot): void;
470
+ }
471
+ OIMReleaseSlotCollectorServiceAfterIdle.INSTANCE
472
+ OIMReleaseSlotCollectorServiceAfterWrite.INSTANCE
473
+ OIMReleaseSlotCollectorServiceAfterUnhold.INSTANCE
474
+
475
+ type TOIMCollectorSlot = {
476
+ readonly unusedSlotCollectorSlot: TOIMUnusedSlotCollectorSlot;
477
+ readonly afterIdleReleaseSlotCollectorSlot: TOIMReleaseSlotCollectorSlot;
478
+ readonly afterWriteReleaseSlotCollectorSlot: TOIMReleaseSlotCollectorSlot;
479
+ readonly afterUnholdReleaseSlotCollectorSlot: TOIMReleaseSlotCollectorSlot;
480
+ };
481
+ ```
482
+
483
+ The clock (`getNowMs`, default `Date.now`), the timer (`releaseTimer`, default an unref'd `setTimeout`)
484
+ and the `errorPolicy` (default `OIMErrorPolicyLogAndContinue.INSTANCE`) are `OIMDB` options, shared by the
485
+ channel and every release collector of that db. A release that throws inside a hand-called `flush` throws to
486
+ that caller; one that throws on the collector's own timer tick has no caller and goes to the db's
487
+ `errorPolicy`. So one error policy on the db routes every error through one place:
488
+
489
+ ```ts
490
+ import { OIMErrorPolicyThrowAtOnce, OIMSlotReleasePolicyAfterIdle } from '@oimdb/core';
491
+
492
+ const oneSinkDb = new OIMDB({
493
+ errorPolicy: new OIMErrorPolicyThrowAtOnce(),
494
+ slotReleasePolicy: new OIMSlotReleasePolicyAfterIdle({ keepAfterIdleMs: 30_000 }),
495
+ });
496
+ ```
497
+
498
+ | Collector service | Reads | What renews it | What does not |
499
+ |---|---|---|---|
500
+ | `OIMReleaseSlotCollectorServiceAfterIdle` | `releaseDueAfterIdleMs` | the first content of a slot, and the give-back of the last hold | a later write |
501
+ | `OIMReleaseSlotCollectorServiceAfterWrite` | `releaseDueAfterWriteMs` | every write, under its policy | a subscriber arriving or leaving |
502
+ | `OIMReleaseSlotCollectorServiceAfterUnhold` | `releaseDueAfterUnholdMs` | every last unhold | a hold taken again inside the window |
503
+
504
+ **A CONSUMER MAY RUN BOTH, AND THEY SHARE NOTHING.** Each has its own release collector slot with its own
505
+ queue and its own scheduled run. Whichever deadline falls due first takes the slot, and
506
+ nothing has to be reconciled: `OIMSlotReleasePolicyEarliest` answers a `min`
507
+ over its members' deadlines, so a sweep of either queue reaches the same verdict.
508
+
509
+ **NEITHER MEANS UNREAD.** No read is seen and no door reports one: an identity a
510
+ consumer subscribed to is in use whatever else is true of it. An effect takes a
511
+ HOLD on each root it was given — `OIMEffectService.INSTANCE.createEffect` calls
512
+ `root.owner.subscribeToSlot(root, …)`, which holds, for every slot in `sourceSlots` and every computed
513
+ source — so the hold is the whole signal, and neither collector reads
514
+ `slot.effects`.
515
+
516
+ **The deadline is only consulted on a slot that is NOT empty.**
517
+ The slot owner's `releaseSlotIfUnheld` checks `holdCount > 0`, then
518
+ `oimIsSlotReleased(slot)`, then `slot.owner !== this`, then `hasReference(slot)`, and
519
+ only then — on a slot that is not empty — asks the policy's `isSlotReleasable`; an empty slot
520
+ asks `isEmptySlotReleasable` instead, which every policy but
521
+ `OIMSlotReleasePolicyNever` answers `true`. So only `OIMSlotReleasePolicyNever`
522
+ keeps an identity that holds nothing.
523
+
524
+ **A release that falls inside a flush waits for it to end.** Releasing a slot
525
+ with a value clears that value, which is a write, and every service
526
+ refuses writes for the whole of a flush of its channel (`channelSlot.isFlushing`; refusal is per channel). So when the
527
+ last hold goes during a frame — an effect body disposing another effect, say — the
528
+ slot is handed to the unused-slot collector instead, which holds it and gives it
529
+ back once the flush is over; the release then runs. An empty slot needs no write
530
+ and goes at once.
531
+
532
+ *The `owner !== this` line costs no lookup. A KEYLESS slot — a single's, filed in an
533
+ `OIMSlotStoreServiceSingleSlotDriven` store — goes through the same guard under its service's policy: an
534
+ emptied single bucket nothing holds is released like a keyed one, and the single service's
535
+ `getSingleSlot(storeSlot)` mints a fresh slot on the next read.*
536
+
537
+ ### Two consequences to generate code against
538
+
539
+ **(a) An entity slot watched through an expired sweep gets a WHOLE window when the
540
+ subscriber leaves, not an immediate release.** The seconds it spent watched were
541
+ not idle ones, so `dispose` on a slot whose deadline passed long ago stamps a
542
+ whole idle window afresh on `releaseDueAfterIdleMs` and the release comes
543
+ one whole window later. **A give-back is NOT that moment**: `unholdSlot` stamps
544
+ nothing, so a slot held through an expired sweep goes at the give-back.
545
+
546
+ ```ts
547
+ const effect = effectService.createEffect(db, [slot], [], () => { paint(slot.entity); });
548
+ // …the window passes, sweeps run, nothing happens to this slot…
549
+ effectService.disposeEffect(effect);
550
+ oimIsSlotReleased(slot); // false — a whole new window starts here
551
+ ```
552
+
553
+ **(b) A cascade is taken in ONE sweep, not one window per level.** The sweep
554
+ releases the stale index, which gives back one reference per entity slot it named, and
555
+ each entity slot whose own window already ran out is taken by the ordinary guard the
556
+ instant that reference departs.
557
+
558
+ Pinned by `engine/collector/tests/what-a-stale-index-takes-with-it.test.ts`,
559
+ `…/what-an-identity-nobody-subscribed-to-costs-after-a-while.test.ts` and
560
+ `…/what-a-subscribed-row-costs-the-release-clock.test.ts`.
561
+
562
+ ### The release guard, and who fires it
563
+
564
+ **THE QUEUE DECIDES AT A DEADLINE, NOT THE OWNER.**
565
+ `OIMReleaseSlotQueueService.INSTANCE.releaseDueUpToSliceNumber`, per slot in a slice at or below the one due:
566
+
567
+ 1. `oimIsSlotReleased(slot)` → skip it;
568
+ 2. its own marker, read through `getReleaseDueMs(slot)` — the ONE field this queue
569
+ answers for;
570
+ 3. `-1` → the slot is dropped from the queue: this policy does not claim it;
571
+ 4. a moment still in the future → the queue **re-files the slot itself**, into
572
+ `oimSliceOfMs(getReleaseDueMs)` through its own slice arithmetic;
573
+ 5. a moment already passed → `slot.owner.releaseSlotIfUnheld(slot)`, which asks the
574
+ policy's `isSlotReleasable` and so declines when the other policy still keeps
575
+ the slot.
576
+
577
+ The owner files nothing from inside a sweep. It **opens** a window on its own
578
+ events, through the policy's `onSlotIdle` — when a slot first gets content, and
579
+ when the last subscriber leaves — and `onSlotUnheld` on the give-back of the last hold.
580
+
581
+ **A SLOT ALREADY ON A QUEUE STAYS WHERE IT IS.** `scheduleSlotRelease` of a slot filed
582
+ at a slice no later than its new deadline moves nothing: the sweep re-reads the
583
+ deadline when that slice comes due and re-files the slot then (the timing-wheel
584
+ lazy re-insertion). So a write to a queued entity slot costs the queue one `Map.get`, not
585
+ an unfile and a refile.
586
+
587
+ `releaseSlotIfUnheld(slot)` is the guard, and it releases only when all of
588
+ `holdCount === 0`, `oimIsSlotReleased(slot)` is false, `slot.owner === this`,
589
+ `hasReference(slot)` is false, and the policy's `isSlotReleasable` answers `true`
590
+ (or the slot is empty). **A held slot stops at the first of those** and nothing
591
+ happens to it. On a slot that is not empty with time still owed, `isSlotReleasable`
592
+ answers `false` and the policy files the slot into its queue at its own marker, so a
593
+ filing can neither shorten nor extend a deadline. That is the path from `unholdSlot` and from a write, never from a
594
+ sweep, which had already re-filed at step 4. Releasing hands the slot to the
595
+ content strategy's `releaseSlotContent` (which gives back what the content held),
596
+ marks the slot empty, calls the policy's `cancelSlotRelease(slot)`, unfiles the key
597
+ through the store service's `remove`, and sets the RELEASED flag in `slot.slotFlags`. The `owner` is NOT cleared — the slot keeps
598
+ pointing at the object that let it go, which is what makes `slot.owner !== this`
599
+ a usable guard afterwards.
600
+
601
+ The RELEASED flag (`oimIsSlotReleased(slot)`) is the other half of that protocol, and the two sides are
602
+ opposite:
603
+
604
+ - **Releasing an already-released slot is SILENT.** `releaseSlotIfUnheld` reads
605
+ `oimIsSlotReleased(slot)` and returns; it does not throw. A sweep meeting a slot
606
+ something else already let go must not raise.
607
+ - **Trying to WORK with a released slot throws by name.** `assertNotReleased`
608
+ guards the content services' write doors (`writeEntity`, `deleteEntity`, `updateEntity`, `markEntityChanged`,
609
+ `writeBucketMemberSlots`, `clearBucketMemberSlots`, `markContentChanged`) and the reference doors (`holdSlot`, `unholdSlot`, `subscribeToSlot`,
610
+ `unsubscribeFromSlot`). The refusal message starts `This identity was released:`.
611
+
612
+ ### The collectors
613
+
614
+ ```ts signature
615
+ OIMReleaseSlotCollectorServiceAfterWrite.INSTANCE // and …AfterIdle, …AfterUnhold
616
+ OIMReleaseSlotQueueService.INSTANCE
617
+ OIMUnusedSlotCollectorService.INSTANCE
618
+ createOIMUnusedSlotCollectorSlot(): TOIMUnusedSlotCollectorSlot
619
+ ```
620
+
621
+ All are stateless; each works on a slot handed to it. The release collector services extend
622
+ `OIMFlushSchedulerService`.
623
+
624
+ **A release collector service** is the clock. It sweeps the queue of the release collector slot it is
625
+ given on `flush(releaseSlotCollectorSlot)` and re-schedules itself to the next deadline.
626
+ `cancelAllSlotReleases(releaseSlotCollectorSlot)` cancels and empties the queue; the next scheduled release
627
+ schedules it again. The clock (`getNowMs`) and the timer come from the db, so a test drives them through
628
+ `new OIMDB({ getNowMs, releaseTimer })`. The default timer is a `setTimeout` that calls `unref`
629
+ where the platform has it, so an idle Node process is not held open.
630
+
631
+ **`OIMReleaseSlotQueueService`** files the slots of one release collector slot into **time slices**, not
632
+ into one entry each; the collector service's `getReleaseDueMs` is the ONE marker it answers for, which is
633
+ what makes two queues over two markers independent. `oimSliceOfMs(ms)` is `Math.floor(ms / OIM_SLICE_LENGTH_MS)` and
634
+ `OIM_SLICE_LENGTH_MS` is `1024`, so every slot due inside the same ~1s slice
635
+ shares one `Set` and one timer. Its doors, each taking the release collector slot first:
636
+
637
+ | Door | What it does |
638
+ |---|---|
639
+ | `scheduleSlotRelease(releaseSlotCollectorSlot, slot, …)` | read this queue's own marker off the slot and file it into that slice (a marker already past now files at now's slice), then schedule the collector. A marker of `-1` files nothing |
640
+ | `cancelSlotRelease(releaseSlotCollectorSlot, slot)` | take it off |
641
+ | `isSlotReleaseScheduled(releaseSlotCollectorSlot, slot)` | is it filed |
642
+ | `getNextReleaseSliceNumber(releaseSlotCollectorSlot)` | the nearest filed slice number, or `undefined` |
643
+ | `releaseDueUpToSliceNumber(releaseSlotCollectorSlot, …)` | the sweep: for every slice at or below the one due, read the marker and then drop it (`-1`), re-file it itself (still in the future), or call `slot.owner.releaseSlotIfUnheld(slot)` |
644
+ | `cancelAllSlotReleases(releaseSlotCollectorSlot)` | drop every filing |
645
+
646
+ A slot re-filed during a sweep cannot land in a slice the same sweep is still
647
+ walking: the sweep raises a floor for its duration, so a re-file — the queue's own
648
+ at step 4, or one `releaseSlotIfUnheld` makes — goes to the next slice and is looked
649
+ at next time, not in a loop.
650
+
651
+ **The unused-slot collector** has nothing to do with time. It is the one that makes
652
+ `getSlotByKey(storeSlot, key)` safe: `OIMUnusedSlotCollectorService.INSTANCE.holdSlotUntilFlush(unusedSlotCollectorSlot, slot)`
653
+ takes a hold, `flush(unusedSlotCollectorSlot)` gives every tracked hold back. `open` / `close` nest a scope and
654
+ `run(unusedSlotCollectorSlot, body)` is the `try/finally` form; `flush` does nothing while a scope is
655
+ open, so an outer scope's slots survive an inner one closing. `close` with no open scope throws by name. The
656
+ channel flush flushes the db's unused-slot collector slot (`db.collectorSlot.unusedSlotCollectorSlot`) once the
657
+ frame is delivered, which is why a turn of work that only read through `getSlotByKey` leaves nothing
658
+ standing after `db.flush()`.
659
+
660
+ **AN ARENA IS AN UNUSED-SLOT COLLECTOR SLOT THAT THE CALLER CREATES.** `createOIMUnusedSlotCollectorSlot()`
661
+ is exported, so a consumer who wants to FORCE-CACHE a derivation makes its own and ends it when it chooses.
662
+ The db's collector slot is the default, not the only one:
663
+
664
+ ```ts
665
+ const unusedSlotCollectorSlot = createOIMUnusedSlotCollectorSlot();
666
+ OIMUnusedSlotCollectorService.INSTANCE.holdSlotUntilFlush(
667
+ unusedSlotCollectorSlot, rankService.computedCollectionService.getSlotByKey(ranks, 'c1'),
668
+ ); // held until this arena says otherwise
669
+ db.flush(); // the db's collector lets its own go; this one does not
670
+ // … the derivation is still there, and still the same object …
671
+ OIMUnusedSlotCollectorService.INSTANCE.flush(unusedSlotCollectorSlot); // now it goes, and so does everything only it held
672
+ ```
673
+
674
+ **TRACKING ONE DERIVATION KEEPS ITS WHOLE SUBTREE, and no caller walks a tree to
675
+ get that.** A computed takes a hold on each of its own direct sources at the mint
676
+ and gives them back when it is released, so an arena holds the top and the chain holds itself.
677
+
678
+ Pinned by `engine/collector/tests/what-a-named-arena-keeps-cached.test.ts`: the
679
+ derivation survives a settle, is the SAME instance on the next read (built once),
680
+ is rebuilt only after the arena is flushed, and `open`/`close` nest so an inner
681
+ scope ending drops nothing an outer one stands on.
682
+
683
+ `@oimdb/react`'s `useOIMSlotsByKeys(slotCollectionService, storeSlot, keys)` does not use an arena: it takes a
684
+ plain `holdSlotByKey` per key on commit and gives the same holds back on unmount or when the key list moves.
685
+
686
+ ```ts signature
687
+ type TOIMTimer = (run: () => void, inMs: number) => TOIMDisposer;
688
+ ```
689
+
690
+ `TOIMTimer` is how a host schedules a collector's run. It is given the body
691
+ and a delay and hands back the cancel. `OIMFlushSchedulerService` is the abstract base
692
+ that owns the schedule/cancel around it, on a `TOIMFlushSchedulerSlot` (`timer`, `scheduledCallback`,
693
+ `timerDisposer`): protected `schedule(flushSchedulerSlot, delayMs)` does
694
+ nothing if a scheduled run is already standing, and `cancelSchedule(flushSchedulerSlot)` drops it. **The
695
+ standing run is the callback itself**: the slot keeps the one handed to the
696
+ timer, and a callback that is no longer that one — a run the host could not
697
+ cancel, still queued after a manual flush — returns without touching anything, so
698
+ it neither flushes early nor lets the next request arm a second wake. The channel
699
+ schedules with `0`. The release collectors read the clock once, schedule to the
700
+ next slice, and cancel and re-schedule only when a shorter window arrives; the
701
+ deadline they compare against is reset by `cancelSchedule`.
702
+ Pass a `TOIMTimer` of your own to drive a collector off a
703
+ frame callback, a worker message or a test clock instead of `setTimeout`.
704
+
705
+ ### A fork is a strategy, not a decision
706
+
707
+ Both strategies ship, chosen at **construction**, so no branch survives onto a hot
708
+ path. There is no `if (inPlace)` anywhere in the engine.
709
+
710
+ | Fork | Strategies |
711
+ |---|---|
712
+ | how a write lands | `createOIMEntityWriterInPlace()` / `createOIMEntityWriterReplacing()` |
713
+ | how a bucket holds slots | `OIMSlotContentBucketServiceSetDriven` / `OIMSlotContentBucketServiceArrayDriven` (the positions are the caller's) |
714
+ | what naming a slot records | `OIMBucketArrayMembershipNonUnique` / `OIMBucketArrayMembershipUnique`, the `bucketArrayMembership` option of `OIMSlotContentBucketServiceArrayDriven` |
715
+ | which writes re-sort a sorted list | `sortColumns` (only a write changing one of them on a member) / `comparator` alone (any member write), the options of `createOIMComputedServiceSorted` |
716
+ | how a key is addressed | `OIMDefaults.KEY_TREE_WALK_SCALAR` / `OIMDefaults.KEY_TREE_WALK_COMPOSITE` |
717
+ | when a slot may go | `OIMSlotReleasePolicyAfterIdle` / `OIMSlotReleasePolicyAfterWrite`, either or both |
718
+ | who decides a bucket's slots | a bucket collection (`createOIMBucketService`, the caller) / an index (`createOIMIndexService`, filed from the entity's own key) |
719
+
720
+ **One strategy per kind per app.** A V8 call site is shared by every service
721
+ that runs the same engine code, so mixing strategies of one kind in one process
722
+ shows that site several hidden classes or functions. Up to four is polymorphic:
723
+ V8 still caches each one and can still inline
724
+ (`--max-valid-polymorphic-map-count`, default 4). Five or more is megamorphic: no
725
+ per-site cache and no inlining through it. The kinds that count: release policies
726
+ (the `IOIMSlotReleasePolicy` strategies), set-driven vs array-driven buckets and indexes, entity
727
+ writers (`createOIMEntityWriterInPlace` / `createOIMEntityWriterReplacing`) and
728
+ `TOIMEntityInsertStrategy`, `TOIMSameValueGuard`, `IOIMSlotEmptiness`, and
729
+ `TOIMChannelScheduler`. Two or three of one kind is fine; five release policies in
730
+ one process is not. The pair
731
+ `alt.slot-shape.three-shapes-against-one-at-the-write` measures three slot shapes
732
+ at one write site against one, and its ratio is close to 1; no pair measures five.
733
+ Generate code with one strategy per kind unless the task names a second.
734
+
735
+ ### Every store slot has a channel — read this before generating code
736
+
737
+ `createStoreSlot(db)` stamps the db's `defaultChannelSlot` on the store slot, and every write to its slots
738
+ is reported to that channel. No service holds a channel; there is no `channel` option and no
739
+ `unusedSlotCollector` option. The base layer and the convenience wrapper build the same thing:
740
+
741
+ ```ts
742
+ const cardContentService = createOIMSlotContentEntityService<Card>(db, { keyOf: (c) => c.id });
743
+ const handBuiltCardService = createOIMSlotCollectionService({ slotContentService: cardContentService });
744
+ handBuiltCardService.createStoreSlot(db);
745
+
746
+ // THE CONVENIENCE WRAPPER — the same two calls in one:
747
+ createOIMEntityService<Card>(db, { keyOf: (c) => c.id }).createStoreSlot(db);
748
+ ```
749
+
750
+ A single entity is `createOIMSingleEntityService<T>(db)` (or `createOIMSingleSlotService` over
751
+ `createOIMSlotContentEntityService(db, { slotStoreService: OIMSlotStoreServiceSingleSlotDriven.INSTANCE })`),
752
+ and its store slot takes the db's channel too and always reports.
753
+
754
+ ### A bucket's slots are the caller's
755
+
756
+ A bucket's slots are a fact about the **response** being composed — a search result, a
757
+ page, a window — and **no column can express absence**. So a bucket collection
758
+ is manual, and `entityService.slotContentService.deleteEntity(slot)` does not take a slot out of any
759
+ bucket. Use an index (`createOIMIndexService`) when the slots are determined by the entity's key value,
760
+ and an index should maintain buckets only for keys being held.
761
+
762
+ ---
763
+
764
+ ## The runtime: the channel slot
765
+
766
+ There is no channel class. A channel is the struct `TOIMChannelSlot`; every operation is on the stateless
767
+ `OIMChannelService.INSTANCE` and takes the channel slot first. An `OIMDB` builds its `defaultChannelSlot`
768
+ from its own options, and every store slot created with `createStoreSlot(db)` carries it as
769
+ `storeSlot.channelSlot`. A test that needs a second, independent channel makes a second `new OIMDB()`.
770
+
771
+ ```ts signature
772
+ new OIMDB(options?: TOIMDBOptions)
773
+
774
+ type TOIMDBOptions = {
775
+ channelScheduler?: TOIMChannelScheduler; // absent = you flush by hand
776
+ errorPolicy?: IOIMErrorPolicy; // absent = OIMErrorPolicyLogAndContinue.INSTANCE
777
+ onBeforeFlush?: () => void; // start of the flush, before the frame; READ ONLY, a write is refused
778
+ getNowMs?: () => number; // the release collectors' clock; absent = Date.now
779
+ releaseTimer?: TOIMTimer; // the release collectors' timer; absent = an unref'd setTimeout
780
+ entityWriter?: IOIMEntityWriter; // default entity writer for every entity factory
781
+ indexKeysService?: IOIMIndexKeysService;
782
+ slotStamp?: IOIMSlotStamp<TOIMSlot>;
783
+ slotReleasePolicy?: IOIMSlotReleasePolicy; // default release policy for every factory
784
+ };
785
+
786
+ db.clock // TOIMChannelClock, the same object as db.defaultChannelSlot.clock
787
+ db.collectorSlot // TOIMCollectorSlot
788
+ db.defaultChannelSlot // TOIMChannelSlot
789
+ db.flush() // OIMChannelService.INSTANCE.flush(db.defaultChannelSlot)
790
+
791
+ interface IOIMErrorPolicy { onErrorCaught(error: unknown): void }
792
+ OIMErrorPolicyLogAndContinue.INSTANCE // console.error; the flush goes on
793
+ new OIMErrorPolicyLogAndContinue({ onError?: (error: unknown) => void }) // console.error, then onError
794
+ new OIMErrorPolicyThrowAtOnce() // throws the first error out of the flush
795
+ ```
796
+
797
+ **What the error policy covers** — every error with no synchronous caller to
798
+ throw to: effect bodies and their cleanups, `onBeforeFlush`, after-each-frame and
799
+ after-each-flush handlers, the unused-slot sweep at the end of a flush, and a
800
+ release collector's own timer tick. A call that has a caller — a write, a
801
+ hand-called collector `flush` — throws to it. `channelSlot.errorPolicy` is the
802
+ instance the channel reports through; it is the db's `errorPolicy`, which the release collectors share,
803
+ so one policy answers for the whole database. **The default digests and logs**: each throw goes to `console.error`
804
+ and then to `onError` (where Sentry or the devtools registry plugs in), and the
805
+ flush finishes every other effect and handler. `OIMErrorPolicyThrowAtOnce` throws
806
+ the first error out of `flush()` at once; the effects the frame had not reached yet
807
+ stay queued and run at the next flush. **An effect whose body or cleanup threw
808
+ has not taken the change it woke for**: its `ranAtRevision`/`ranAtEpoch` are
809
+ written only after the body returns, so its next wake — any later write to its
810
+ roots, even one through a computed that settles to the same value — sees that
811
+ change as still new and runs it again. It is never re-run inside the frame it
812
+ threw in (each pending effect runs once per frame, and writes are refused during
813
+ a flush), so a body that always throws costs one run and one caught error per
814
+ wake, not a loop. A cleanup that disposes its own effect stops the body that would
815
+ have run after it. **A computed is stamped fresh only after its derive and its
816
+ `sameValueGuard` both returned**: if either threw, the next read derives again.
817
+ A computed with no sources at all derives on its first read. **A computed whose
818
+ value changed is stamped with the revision of its newest source**, not with the
819
+ clock at the moment it was read — so whether an effect over it owes a run depends
820
+ only on its sources, never on an unrelated write that moved the clock. An effect
821
+ born after a computed's source changed is owed nothing for it, as one over the
822
+ root itself. **A throw does not
823
+ stop the channel asking for frames**: the
824
+ request flag is cleared before `onBeforeFlush` runs, and a write that finds
825
+ effects still pending with no frame requested asks for one — so a frame aborted by
826
+ a throw is followed by a frame at the next write, under either policy. **A throw in
827
+ one release of a sweep leaves the rest of that slice on the release clock**; the
828
+ next sweep takes them, and the clock is re-armed even when the sweep threw. Code
829
+ that maintains DERIVED state — an index's `keyOf`, an emptiness check — is not
830
+ under the policy: a throw there leaves the state consistent and is thrown in
831
+ either mode. **An index whose `keyOf` throws on an entity files that entity slot under no
832
+ key** — it leaves the bucket its old key named — and finishes the rest of the write
833
+ or batch before it rethrows the first error; the entity stays in its store slot, and
834
+ the next write the index accepts files it again. A NEW entity refused by a single
835
+ write is withdrawn from the store slot instead. This costs nothing on a write
836
+ where nothing throws. **A bucket minted by a scan of the source moves an entity slot out
837
+ of the bucket its stored key still names** — an entity mutated by hand whose
838
+ `markEntityChanged` has not run yet — so it ends in one bucket, not two; the scan
839
+ pays one read of the stored key per entity slot it files, and the move keeps the entity slot held
840
+ until the next flush so its last hold never goes mid-mint. Inside a flush, where
841
+ writes are refused, the move is skipped.
842
+
843
+ Every door below is on `OIMChannelService.INSTANCE` and takes the channel slot first.
844
+
845
+ | Door | Signature |
846
+ |---|---|
847
+ | `flush(channelSlot)` | `void` — the only place anything is delivered; from the TAIL of a flush it returns having done nothing. `db.flush()` is this on the default channel |
848
+ | `scheduleEffectRun(channelSlot, effect)` | `void` — puts the effect on this channel's queue for the next frame, once (the RUN_SCHEDULED bit of `effect.effectFlags` says whether it is there), sets the SOURCE_CHECK_OWED bit (`OIM_EFFECT_FLAG_SOURCE_CHECK_OWED`) and asks for a flush. Effects are made by `OIMEffectService.INSTANCE`, not here; `createEffect` calls this when the open batch already has writes and no frame is running, so an effect created inside a running frame is never queued |
849
+ | `subscribeAfterEachFrame(channelSlot, handler)` | `TOIMDisposer` — after every frame, but still INSIDE the flush: a write from it is refused |
850
+ | `subscribeOnceAfterFrame(channelSlot, handler)` | `TOIMDisposer` — the same phase, told **once**; see below |
851
+ | `subscribeAfterEachFlush(channelSlot, handler)` | `TOIMDisposer` — told once the flush has FULLY ended: after the after-each-frame phase and after the unused-slot collector of the db is flushed. For what may live only one frame — `OIMBucketCommands` empties its render scripts here |
852
+ | `isRefusingWrites(channelSlot)` | `boolean` — `channelSlot.isFlushing`; refusal is per channel |
853
+ | `assertNotFlushing(channelSlot)` | `void` — throws if the channel is flushing or its frame is running, i.e. for the whole of a flush |
854
+ | `write(channelSlot, slot)` | `void` — asks `assertNotFlushing`, then marks and queues |
855
+ | `writeWithoutFlushCheck(channelSlot, slot)` | `void` — the same marking and queueing WITHOUT the guard, for a door that already refused. Who may call it is pinned by `tests/who-may-write-without-asking-the-channel.test.ts`; a caller not on that list fails the suite |
856
+ | `openSubframe(channelSlot)` | `void` — a subframe opens, and the frame cannot close until it closes — `flush` throws meanwhile |
857
+ | `closeSubframe(channelSlot)` | `void` — it closes, delivering what was listed for its close and then asking for the frame it owes |
858
+ | `isSubframeOpen(channelSlot)` | `boolean` — whether a subframe is open right now, so a caller can defer work to its close instead of per write |
859
+ | `listChangedStoreSlot(channelSlot, storeSlot, subframeCloseListener)` | `void` — list a store slot whose changes are delivered when the OUTERMOST subframe closes, by calling `subframeCloseListener.onStoreSlotSubframeClosed(storeSlot)`; see below |
860
+ | `dropChannelSlot(channelSlot)` | `void` — kill the channel whole; see below |
861
+ | `isChangedInCurrentFrame(channelSlot, slot)` | `boolean` — whether the slot was written after the last frame ended, i.e. in the frame being collected now; compares `slot.changedAtRevision`/`changedAtEpoch` with the clock reading the last frame closed at. `OIMBucketCommands` keeps a bucket's render script across a flush only while this is true |
862
+
863
+ The channel slot's own fields that a caller reads: `frame` (`TOIMChannelFrame`, `{ isRunning, openedAtRevision,
864
+ openedAtEpoch, … }`; a frame carries no number, the clock reading it opened at is its boundary), `clock`
865
+ (`TOIMChannelClock`, the `{revision, epoch}` pair, shared by every channel of one db; an epoch holds
866
+ `OIM_REVISIONS_PER_EPOCH` (2^30) revisions, so the counter stays a V8 small integer under pointer compression),
867
+ `collectorSlot`, `errorPolicy`, `isFlushing`. Everything else on it is engine state.
868
+
869
+ ### A subscription hears only what happens after it
870
+
871
+ `effectService.createEffect` is told about changes made **after** the call and about nothing
872
+ before them. This holds for every kind of source — an entity slot, a column, a
873
+ bucket, a computed — and a batch does not soften it: writes accumulate until
874
+ `flush()`, and subscribing behind a write of the still-open batch is subscribing
875
+ after it.
876
+
877
+ ```ts
878
+ cardService.slotContentService.upsertEntity(cards, { id: 'a', title: 'A', rank: 1, deck: 'spades' });
879
+ const entitySlot = cardService.getSlotByKey(cards, 'a');
880
+
881
+ effectService.createEffect(db, [entitySlot], [], () => { /* NEVER runs for the upsert above */ });
882
+ db.flush();
883
+
884
+ // A first value is `createEffectImmediate`'s job, not `createEffect`'s:
885
+ effectService.createEffectImmediate(db, [entitySlot], [], () => { paint(entitySlot.entity); }); // runs now
886
+ ```
887
+
888
+ A later write in that same batch IS delivered — "after I subscribed" is decided
889
+ by the revision (`clock.revision` advances on every write), not by the frame
890
+ number, which is one number for the whole batch and cannot separate the two.
891
+
892
+ ### `subscribeOnceAfterFrame(channelSlot, handler)`
893
+
894
+ Told after **one** frame — the first that ends after the call — and then gone.
895
+ The place that asks decides which frame that is.
896
+
897
+ ```ts
898
+ effectService.createEffect(db, [slot], [], () => {
899
+ paint(slot.entity);
900
+ // the tail of THIS frame, after every other effect of it
901
+ OIMChannelService.INSTANCE.subscribeOnceAfterFrame(channelSlot, () => { measureAndScroll(); });
902
+ });
903
+ ```
904
+
905
+ - **Registered from inside an effect body it runs in the tail of that very
906
+ frame**, after every other effect of it. The after-each-frame phase settles who
907
+ it owes when the phase *begins*, and the drain is before that — so a handler
908
+ added from the drain falls on the "owed" side. That is the whole reason the
909
+ method exists: a consumer whose work needs the **settled** screen (a
910
+ measurement, a scroll to an item that moved, a size written back) asks from
911
+ inside its effect and is answered at the end of that same frame.
912
+ - Registered from the after-each-frame phase itself, it sits that frame out and
913
+ is told after the next one — the phase had already settled who it owed.
914
+ - **It is not `subscribeAfterEachFrame` plus a disposal.** The effect runs again
915
+ next frame and registers again, so a standing handler accumulates one per run:
916
+ `tests/a-handler-that-wants-one-frame-and-not-every-frame.test.ts` measures the
917
+ one-shot at 1, 2, 3 calls over three frames against the standing handler's 1,
918
+ 3, 6.
919
+ - It comes off **before** its body runs, so a handler that throws is not left
920
+ standing for the next frame; the throw is reported by the frame that ran it.
921
+ - One list, not two: it goes into the same set at the same phase, so one-shot and
922
+ standing handlers are called in registration order among each other.
923
+
924
+ ### `openSubframe(channelSlot)` / `closeSubframe(channelSlot)`
925
+
926
+ A **frame** is everything delivered together: the writes collected since the
927
+ last delivery, handed to effects and after-each-frame handlers in one go. A
928
+ **subframe** is one logical change inside it — a write that has begun and not yet
929
+ ended. **The frame cannot close while a subframe is open**, however many writes
930
+ ask for one: the debt is recorded and paid by the `closeSubframe` that brings the
931
+ count to zero. A subframe delivers nothing of its own; it only keeps the frame
932
+ open.
933
+
934
+ - **`flush()` throws while a subframe is open**, with a message containing
935
+ "a subframe is open": code a write runs (a `keyOf`, a content
936
+ listener) called `flush`, or the caller opened a subframe and has not closed it. Nothing is delivered and the write in progress stays whole; flush
937
+ after the write returns.
938
+ - Counted, not a flag: a subframe is routinely inside another one, and the inner
939
+ close delivers nothing.
940
+ - Not `assertNotFlushing` from the other side. That refuses a **write** made
941
+ while a frame closes; this refuses a **frame** asked for while a subframe is open.
942
+ - A subframe is bigger than any single write, which is why the span is the
943
+ service's and not the writer's: a write moves the entity's columns and *then*
944
+ settles emptiness, which is what puts the entity slot into the index's
945
+ bucket. Under a synchronous scheduler that gap used to flush — an auto-index
946
+ settled over a bucket the entity slot had not joined yet, stamped itself past the
947
+ entity slot's revision, and the entity was invisible to that index forever with nothing
948
+ thrown.
949
+ - `closeSubframe` is called from a `finally` by every opener, so a subframe that
950
+ threw part-way still closes and still delivers what it managed to write.
951
+ - **The span belongs to the DOOR, and exactly one door opens it.** The entity content service's
952
+ `writeEntity`, `deleteEntity`, `deleteEntities`, `fillEntities`, `updateEntity` and `markEntityChanged` each
953
+ open one — except a write that is exactly one channel write, which opens none — one write cannot be seen
954
+ half-done, and there is nobody to batch for: `updateEntity`/`updateEntityColumn` and `deleteEntity` when
955
+ the store slot has no content listener, no column of the entity is referenced and emptiness is not
956
+ recomputed on a column write; the entity writer's `fillEntity`, `putEntity`, `patchEntity`, `deleteColumns`
957
+ and `clearEntity` open none. One `upsertEntity` therefore opens one span, and one `insertEntities` opens one
958
+ however many entities it carries. The one exception is the content strategy's `releaseSlotContent`, which
959
+ keeps its own: `releaseSlotIfUnheld` reaches it when a release deadline has passed, and that call arrives
960
+ from the release queue through no door at all — so a removal opens two and an eviction opens one.
961
+ - Every write door of a content service is wrapped in the channel's `openSubframe` / `closeSubframe` on
962
+ `storeSlot.channelSlot`.
963
+ - **Every bucket door wakes the bucket the same way.** Each door of a bucket content service that changed
964
+ the bucket — `addBucketMemberSlot(s)`, `removeBucketMemberSlot(s)`,
965
+ `insertBucketMemberSlotAt`, `removeBucketMemberSlotAt` / `…Range`, both moves,
966
+ `writeBucketMemberSlots`, `clearBucketMemberSlots` — lists the bucket's store slot on the channel
967
+ (`listChangedStoreSlot`) and sets the bucket's EMPTY flag there and then; the outermost `closeSubframe`
968
+ stamps each changed bucket once, tells its release policy `onSlotWritten` once, and only then asks
969
+ whether it may go. So `hasSlotContentByKey` answers right inside an open batch, a
970
+ batch that mixes doors stamps the bucket once, a move renews a write deadline,
971
+ and a door that changed nothing stamps nothing and tells the policy nothing.
972
+ Commands are still recorded at the door, because a position is true only when it
973
+ is written. A release asked for while the bucket has a pending change waits for
974
+ the close.
975
+ - **A content listener that throws on one bucket does not strand the others.**
976
+ The close stamps every filed bucket and settles it before its listeners run,
977
+ goes on to the next bucket when one listener throws, and rethrows the first error
978
+ once every bucket is through — costing nothing when nothing throws.
979
+ - **A released identity is refused before the bucket changes.** Every door that
980
+ takes a member — the add doors, the doors by key, `insertBucketMemberSlotAt`,
981
+ a whole-list `writeBucketMemberSlots`, and the fill of a minted bucket — checks what it was
982
+ handed with `oimIsSlotReleased` first and throws, so a refused door leaves the members,
983
+ their holds and the EMPTY flag exactly as they were. It costs one field read per
984
+ member handed in.
985
+
986
+ ### `isSubframeOpen(channelSlot)` / `listChangedStoreSlot(channelSlot, storeSlot, subframeCloseListener)`
987
+
988
+ **What a batch does once instead of per entity.** A subframe is the unit a
989
+ service can defer work to the end of: `listChangedStoreSlot` lists a store slot together with the
990
+ `IOIMSubframeCloseListener` (`onStoreSlotSubframeClosed(storeSlot)`) that delivers its changes, and the
991
+ outermost `closeSubframe` calls each listed listener with its store slot. `isSubframeOpen` says whether there
992
+ is a subframe to defer into at all — with none, the caller does the work immediately.
993
+
994
+ - **The drain runs before the count drops**, so a listener that writes is still inside the subframe and
995
+ still cannot close the frame. A store slot listed by a listener during the drain is picked up by the same
996
+ drain.
997
+ - **Every listed entry runs.** A listener that throws loses its own work for that batch and nothing retries
998
+ it; the entries after it still run, the list is emptied, the count still drops, the frame the subframe
999
+ owed is still asked for, and then the FIRST throw leaves `closeSubframe`.
1000
+ - **What uses it**: the bucket content services. A join or a leave records the bucket slot on its store
1001
+ slot's write queues and lists that store slot; the drain then writes the channel, settles emptiness and
1002
+ asks `releaseSlotIfUnheld` **once per bucket per batch** rather than once per member. Filing 10 000 entity
1003
+ slots into 100 buckets pays 100 of each tail instead of 10 000. Nothing is delivered before the flush either
1004
+ way, so the two are behaviourally the same.
1005
+
1006
+ ### `dropChannelSlot(channelSlot)`
1007
+
1008
+ Kills the whole channel on the caller's word that **nothing is subscribed to
1009
+ anything in it**. The engine does not verify that: checking would mean asking
1010
+ every slot whether its `effects` list is empty, which is exactly the walk the
1011
+ method exists to skip — the check IS the cost, which is what the suffix of its name says.
1012
+
1013
+ ```ts
1014
+ // every subscriber disposed, the screen is going away
1015
+ OIMChannelService.INSTANCE.dropChannelSlot(db.defaultChannelSlot);
1016
+ // drop your own references; every slot, store and derivation goes with them
1017
+ ```
1018
+
1019
+ - It empties the effect queue and the after-each-frame and after-each-flush handlers, cancels the scheduled
1020
+ flush, sets the `dropped` flag, and returns. **Nothing walks a slot to ask who is subscribed to it** —
1021
+ that is the walk the name refuses.
1022
+ - **It does flush the unused-slot collector**, which is the channel's own debt
1023
+ and not a walk of your graph: every getter that minted an identity this frame
1024
+ filed a hold there, and a flush was the only thing that ever gave those back. Since `flush` returns on the
1025
+ flag, a drop that skipped this would pin every such identity for as long as the db's collector slot lives.
1026
+ A drop made from INSIDE a flush leaves it to that flush, which still runs it.
1027
+ - The flag exists because `TOIMChannelScheduler` gives back no handle, so a frame asked
1028
+ for before the call still arrives; `flush` returns on it.
1029
+ - **The flag is read in TWO places.** `flush` returns on it, and so does the
1030
+ private `requestFlush` — which `write` reaches at the end of every
1031
+ write that got past the per-frame coalescing guard with at least one dependent
1032
+ to schedule, and which `scheduleEffectRun` reaches when
1033
+ `OIMEffectService.INSTANCE.createEffect` finds a source that already moved in the open batch outside a running frame. A dropped channel therefore
1034
+ stops asking its host for frames instead of arming a timer whose flush is
1035
+ guaranteed to return on the flag.
1036
+ - **A write after the call is not refused, and it is not unnoticed either.** It
1037
+ passes `assertNotFlushing`, advances the clock, stamps the slot and schedules
1038
+ the dependents it finds — and then asks for no frame, so nothing it scheduled is
1039
+ ever delivered. Do not read "not refused" as "still works".
1040
+ - Called with subscribers still standing, their bodies simply never run again —
1041
+ no throw and no report. That is the bargain the name states.
1042
+ - There is no checked sibling. One would cost the walk this avoids; if a
1043
+ diagnostic is ever wanted it ships under its own name, not as an option here.
1044
+
1045
+ ### Frame rules
1046
+
1047
+ - **Delivery is coalesced per identity per frame.**
1048
+ - **Writes during a FLUSH throw — not just during the frame body:**
1049
+ `OIMChannelService: writes are refused for the whole of a flush — onBeforeFlush and every after-each-frame handler included, …` and then tells you to write after `flush` returns.
1050
+ `flush` raises `channelSlot.isFlushing` before `onBeforeFlush` and lowers it in its
1051
+ `finally`; `assertNotFlushing` throws on `isFlushing` or `frame.isRunning`, so
1052
+ all three phases are refused. Refusal is per channel: a flush of one db's channel does not refuse a
1053
+ write to another db's.
1054
+ - **A refused write keeps its change pending — it is never dropped.** A bucket
1055
+ carries a change `addBucketMemberSlots`/`removeBucketMemberSlots` recorded inside an outer open batch
1056
+ until that batch closes. If something delivers it mid-flush — a member's
1057
+ subscriber giving the list back, so `unholdSlot` reaches `releaseSlotIfUnheld` —
1058
+ the write is refused and the bucket's store slot stays listed, so the next
1059
+ subframe close delivers it.
1060
+ - **Each door asks once, then writes through `writeWithoutFlushCheck`.** A content service
1061
+ door opens with `assertWritable`, which asks the channel, and every write it
1062
+ makes after that asks nothing: the guard was three field reads and a branch
1063
+ per write, and `upsertEntities` of 100 entities into one ordered bucket asked it 302
1064
+ times where 201 of those were the door's own re-asking. The per-entity ask is NOT
1065
+ removable and stays — user code runs inside a door (`keyOf`, `index.keyOf`,
1066
+ `slotEmptiness.isEmpty`, an `IOIMEntityWriter`) and can call
1067
+ `flush()`, so each entity is a new question. What went is the SECOND and THIRD ask
1068
+ of the same entity.
1069
+ - **`flush` during a frame throws:** the message says a frame is already running, and a second one
1070
+ inside it would end the first.
1071
+ - **`flush()` from the TAIL of a flush** (`onBeforeFlush`, an after-each-frame
1072
+ handler, or anything either called) returns having done nothing. Not a
1073
+ deferral: one flush spends exactly one frame however many handlers ask for
1074
+ another.
1075
+ - **There is NO door that writes from inside a flush.** `subscribeAfterEachFrame`,
1076
+ `subscribeOnceAfterFrame` and `onBeforeFlush` all run inside it. To write in
1077
+ response to a change, write after `flush()` has returned — a tail handler
1078
+ hands the work to a microtask or a later callback rather than writing itself.
1079
+ - A refusal raised from a tail handler does NOT reach the caller at the call: it
1080
+ goes to the error policy like any handler throw — logged by default, or thrown
1081
+ out of `flush()` under `OIMErrorPolicyThrowAtOnce`.
1082
+ - Effects are isolated under the default policy: a throw is logged and handed to
1083
+ `onError`, and the frame finishes every other effect.
1084
+ - **A derivation is asked, never woken.** No frame walks to a computed slot.
1085
+
1086
+ ### Subscribing
1087
+
1088
+ ```ts
1089
+ const effect = effectService.createEffect(db, [slot], [], () => { paint(slot.entity); });
1090
+ effectService.disposeEffect(effect);
1091
+ ```
1092
+
1093
+ A `TOIMEffect` is a plain object with no methods; its behaviour lives in the stateless
1094
+ `OIMEffectService.INSTANCE`, which takes the channel map (`db`) on every create and files the effect on its
1095
+ `defaultChannelSlot` (`effect.channelSlot`). `createEffect` schedules the new effect when
1096
+ the current frame already has writes; it still never receives writes made before it was created.
1097
+ For a detached function, `effectService.subscribeEffect(db, roots, computeds, body)` creates the
1098
+ effect exactly as `createEffect` does and returns its `TOIMDisposer` — `disposers.push(…)` it,
1099
+ or return it from a React cleanup. `oimHoldSlots(slots)` takes one hold on each slot of a list,
1100
+ through its owner; to give back a list of holds taken around one write,
1101
+ `oimUnholdSlotsIfAlive(slots)` unholds each slot that was not released meanwhile.
1102
+
1103
+ Two things about how an effect is filed:
1104
+
1105
+ - **The derivation list is deduplicated.** Naming one computed slot twice in the
1106
+ second argument gets it one edge, not two. Nothing is allocated for a list of one
1107
+ or none, and a list that turns out to be distinct keeps the caller's array.
1108
+ - **`disposeEffect` unfiles by a remembered position, not by scanning.** Each effect
1109
+ records where it sits in every list it is on; removal swaps in the tail and
1110
+ tells the moved effect its new position. The
1111
+ consequence a consumer can observe: **a slot's `effects` is not in
1112
+ subscription order**, and it never was guaranteed to be.
1113
+ - **The moved effect is told by EDGE, not found by search.** Beside `effects` a slot
1114
+ keeps `effectEdges` — for each effect, which of that effect's sources this slot
1115
+ is — and the swap carries both, so the moved effect updates one number. Taking one
1116
+ of two effects off the same N slots reads the other's source list ZERO times (a
1117
+ search read it N(N+1)/2). Only the slot's owner writes either array.
1118
+
1119
+ The body's type is `TOIMEffectBody = () => (() => void) | void`. **A returned
1120
+ function is the undo**: it is called before the next run of that body and by
1121
+ `disposeEffect`.
1122
+
1123
+ ```ts
1124
+ effectService.createEffect(db, [slot], [], () => {
1125
+ const timer = setInterval(poll, 1000);
1126
+ return () => clearInterval(timer);
1127
+ });
1128
+ ```
1129
+
1130
+ The return is checked with `typeof`, not for truthiness — `() => set.add(key)`
1131
+ hands back the `Set` — so anything that is not a function is discarded. The undo
1132
+ is cleared before it is called, so one that throws is not called again by the
1133
+ `disposeEffect` behind it.
1134
+
1135
+ **Sources found at read: `createEffectWithSourceTracking(db, body)`.** The second way to build
1136
+ an effect takes no source lists. The body receives an `OIMEffectSourceTracker`, and what it
1137
+ reads through the tracker is what that run depends on — signal-style tracking.
1138
+
1139
+ ```ts signature
1140
+ createEffectWithSourceTracking(channelMap: IOIMChannelMap, body: TOIMEffectBodyWithSourceTracking): TOIMEffect;
1141
+ type TOIMEffectBodyWithSourceTracking = (effectSourceTracker: OIMEffectSourceTracker) => (() => void) | void;
1142
+ class OIMEffectSourceTracker { // exported as a type; the service builds it
1143
+ readSourceSlot<TSlot extends TOIMSlot>(slot: TSlot): TSlot; // files the slot, returns it
1144
+ readSourceComputedValue<TValue>(computedSlot: TOIMComputedSlot<TValue, unknown>): TValue; // files it, returns its value
1145
+ }
1146
+ ```
1147
+
1148
+ ```ts
1149
+ const fallback = cardService.slotContentService.upsertEntity(cards, { id: 'c2', title: 'King', rank: 13, deck: 'hearts' });
1150
+ effectService.createEffectWithSourceTracking(db, effectSourceTracker => {
1151
+ const isFirst = effectSourceTracker.readSourceSlot(slot).entity?.rank === 1;
1152
+ paint(effectSourceTracker.readSourceSlot(isFirst ? slot : fallback).entity);
1153
+ });
1154
+ ```
1155
+
1156
+ - The body runs once at creation: that run is what finds the sources.
1157
+ - After every run the effect is subscribed to exactly what the run read: a branch not
1158
+ taken is unsubscribed, a slot read twice is subscribed once.
1159
+ - A run that reads the same sources in the same order compares each read with the one
1160
+ before and changes no subscription.
1161
+ - `createEffect` with declared sources is unchanged; the choice is made per effect, at
1162
+ creation. A returned function is the undo, exactly as for `TOIMEffectBody`.
1163
+
1164
+ ```ts
1165
+ const onRank = effectService.createEffect(db, [cardService.holdColumn(slot, 'rank')], [], () => { paint(slot.entity); });
1166
+ ```
1167
+
1168
+ **A column effect takes the `TOIMColumnSlot` as a root, not a slot and a name.** Minting the
1169
+ column is the caller's call and it is a separate door, on the collection (or single) service:
1170
+ `service.holdColumn(slot, columnName)` mints and HOLDS (`service.unholdColumn(columnSlot)` gives it back),
1171
+ and `service.getColumn(slot, columnName)` mints and lets the db's unused-slot collector hold it until the end
1172
+ of the next flush. Every collection and single service has them (a single entity's doors take the slot its
1173
+ service hands back: `settingsService.getColumn(settingsService.getSingleSlot(settings), 'theme')`), and all go
1174
+ through the owner's column owner, which is stateless configuration built at construction; the column state —
1175
+ the column slots, the column groups, and which columns are referenced — lives on the store slot. Every
1176
+ service also has `getAnyColumn(slot)` / `holdAnyColumn(slot)`, a column slot that wakes on any column write to
1177
+ its carrier. An index store slot's buckets have the same doors on the index service, taking the bucket slot.
1178
+ `createEffect` returns the **effect**, not the column: a column holds no value; read `slot.entity[name]`.
1179
+
1180
+ **The name is compile-time checked only as far as the service's column-name type goes** —
1181
+ `holdColumn` takes the service's `TColumnName`, which `createOIMEntityService` sets to
1182
+ `TOIMEntityColumnName<TEntity>`. `TOIMEntityColumnName<TEntity>` is the type that narrows a name to the
1183
+ entity's own columns, and `slotContentService.deleteEntityColumns` is typed on it.
1184
+
1185
+ **A column group is one column slot for several columns.** `service.getColumnGroup(['rank', 'label'])`
1186
+ returns a `TOIMColumnGroup` — `{ key, columnNames }`, the names deduplicated and sorted, a fresh frozen object
1187
+ per call; get it once per view, not per entity. `service.holdColumnGroup(slot, columnGroup)` mints and holds
1188
+ the group's column slot on `slot`, and `service.unholdColumn(columnSlot)` gives it back. A write wakes it once
1189
+ if it changes any of the group's columns and not at all if it changes only others.
1190
+
1191
+ **`columnNames` picks the column slot store.** `createOIMEntityService<TCard, 'rank' | 'label'>(db, { keyOf, columnNames: ['rank', 'label'] })`
1192
+ — and `columnNames` on any factory's options — files the service's column slots by ordinal: every named column
1193
+ gets a number, a group gets one at its first slot, and an entity slot's column slots sit in an array by
1194
+ number instead of a `Map` per entity slot, which is cheaper to create and give back. `holdColumn` and
1195
+ `getColumnGroup` are typed on those names, and a slot for any other column throws. Without `columnNames` the
1196
+ store is the `columnSlotStoreService` option, defaulting to the shared map-driven
1197
+ `OIMDefaults.COLUMN_SLOT_STORE_SERVICE.getValue()`, and any name is accepted.
1198
+
1199
+ ---
1200
+
1201
+ ## `OIMDB` and the three doors
1202
+
1203
+ ```ts signature
1204
+ new OIMDB(options?: TOIMDBOptions) // see the runtime section for the option list
1205
+ ```
1206
+
1207
+ It owns the shared `clock`, one `collectorSlot` and one `defaultChannelSlot`, all public, and
1208
+ holds `entityWriter`, `slotStamp`, `slotReleasePolicy` and `indexKeysService` as the
1209
+ defaults every factory reads — `undefined` when not named. **It builds no service**, and `flush()` is its
1210
+ only method: `OIMChannelService.INSTANCE.flush(db.defaultChannelSlot)`, which flushes the db's unused-slot
1211
+ collector slot once the frame is delivered. `OIMDB` implements `IOIMChannelMap`
1212
+ (`{ readonly defaultChannelSlot }`), so every door typed `channelMap: IOIMChannelMap` takes the db.
1213
+
1214
+ **There is no default scheduler, on purpose — do not add one and do not generate
1215
+ code that wraps every write in `flush()`.** A test calls `db.flush()` itself. An
1216
+ application hands it to its orchestrator: with cnstra the whole wiring is
1217
+ `cns.addDrainListener(() => db.flush())`, which runs at the end of every synchronous
1218
+ turn of every stimulation. Each turn becomes one frame; an `await` inside a neuron
1219
+ ends a turn, so a request delivers its loading state first and its data in a later
1220
+ frame, with no further code. Every mutation goes through a domain neuron — a write
1221
+ from outside the orchestrator is the mistake, not a case to cover with a fallback.
1222
+ Full page: `website/docs/guides/with-cnstra.md`.
1223
+
1224
+ The doors are free functions, one module each, so a consumer ships only the one they import. The entity,
1225
+ bucket and index factories take `db` first and ONE options object second (optional on the bucket doors); the
1226
+ base collection factories and the computed factories take only the options object. Each builds a SERVICE,
1227
+ once per collection type; the store slot is made separately:
1228
+
1229
+ | Door | Builds | Store slot |
1230
+ |---|---|---|
1231
+ | `createOIMSlotContentEntityService(db, { keyOf, entityWriter?, slotEmptiness?, slotStoreService?, slotReleasePolicy?, columnSlotStoreService? \| columnNames? })` | `OIMSlotContentEntityService<TEntity, TKey, TStore, TColumnName>`, the entity content service with its slot owner; `slotStoreService` defaults to a Map store for a key and is REQUIRED for any other store (a tree store, or `OIMSlotStoreServiceSingleSlotDriven` for a single, which takes no `keyOf`) | — |
1232
+ | `createOIMSlotCollectionService({ slotContentService })` | `OIMSlotCollectionService` over an entity content service: the read doors and the column doors | `createStoreSlot(db)` |
1233
+ | `createOIMSingleSlotService({ slotContentService })` | `OIMSingleSlotService` over a single entity content service | `createStoreSlot(db)` |
1234
+ | `createOIMEntityService(db, { keyOf, entityWriter?, slotEmptiness?, slotReleasePolicy?, columnSlotStoreService? \| columnNames? })` | convenience wrapper: the two calls above for a Map store; `TColumnName` is `TOIMEntityColumnName<TEntity>` | `createStoreSlot(db)` |
1235
+ | `createOIMSingleEntityService(db, { … }?)` | convenience wrapper for a single entity | `createStoreSlot(db)` |
1236
+ | `createOIMBucketService<TBucketMemberSlot, TKey?, TSlots?, TSlotContentService?, TStore?>(db, { slotContentService?, bucketSlotStoreService?, maxCommandsPerSlot?, slotReleasePolicy?, columnSlotStoreService? \| columnNames? }?)` | `OIMSlotCollectionService` of bucket slots; `slotContentService` defaults to an `OIMSlotContentBucketServiceArrayDriven` that records `OIMBucketCommands` (`maxCommandsPerSlot` caps it). An array `TKey` makes `bucketSlotStoreService` REQUIRED by type — pass `OIMSlotStoreServiceTreeDriven`, since a Map store compares arrays by reference | `createStoreSlot(db)` |
1237
+ | `createOIMSingleBucketService(db, { … }?)` | `OIMSingleSlotService` of one bucket slot | `createStoreSlot(db)` |
1238
+ | `createOIMIndexService(db, { entityService, columns? \| keyOf?, slotContentService?, slotReleasePolicy?, columnSlotStoreService? \| columnNames? })` | `OIMIndexService`, over the entity collection service whose store slots it will index (it takes a `Pick` of `getSlots`); `slotContentService` defaults to array-driven, pass `new OIMSlotContentBucketServiceSetDriven()` for an unordered one. Generics `<TEntity, TSourceKey, const TColumns, TKey extends TOIMStoreKey>`: a scalar key, inferred from ONE inline column or from `keyOf`'s return | `createIndexStoreSlot(entityStoreSlot)` |
1239
+ | `createOIMIndexServiceSortedArrayDriven(db, { entityService, columns? \| keyOf?, sortColumns, … })` | `OIMIndexServiceSortedArrayDriven`: the index whose buckets are kept in the order of `sortColumns` (`readonly TOIMSortColumn<TEntity>[]`, required) through an `OIMSlotContentBucketServiceSortedArrayDriven`. See [Sorted at write](#sorted-at-write) | `createIndexStoreSlot(entityStoreSlot)` |
1240
+ | `createOIMIndexServiceByCompositeKey(db, { entityService, columns? \| keyOf?, … })` | convenience wrapper: the index keyed by an array, `TKey extends readonly TOIMStoreKey[]`, inferred as a tuple from two or more inline columns or from an array-returning `keyOf`, over a tree store | `createIndexStoreSlot(entityStoreSlot)` |
1241
+ | `createOIMComputedService({ createSources?, derive?, sameValueGuard?, initialValue?, slotReleasePolicy?, computedSlotStoreService?, columnSlotStoreService?, columnNames? })` | `OIMComputedService<TKey, TValue, TComputedStore>`; its reads are on `computedService.computedCollectionService`; `derive` and `createSources` may instead be given per store slot; `computedSlotStoreService` is REQUIRED for a non-Map store (a single: `OIMSlotStoreServiceSingleSlotDriven`); see [One derivation per key](#one-derivation-per-key) | `createComputedStoreSlot(db, derive?, createSources?)` |
1242
+ | `createOIMComputedServiceSorted({ indexService, sortColumns?, comparator?, slotReleasePolicy?, columnSlotStoreService?, computedSlotStoreService? })` | `OIMComputedServiceSorted` — per key, the entity slots of the index bucket under that key, sorted; see [Sorted lists](#sorted-lists) | `createSortedStoreSlot(indexStoreSlot)` |
1243
+ | `createOIMComputedServiceBucketMemo({ bucketCollectionService, slotReleasePolicy?, columnSlotStoreService?, computedSlotStoreService? })` | `OIMComputedServiceBucketMemo` — per key, the bucket's slots as one array; see [The order of a bucket](#the-order-of-a-bucket-as-one-derivation-per-key) | `createBucketMemoStoreSlot(bucketStoreSlot)` |
1244
+
1245
+ `entityWriter` and `slotReleasePolicy` on these doors default to the
1246
+ db's, so naming one overrides it for that service alone.
1247
+
1248
+ **A DOOR ALWAYS PASSES; IT NEVER LETS A CLASS REACH FOR A GLOBAL.** Every option
1249
+ a service would otherwise answer with `?? OIMDefaults.x` is in the object its
1250
+ door hands it, so there is one answer to each question and not two. Pinned in
1251
+ `src/factories/tests/which-answer-a-collection-the-root-mints-gets.test.ts`,
1252
+ which also fails when an option arrives on a service and is not forwarded,
1253
+ when the root mints anything but the channel and the collector, or when a public
1254
+ member appears on the root that is not one of the seven it owns.
1255
+
1256
+ **`createOIMBucketService` with no content service is the door for a list whose order is yours.** It builds
1257
+ an `OIMSlotContentBucketServiceArrayDriven` — insertion order, never re-decided — that records an
1258
+ `OIMBucketCommands` script per bucket. The recorder holds no subscription: it lists store slots on the channel
1259
+ slot, and the script lives for one frame.
1260
+
1261
+ ```ts
1262
+ const freshPageService = createOIMBucketService<TOIMEntitySlot<Card>>(db);
1263
+ const freshPage = freshPageService.createStoreSlot(db);
1264
+ const freshBucketSlot = freshPageService.holdSlotByKey(freshPage, 'page-1');
1265
+ // freshBucketSlot.commands and freshBucketSlot.isWholeListRecorded carry the script; read them in an effect on the bucket slot
1266
+ ```
1267
+
1268
+ ---
1269
+
1270
+ ## `OIMDefaults` — where an optional dependency comes from
1271
+
1272
+ Every optional property of the factories falls back to an entry
1273
+ on `OIMDefaults`. It is a class of static `OIMDefaultValue` fields and nothing else — never
1274
+ construct one, never subclass it.
1275
+
1276
+ | Accessor | Type | What it hands back |
1277
+ |---|---|---|
1278
+ | `OIMDefaults.COLUMN_SLOT_STORE_SERVICE` | `IOIMSlotStoreService<string, TOIMColumnSlot>` | a map-driven store service for column slots; the factories pass it to every service they build without `columnNames` |
1279
+ | `OIMDefaults.COMPUTED_COLUMN_SLOT_STORE_SERVICE` | `IOIMSlotStoreService<string, TOIMComputedColumnSlot>` | a map-driven store service for the column slots of computed services, keyed or single; the computed factories pass it to every one they build without `columnNames` |
1280
+ | `OIMDefaults.INDEX_KEYS_SERVICE` | `IOIMIndexKeysService` | `OIMIndexKeysServiceAppending`; the factories pass it when `db.indexKeysService` is not set |
1281
+ | `OIMDefaults.KEY_TREE_WALK_SCALAR` | `OIMKeyTreeWalkScalar` | the chain-of-values walk |
1282
+ | `OIMDefaults.KEY_TREE_WALK_COMPOSITE` | `OIMKeyTreeWalkComposite` | the chain-of-identities walk |
1283
+ | `OIMDefaults.STANDALONE_SLOT_OWNER` | `OIMSlotOwner` | the owner of a slot nobody filed |
1284
+ | `OIMDefaults.ENTITY_WRITER` | `IOIMEntityWriter` | `createOIMEntityWriterInPlace()` |
1285
+ | `OIMDefaults.ENTITY_SLOT_EMPTINESS` | `IOIMSlotEmptiness<TOIMEntitySlot>` | "empty means `slot.entity === undefined`" |
1286
+ | `OIMDefaults.STORE_SLOT_LISTENER` | `IOIMStoreSlotListener` | `OIMStoreSlotListenerNone`: hears nothing. Not a constructor option: every service reads it when it is built and calls `onStoreSlotCreated(storeSlot)` for each store slot it creates. A tool that wants to see every store slot sets its own with `setValue` before the first service is built; once one has been built, `setValue` throws |
1287
+
1288
+ **NOTHING IS CONSTRUCTED AT MODULE LOAD.** Every entry is an `OIMDefaultValue`
1289
+ whose `getValue()` mints on its first call (`??=`) and hands the same instance back
1290
+ for the rest of the process. A consumer that never reaches for a key tree walk never
1291
+ builds one.
1292
+
1293
+ Each has a matching `setValue(value)`, which is how a process replaces a default
1294
+ everywhere at once:
1295
+
1296
+ ```ts
1297
+ OIMDefaults.ENTITY_WRITER.setValue(createOIMEntityWriterReplacing());
1298
+ ```
1299
+
1300
+ **`setValue` THROWS ONCE `getValue` HAS HANDED THE FIRST ONE OUT.** `getValue`
1301
+ marks the entry as handed out; `setValue` refuses after that, by name —
1302
+ `OIMDefaults.ENTITY_WRITER has already been handed out, and whoever took it keeps
1303
+ it …`, the name in the message being the field's upper-case name. Whoever took it keeps
1304
+ it, so replacing it then would leave two implementations live in one process,
1305
+ the older one inside every object built before the call, and the second half of
1306
+ the program would disagree with the first about what a write does.
1307
+
1308
+ Setting it before anything reads it works, and so does setting it twice before
1309
+ the first read. Each property is tracked on its own, so calling
1310
+ `OIMDefaults.COLUMN_SLOT_STORE_SERVICE.getValue()` does not close
1311
+ `OIMDefaults.KEY_TREE_WALK_SCALAR`. **Set every default you intend to replace at
1312
+ startup**, before you construct an `OIMDB` or a service. Pinned by
1313
+ `packages/core/src/factories/tests/what-a-default-replaced-too-late-does.test.ts`.
1314
+
1315
+ **Each default is its own module, so reaching one retains only its arm.** Every
1316
+ default lives in its own file under `packages/core/src/factories/defaults/` with its
1317
+ own `OIMDefaultValue`; a service reads the modules it needs and
1318
+ neither key tree walk enters its bundle. `OIMDefaults` itself delegates to all of
1319
+ them, so importing `OIMDefaults` retains every arm.
1320
+ `packages/core/tests/what-an-import-retains.test.ts` pins it — *"a default is one
1321
+ door to its own arm, and the defaults class to all of them"*.
1322
+
1323
+ ---
1324
+
1325
+ ## Entity services
1326
+
1327
+ **An entity collection is two objects: the content service and the collection service.** The entity content
1328
+ service (`OIMSlotContentEntityService`) is the writes and reads of entities, built once per collection type
1329
+ with its slot owner; the collection service (`OIMSlotCollectionService`) over it is the read and column doors.
1330
+ Neither holds an entity: each `createStoreSlot(db)` is one instance of that type's store.
1331
+
1332
+ ```ts signature
1333
+ createOIMSlotContentEntityService<TEntity, TColumnName?, TKey?, TStore?>(db, {
1334
+ keyOf: (entity: TEntity) => TKey; // REQUIRED for a key; absent for a single
1335
+ entityWriter?: IOIMEntityWriter; // db.entityWriter, then OIMDefaults.ENTITY_WRITER
1336
+ slotEmptiness?: IOIMSlotEmptiness<TOIMEntitySlot<TEntity, TKey>>; // OIMDefaults.ENTITY_SLOT_EMPTINESS
1337
+ slotStoreService?: IOIMSlotStoreService<TKey, TOIMEntitySlot<TEntity, TKey>, TStore>; // Map; REQUIRED for any other store
1338
+ // and for a single (OIMSlotStoreServiceSingleSlotDriven)
1339
+ slotReleasePolicy?: IOIMSlotReleasePolicy; // db.slotReleasePolicy, then OIMSlotReleasePolicyAtEmpty
1340
+ columnSlotStoreService?: IOIMSlotStoreService<string, TOIMColumnSlot>; // OIMDefaults.COLUMN_SLOT_STORE_SERVICE
1341
+ columnNames?: readonly TColumnName[]; // the ordinal column store over those names
1342
+ }): OIMSlotContentEntityService<TEntity, TKey, TStore, TColumnName>
1343
+
1344
+ createOIMSlotCollectionService({ slotContentService }): OIMSlotCollectionService<TKey, TOIMEntitySlot<TEntity, TKey>, TStore, …>
1345
+ collectionService.createStoreSlot(db): TOIMStoreSlot<TStore, TOIMEntitySlot<TEntity, TKey>, Partial<TEntity>>
1346
+
1347
+ createOIMEntityService<TEntity, TColumnName?, TKey?>(db, { keyOf, … }) // convenience: both calls, Map store
1348
+ ```
1349
+
1350
+ **Each collection type has its own column owner.** It is stateless configuration built with the slot owner
1351
+ over `columnSlotStoreService` (or the ordinal store `columnNames` names); the column state — the column slots,
1352
+ the column groups, and which columns are referenced — lives on the store slot. The doors are on the
1353
+ collection service (`getColumn`, `holdColumn`, `unholdColumn`, `getColumnGroup`, `holdColumnGroup`,
1354
+ `getAnyColumn`, `holdAnyColumn`). The column owner has two strategies, chosen by the factory: write-driven
1355
+ column slots, woken by the marks a write makes (`OIMColumnOwnerPushDriven`, entities and buckets), and
1356
+ derive-driven column slots, each a computed slot over its carrier (`OIMColumnOwnerPullDriven`, computeds).
1357
+
1358
+ Example, over the `db`, `cardService`, `cards` and `slot` of the setup block:
1359
+
1360
+ ```ts
1361
+ const effect = effectService.createEffect(db, [slot], [], () => { console.log(slot.entity?.title); });
1362
+ cardService.slotContentService.updateEntity(slot, { title: 'Ace of Spades' });
1363
+ db.flush();
1364
+ effectService.disposeEffect(effect);
1365
+ ```
1366
+
1367
+ Assembled from the base layer, which is what `createOIMEntityService` does:
1368
+
1369
+ ```ts
1370
+ const baseCardService = createOIMSlotCollectionService({
1371
+ slotContentService: createOIMSlotContentEntityService<Card>(db, { keyOf: (card) => card.id }),
1372
+ });
1373
+ const baseCards = baseCardService.createStoreSlot(db);
1374
+ ```
1375
+
1376
+ The writer is omitted because the db and `OIMDefaults` supply it, and so is the column store. The slot stamp
1377
+ is not an option of the content service: it is `new OIMDB({ slotStamp })`, for every collection of that db.
1378
+
1379
+ ### Public properties
1380
+
1381
+ | Property | Of | Type |
1382
+ |---|---|---|
1383
+ | `slotContentService` | the collection service | the content service: the write doors |
1384
+ | `keyOf` | the entity content service | `(entity: TEntity) => TKey` |
1385
+ | `slotOwner` | the entity content service | `IOIMSlotOwner` — the owner of every slot the collection type files |
1386
+ | `slotStoreService` | the entity content service | the store service |
1387
+
1388
+ A store slot is not a slot, so nothing holds, subscribes or wakes it: there is no
1389
+ `effectService.createEffect(db, [cards], …)` that hears the population. "Something in this population changed" is an
1390
+ index over the store slot and a subscription to its bucket — the bucket's door
1391
+ is written when an entity slot joins or leaves it — or a content-change listener on the store slot
1392
+ (`OIMContentChangeListenerService.INSTANCE.addContentChangeListener(storeSlot, listener, readColumnNames)`,
1393
+ removed by the same listener object). The slot owner is what `slot.owner` points at for every slot the type
1394
+ filed, in every one of its store slots; `slot.storeSlot` names which store slot.
1395
+
1396
+ **THERE IS NO DOOR THAT HANDS BACK "THE ENTITY SLOTS THAT HOLD A VALUE".** A store slot
1397
+ keeps no bucket of its own. To read the whole population, walk `getSlots(storeSlot)` — every
1398
+ slot filed in that store slot, in store order — and skip the ones whose
1399
+ `oimIsSlotEmpty` says they hold nothing:
1400
+
1401
+ ```ts
1402
+ cardService.getSlotCount(cards); // how many ids are filed, empty ones included
1403
+ for (const filedSlot of cardService.getSlots(cards)) {
1404
+ if (oimIsSlotEmpty(filedSlot)) continue;
1405
+ paint(filedSlot.entity);
1406
+ }
1407
+ ```
1408
+
1409
+ To have that answer MAINTAINED and subscribable instead of walked, declare an
1410
+ index keyed on the entity's own id (`keyOf: card => card.id`) and hold the key
1411
+ you want. An index's bucket is an injected lifetime — a
1412
+ `slotReleasePolicy` — so it is the consumer who says how long the
1413
+ answer outlives its last reader.
1414
+
1415
+ An index maintains buckets only while they are held, filing entity slots from the source
1416
+ store slot on every write by the entity's key value. The bucket is created on first
1417
+ `holdSlotByKey` by walking the source store slot's slots once, and released on the last give-back.
1418
+
1419
+ **`holdColumn(slot, columnName)`** is on the collection service and takes a hold on a column slot;
1420
+ `service.unholdColumn(columnSlot)` gives that hold back. **`service.getColumn(slot, columnName)`**
1421
+ returns the same column slot without a hold: a column slot it mints is held by the
1422
+ unused-slot collector until the end of the next flush, so `getColumn` is
1423
+ the door that does not hold but tracks. `slotContentService.findEntityByKey(storeSlot, key)` reads the entity
1424
+ itself through the store lookup `findSlotByKey` runs, so it mints nothing: a miss answers `undefined`
1425
+ and leaves no slot behind.
1426
+
1427
+ ### The doors every service has
1428
+
1429
+ **The read doors are the collection service's and take the store slot first; the write doors are the content
1430
+ service's.** Every collection service (entity, bucket, index, and a computed's `computedCollectionService`) has
1431
+ the read doors below; a single slot service has `getSingleSlot` / `holdSingleSlot` / `findSingleSlot` /
1432
+ `hasSingleSlotContent` in their place.
1433
+
1434
+ ```ts signature
1435
+ class OIMSlotCollectionService<TKey, TSlot, TStore, TSlotContentService, TColumnName, …> {
1436
+ readonly slotContentService: TSlotContentService;
1437
+ createStoreSlot(channelMap: IOIMChannelMap): TOIMStoreSlot<TStore, TSlot, TPatch>;
1438
+ createStoreSlotFrom(sourceStoreSlot: TOIMStoreSlot): TOIMStoreSlot<TStore, TSlot, TPatch>;
1439
+ findSlotByKey(storeSlot, key): TSlot | undefined;
1440
+ hasSlotContentByKey(storeSlot, key): boolean;
1441
+ getSlotByKey(storeSlot, key): TSlot;
1442
+ holdSlotByKey(storeSlot, key): TSlot;
1443
+ getSlots(storeSlot): IterableIterator<TSlot>;
1444
+ getSlotCount(storeSlot): number;
1445
+ isSameKey(key, otherKey): boolean;
1446
+ getColumn / holdColumn / getAnyColumn / holdAnyColumn / getColumnGroup / holdColumnGroup / unholdColumn;
1447
+ }
1448
+ ```
1449
+
1450
+ | Door | Of | Signature | Mints? |
1451
+ |---|---|---|---|
1452
+ | `getSlotCount(storeSlot)` | collection | `number` | no |
1453
+ | `getSlots(storeSlot)` | collection | `IterableIterator<TSlot>` | no |
1454
+ | `findSlotByKey(storeSlot, key)` | collection | `TSlot \| undefined` — the store lookup alone; takes no hold and leaves nothing tracked | no |
1455
+ | `getSlotByKey(storeSlot, key)` | collection | `TSlot` — never `undefined`; minted if missing, then tracked by the unused-slot collector | **yes** |
1456
+ | `holdSlotByKey(storeSlot, key)` | collection | `TSlot` — minted if missing, `holdCount` up by one | **yes** |
1457
+ | `hasSlotContentByKey(storeSlot, key)` | collection | `boolean` — **does this key have content right now**, not whether an identity is filed under it: a slot a read or a hold minted and nobody wrote answers `false`. It hands back no slot, which is why it can answer without minting, and it is not a read: the answer is about the past the moment it returns | no |
1458
+ | `isSameKey(key, otherKey)` | collection | `boolean` — whether two keys name one slot here, answered by the store service (SameValueZero for a Map store, part by part for a tree store) | no |
1459
+ | `insertEntity(storeSlot, key, entity, changed?)` | entity content | `TOIMEntitySlot` — **total; always the identity it resolved or minted** | **yes** |
1460
+ | `upsertEntity(storeSlot, entity, changed?)` / `upsertEntities(storeSlot, entities, changed?)` | entity content | the slot(s) — `insertEntity` under `keyOf(entity)` | **yes** |
1461
+ | `writeEntity(slot, entity)` / `writeBucketMemberSlots(bucketSlot, members)` | content | `boolean` — on a slot you already hold; the boolean is about the CONTENT | no |
1462
+ | `deleteEntity(slot)` / `deleteEntities(storeSlot, slots)` / `clearBucketMemberSlots(bucketSlot)` | content | `void` — it takes the value, not the identity | no |
1463
+ | `clearAllEntities(storeSlot)` | entity content | `void` — clears every slot filed in that store slot | no |
1464
+ | `markContentChanged(bucketSlot)` | bucket content | `void` — re-settle emptiness and re-ask the guard after a write you made another way; the entity counterpart is `markEntityChanged(slot, changedColumns?)` | no |
1465
+ | `fillEntities(storeSlot, keys, entities)` / `insertEntities(storeSlot, entities)` | entity content | `void` — a bulk load of new keys; hands out no reference. On a key that is already filed or named twice it throws and rolls back only the entities this call filed; `fillEntities` also requires an empty store slot, and throws on lists of different lengths | yes |
1466
+ | `slot.owner.holdSlot(slot)` / `slot.owner.unholdSlot(slot)` | `void` — the `IOIMSlotOwner` pair; `unholdSlot` on an unheld slot throws by name | no |
1467
+ | `slot.owner.releaseSlotIfUnheld(slot)` | `void` — run the release guard; it releases nothing unless the slot is unheld, unreferenced, still owned here and out of grace | no |
1468
+
1469
+ **`releaseSlotIfUnheld` is PUBLIC and it is still not a "release" door.** It is public because the release queue calls it through
1470
+ `slot.owner`, and it is a guard, not a command: on a held, referenced, foreign-
1471
+ owned or in-grace slot it returns having done nothing, and on an
1472
+ already-released one it returns silently rather than throwing.
1473
+
1474
+ **`insertEntity` does not file a key whose content is refused.** On a MISS it
1475
+ mints, writes inside a `try`, and on a throw runs the release guard on what it
1476
+ just minted before re-raising — so the caller's error is the only thing that
1477
+ leaves. Every path still runs `closeSubframe` from a `finally`. The HIT path looks
1478
+ the key up first and never mints, so a refused write on an existing key leaves
1479
+ that key exactly as it was.
1480
+
1481
+ **REFUSED IS NOT UNCHANGED**, and the two get opposite answers:
1482
+
1483
+ | | What happened | What the caller gets |
1484
+ |---|---|---|
1485
+ | **Refused** | The write threw; **no content ever arrived** | The throw. A key minted for it is unwound — `insertEntity` files nothing |
1486
+ | **Unchanged** | The write **did** happen and the content was already there | The identity, like any other write. Nothing is unwound |
1487
+
1488
+ `insertEntity` is **total**: whatever it decides about the content, it hands back
1489
+ the identity it resolved or minted. A door that mints must leave a reference
1490
+ behind, which is the one test that admits a door at all.
1491
+
1492
+ `fillEntities` is the companion and does not carry the rule: it returns `void` and hands
1493
+ out no reference at all.
1494
+
1495
+ **`changed?` — the verdict, into an array you own.**
1496
+
1497
+ ```ts
1498
+ const changed: TOIMEntitySlot<Card, TOIMStoreKey>[] = [];
1499
+ cardService.slotContentService.insertEntity(cards, 'c1', { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' }, changed); // pushed exactly when the content differed
1500
+ ```
1501
+
1502
+ - **Optional, yours, and appended to** — never read by the engine, never cleared.
1503
+ The slot is pushed exactly when the write put in content different from what
1504
+ the slot held.
1505
+ - A batch therefore comes out of one loop as the list of entity slots that **actually
1506
+ changed**, which is what a consumer owning a bucket this engine does not
1507
+ maintain needs.
1508
+ - It is an out-parameter and not a return because the return is spoken for (see
1509
+ above), and a `{ slot, changed }` record would allocate once per write on the
1510
+ path this exists to make cheaper. What it costs a caller who does not want it
1511
+ is one `undefined` compare per write.
1512
+
1513
+ ---
1514
+
1515
+ ## Writing
1516
+
1517
+ Every write door is on the entity content service, `service.slotContentService`. A store door takes the store
1518
+ slot first; a slot door takes the slot alone. There are no by-key write doors: resolve the slot with
1519
+ `getSlotByKey` / `findSlotByKey` and write through it.
1520
+
1521
+ | Call | Signature |
1522
+ |---|---|
1523
+ | `upsertEntity(storeSlot, entity, changedEntitySlots?)` | `(storeSlot, TEntity, TOIMEntitySlot<TEntity, TKey>[]?) => TOIMEntitySlot<TEntity, TKey>` — **total** |
1524
+ | `upsertEntities(storeSlot, entities, changedEntitySlots?)` | `(storeSlot, readonly TEntity[], TOIMEntitySlot<TEntity, TKey>[]?) => TOIMEntitySlot<TEntity, TKey>[]` — the slot of EVERY entity, in input order, whether or not its write changed anything |
1525
+ | `insertEntity(storeSlot, key, entity, changedEntitySlots?)` | `(storeSlot, TKey, TEntity, TOIMEntitySlot<TEntity, TKey>[]?) => TOIMEntitySlot<TEntity, TKey>` — **total**, under a key you name |
1526
+ | `insertEntities(storeSlot, entities)` | `(storeSlot, readonly TEntity[]) => void` — a bulk load of NEW keys. A key already filed, or named twice in the list, throws and rolls back only the entities this call filed; what was in the store slot before stays. No `changed` — a bulk load changes every entity |
1527
+ | `fillEntities(storeSlot, keys, entities)` | `(storeSlot, readonly TKey[], readonly TEntity[]) => void` — the same under keys you name, with the same rollback; it also **throws unless the store slot is empty** |
1528
+ | `updateEntity(slot, patch)` | `(slot, Partial<TEntity>) => slot` |
1529
+ | `updateEntityColumn(slot, columnName, value)` | `<C extends keyof TEntity & string>(slot, C, TEntity[C]) => slot` — one column, no patch object and no walk over it |
1530
+ | `deleteEntity(slot)` | `(slot) => void` |
1531
+ | `deleteEntities(storeSlot, slots)` | the batch form — one subframe for the whole batch, so the store slot is written to the channel ONCE for the batch, not once per entity (`upsertEntities` and `insertEntities` are the same) |
1532
+ | `clearAllEntities(storeSlot)` | `(storeSlot) => void` — clears every filed slot inside one subframe, so the whole clear is one frame, not one per slot |
1533
+ | `markEntityChanged(slot, changedColumns?)` | `(TOIMEntitySlot<TEntity, TKey>, readonly (keyof TEntity & string)[]?) => void` |
1534
+ | `deleteEntityColumns(slot, columnNames)` | `(slot, readonly TOIMEntityColumnName<TEntity>[]) => slot` — the named columns go |
1535
+ | `writeEntity(slot, entity)` | `(slot, TEntity) => boolean` — the whole value onto a slot you hold; columns it had and `entity` does not, go |
1536
+ | `findEntityByKey(storeSlot, key)` | `(storeSlot, TKey) => TEntity \| undefined` — the entity alone; mints nothing |
1537
+
1538
+ - **The store keeps your object by default.** Under the default `oimInsertEntityWithoutCopy` the
1539
+ slot holds the object you hand in, so with the in-place writer a later update edits that
1540
+ same object; pass `entityInsertStrategy: oimInsertEntityWithCopy` to store `{ ...entity }`
1541
+ and leave your object untouched. After an insert, change the object only through the
1542
+ content service (`updateEntity`, or `markEntityChanged` after a hand edit): editing it and calling
1543
+ `upsertEntity` with the same object again wakes nobody, because no column reads as changed. `oimInsertEntityWithSanitizedCopy` also removes an own
1544
+ `__proto__` key: pick it for untrusted JSON.
1545
+ - `updateEntity` takes a **patch** — named columns move, unnamed ones stay.
1546
+ `insertEntity`, `upsertEntity` and `writeEntity` take a **whole value**, and a column the new value does not name
1547
+ is removed from the entity.
1548
+ - A write that moves no column wakes nobody.
1549
+ - **`upsertEntity` always hands back the slot**, whatever the write changed — see the
1550
+ rule on `insertEntity`; this door is that one plus `keyOf`.
1551
+ - **`changedEntitySlots?` is the same array on both**: optional, yours, appended with the
1552
+ entity slots whose content was different from what they already held. One array reused
1553
+ across a whole `upsertEntities` batch is the intended use, so a
1554
+ pass over its slots costs the entities that changed rather than the entities that
1555
+ arrived. It is never cleared — you empty it.
1556
+
1557
+ ```ts
1558
+ const changed: TOIMEntitySlot<Card, TOIMStoreKey>[] = [];
1559
+ cardService.slotContentService.upsertEntities(cards, payload, changed);
1560
+ for (const slot of changed) reindex(slot); // the re-sent entities are not in here
1561
+ changed.length = 0;
1562
+ ```
1563
+
1564
+ ### `markEntityChanged` — you mutated the entity yourself, now say so
1565
+
1566
+ ```ts signature
1567
+ markEntityChanged(
1568
+ slot: TOIMEntitySlot<TEntity, TKey>,
1569
+ changedColumns?: readonly (keyof TEntity & string)[],
1570
+ ): void
1571
+ ```
1572
+
1573
+ Every other write door computes what moved: the writer walks the patch
1574
+ (`oimPatchEntityColumns`, `oimPutEntityColumns`) and puts each value through `sameValueGuard`. A caller who edited the stored object by hand
1575
+ already knows the answer, and this door takes it instead of recomputing it — no
1576
+ patch object, no copy, no per-column comparison. The shape is: mutate
1577
+ `slot.entity`, then report it.
1578
+
1579
+ ```ts
1580
+ slot.entity!.title = 'two';
1581
+ cardService.slotContentService.markEntityChanged(slot, ['title']);
1582
+ ```
1583
+
1584
+ - **The hand mutation already compiles.** `TOIMEntitySlot` declares
1585
+ `readonly entity: TEntity | undefined`, and `readonly` forbids REPLACING the
1586
+ object, not writing its columns — `entity.title = 'two'` needs no cast, only
1587
+ the usual narrowing of `| undefined`. `TOIMSlotEditable<TSlot>` is what strips
1588
+ `readonly` from the field itself, which is what assigning `slot.entity` needs.
1589
+ - **With a list, only the named columns wake. Without it, every watched column
1590
+ does** — the column owner walks the slot's whole column store, and a
1591
+ column slot exists there only while something holds it or subscribes to it
1592
+ (the slot owner drops it otherwise, in `releaseSlotIfUnheld`), so "watched" is exactly
1593
+ what the store holds. **Pass the list.** The no-argument form is the convenient
1594
+ one and it gives back the per-column walk this door exists to skip.
1595
+ - **An empty list wakes nothing at all** — not the columns, not the entity slot, not the
1596
+ store slot's listeners. `[]` is the caller saying nothing moved, so it returns before the
1597
+ subframe opens: a write that changes nothing wakes nobody here too.
1598
+ - **With either of the other two it wakes the entity slot and the store slot's content-change listeners**, so an
1599
+ effect on the slot itself and an index over the store slot both see a hand mutation.
1600
+ - **It re-stamps emptiness** whenever emptiness can depend on the mutation — with no column list,
1601
+ on a slot whose `entity` is `undefined`, and on a content service with a `slotEmptiness` other than
1602
+ `OIMSlotEmptinessByEntityPresence.INSTANCE`, a release policy that releases written slots, or a db with a
1603
+ `slotStamp` — so a hand mutation that emptied the entity leaves `oimIsSlotEmpty(slot)`
1604
+ true rather than lying, and the release guard can take the identity when it should.
1605
+ - **It refuses inside a frame**, through the same `assertWritable` →
1606
+ `OIMChannelService.INSTANCE.assertNotFlushing(channelSlot)` as every other write, and throws on a released
1607
+ identity.
1608
+ - **The column names are type-checked**: `keyof TEntity & string` rejects a
1609
+ misspelt name, a bare runtime `string`, and a slot of another entity type.
1610
+
1611
+ ---
1612
+
1613
+ ## Write strategies
1614
+
1615
+ A **writer** is the operations that move an entity's columns, and nothing
1616
+ else: it reaches no channel, takes no holds and knows nothing about lifetime.
1617
+ Both strategies ship as factories; the fork is chosen once, as the content service's `entityWriter`
1618
+ (or for every collection of a db, `new OIMDB({ entityWriter })`).
1619
+
1620
+ ```ts signature
1621
+ interface IOIMEntityWriter {
1622
+ readonly fillEntity: (slot: TOIMEntitySlot, entity: object) => void;
1623
+ readonly putEntity: (slot: TOIMEntitySlot, entity: object, changedColumnNames: string[] | undefined) => boolean;
1624
+ readonly patchEntity: (slot: TOIMEntitySlot, patch: object, changedColumnNames: string[] | undefined) => boolean;
1625
+ readonly patchColumn: (
1626
+ slot: TOIMEntitySlot, columnName: string, value: unknown, changedColumnNames: string[] | undefined,
1627
+ ) => boolean;
1628
+ readonly deleteColumns: (slot: TOIMEntitySlot, names: readonly string[]) => readonly string[];
1629
+ readonly clearEntity: (slot: TOIMEntitySlot) => void;
1630
+ }
1631
+
1632
+ type TOIMEntityWriterOptions = {
1633
+ sameValueGuard?: TOIMSameValueGuard;
1634
+ entityInsertStrategy?: TOIMEntityInsertStrategy;
1635
+ putEntityColumns?: typeof oimPutEntityColumns; // oimPutEntityColumns | oimPutEntityColumnsGuarded
1636
+ patchEntityColumns?: typeof oimPatchEntityColumns; // oimPatchEntityColumns | oimPatchEntityColumnsGuarded
1637
+ patchEntityColumn?: typeof oimPatchEntityColumn; // oimPatchEntityColumn | oimPatchEntityColumnGuarded
1638
+ };
1639
+
1640
+ createOIMEntityWriterInPlace(options?: TOIMEntityWriterOptions): IOIMEntityWriter // stored object keeps its identity
1641
+ createOIMEntityWriterReplacing(options?: TOIMEntityWriterOptions): IOIMEntityWriter // a new object per write that changed something
1642
+ ```
1643
+
1644
+ `entityInsertStrategy` is the first-write strategy: `oimInsertEntityWithoutCopy` (the default) stores
1645
+ your object itself, `oimInsertEntityWithCopy` stores `{ ...entity }`, `oimInsertEntityWithSanitizedCopy`
1646
+ stores a copy with an own `__proto__` key removed (for untrusted JSON).
1647
+
1648
+ `deleteColumns` hands back **the names that actually moved**, a frozen empty
1649
+ array when nothing did — that list is what the entity content service routes to
1650
+ column subscribers, and it is why a write that changes nothing wakes nobody.
1651
+ **`putEntity` and `patchEntity` answer whether anything moved, and name the moved
1652
+ columns only into the array they are handed**: one pass compares and writes (a put
1653
+ then walks the stored columns the new value lacks), and with `undefined` they build
1654
+ no list at all. The entity content service asks whether the slot has changed-column readers — the slot has
1655
+ column slots, or its store slot has a content listener such as an index — and hands
1656
+ an array only then, so a write of an entity nobody reads by column allocates no names.
1657
+ The list it builds goes on to the listeners: an index wakes a bucket column exactly
1658
+ when a member's value of it changed, left, or became `undefined`. The first write
1659
+ into an empty slot tells listeners `undefined` ("everything"), not an empty list. `sameValueGuard` defaults to `oimSameValueZeroGuard` and is the
1660
+ per-column comparison that decides "moved".
1661
+
1662
+ `putEntity` is PUT, `patchEntity` is PATCH (HTTP semantics). PUT makes the stored entity
1663
+ the value handed in — changed columns written, columns the value lacks removed — in ONE
1664
+ call, returning both sets of names; PATCH writes only the columns named. `upsertEntity`
1665
+ of a key that already has a value and `slotContentService.writeEntity(slot, entity)` are PUT: a whole-entity
1666
+ write that drops a column is ONE channel write of the slot, and the replacing writer
1667
+ builds ONE new object.
1668
+
1669
+ Pick `createOIMEntityWriterReplacing` when something downstream compares by
1670
+ reference — a React `useSyncExternalStore` snapshot, a memo, a shallow-equal
1671
+ selector. Under `createOIMEntityWriterInPlace` the entity reference never moves,
1672
+ so a value comparison never fires and a binding needs a counter bumped inside its
1673
+ handler. Replacing allocates one object per changed write; in-place allocates
1674
+ none.
1675
+
1676
+ Neither writer `delete`s: a column a PUT drops or `deleteColumns` takes away is
1677
+ written `undefined` — into the stored object in place, into a spread copy when
1678
+ replacing — so the entity keeps its hidden class, and the key stays in
1679
+ `Object.keys` and `in`. A column holding `undefined` counts as absent — a later write that
1680
+ leaves it out does not report it, and taking the entity away does not wake its
1681
+ readers.
1682
+
1683
+ **Neither strategy reports anything.** Reporting is `OIMSlotContentEntityService`'s,
1684
+ and it wraps each writer call in a content subframe on the store slot's channel slot so the whole write is
1685
+ one logical change.
1686
+
1687
+ **A writer of your own** reuses the column walks both shipped writers run:
1688
+
1689
+ ```ts signature
1690
+ oimPatchEntityColumns(slot, patch: object, sameValueGuard, changedColumnNames: string[] | undefined,
1691
+ targetOf: (entity: Record<string, unknown>) => Record<string, unknown>): Record<string, unknown> | undefined
1692
+ oimPatchEntityColumn(slot, columnName: string, value: unknown, sameValueGuard, changedColumnNames: string[] | undefined,
1693
+ targetOf: (entity: Record<string, unknown>) => Record<string, unknown>): Record<string, unknown> | undefined
1694
+ oimPutEntityColumns(slot, entity: object, sameValueGuard, changedColumnNames: string[] | undefined,
1695
+ targetOf: (entity: Record<string, unknown>) => Record<string, unknown>): Record<string, unknown> | undefined
1696
+ oimPutEntityColumnsGuarded(slot, entity: object, sameValueGuard, changedColumnNames: string[] | undefined,
1697
+ targetOf: (entity: Record<string, unknown>) => Record<string, unknown>): Record<string, unknown> | undefined
1698
+ oimPatchEntityColumnsGuarded(slot, patch: object, sameValueGuard, changedColumnNames, targetOf) // same signature as the unguarded walk
1699
+ oimPatchEntityColumnGuarded(slot, columnName: string, value: unknown, sameValueGuard, changedColumnNames, targetOf)
1700
+ ```
1701
+
1702
+ Each compares every column through `sameValueGuard`, writes the moved ones into
1703
+ the object `targetOf` hands back on the first move (the stored entity in place, a
1704
+ copy when replacing), pushes their names into `changedColumnNames` when it is an
1705
+ array, and returns that object; `undefined` means nothing moved or the slot holds
1706
+ no entity. The put walk also writes `undefined` over every column the new value
1707
+ lacks. Each walk is a strategy, an option of the writer factories (`TOIMEntityWriterOptions`), and each
1708
+ ships two arms:
1709
+
1710
+ | Option | Default (unguarded) | Guarded arm |
1711
+ |---|---|---|
1712
+ | `putEntityColumns` | `oimPutEntityColumns` — skips neither `__proto__` keys nor inherited stored columns | `oimPutEntityColumnsGuarded` — skips both |
1713
+ | `patchEntityColumns` | `oimPatchEntityColumns` — every enumerable key of the patch, `__proto__` included | `oimPatchEntityColumnsGuarded` — skips a `__proto__` key and a key the patch does not own |
1714
+ | `patchEntityColumn` | `oimPatchEntityColumn` | `oimPatchEntityColumnGuarded` — refuses the column name `__proto__` |
1715
+
1716
+ Take the guarded arms, at a cost on every write, for values that can carry a `__proto__` key or are class
1717
+ instances — `createOIMEntityWriterInPlace({ putEntityColumns: oimPutEntityColumnsGuarded,
1718
+ patchEntityColumns: oimPatchEntityColumnsGuarded, patchEntityColumn: oimPatchEntityColumnGuarded })`, the
1719
+ same options on `createOIMEntityWriterReplacing`. The writer decides only where the result goes — this one
1720
+ stores every value frozen:
1721
+
1722
+ ```ts
1723
+ const copyColumns = (entity: Record<string, unknown>): Record<string, unknown> => ({ ...entity });
1724
+
1725
+ function storeFrozen(slot: TOIMEntitySlot, next: Record<string, unknown> | undefined): boolean {
1726
+ if (next === undefined) return false;
1727
+ (slot as TOIMSlotEditable<TOIMEntitySlot>).entity = Object.freeze(next);
1728
+ return true;
1729
+ }
1730
+
1731
+ const replacingWriter = createOIMEntityWriterReplacing();
1732
+
1733
+ const freezingWriter: IOIMEntityWriter = {
1734
+ fillEntity: (slot, entity) => { storeFrozen(slot, copyColumns(entity as Record<string, unknown>)); },
1735
+ clearEntity: replacingWriter.clearEntity,
1736
+ putEntity: (slot, entity, changedColumnNames) =>
1737
+ storeFrozen(slot, oimPutEntityColumns(slot, entity, oimSameValueZeroGuard, changedColumnNames, copyColumns)),
1738
+ patchEntity: (slot, patch, changedColumnNames) =>
1739
+ storeFrozen(slot, oimPatchEntityColumns(slot, patch, oimSameValueZeroGuard, changedColumnNames, copyColumns)),
1740
+ patchColumn: (slot, columnName, value, changedColumnNames) =>
1741
+ storeFrozen(slot, oimPatchEntityColumn(slot, columnName, value, oimSameValueZeroGuard, changedColumnNames, copyColumns)),
1742
+ deleteColumns: (slot, names) => {
1743
+ const deleted = replacingWriter.deleteColumns(slot, names);
1744
+ if (deleted.length !== 0) storeFrozen(slot, slot.entity as Record<string, unknown>);
1745
+ return deleted;
1746
+ },
1747
+ };
1748
+ ```
1749
+
1750
+ ---
1751
+
1752
+ ## Single entities
1753
+
1754
+ A single entity — one value under no key, such as settings or a session — is a store slot of an
1755
+ `OIMSingleSlotService` over an entity content service whose key type is `void` and whose store service is
1756
+ `OIMSlotStoreServiceSingleSlotDriven.INSTANCE`, which files at most one slot per store slot. It has no
1757
+ by-key doors: its read doors name the one slot.
1758
+
1759
+ ```ts signature
1760
+ createOIMSingleEntityService<TEntity, TColumnName?>(db, {
1761
+ entityWriter?, slotEmptiness?, slotReleasePolicy?, columnSlotStoreService?, columnNames?,
1762
+ }?): OIMSingleSlotService<TOIMEntitySlot<TEntity, void>, OIMSlotContentEntityService<TEntity, void, TOIMSingleSlotStore<…>, TColumnName>, …>
1763
+
1764
+ // the base layer it assembles
1765
+ createOIMSingleSlotService({
1766
+ slotContentService: createOIMSlotContentEntityService<TEntity>(db, {
1767
+ slotStoreService: OIMSlotStoreServiceSingleSlotDriven.INSTANCE, // no keyOf
1768
+ }),
1769
+ })
1770
+
1771
+ class OIMSingleSlotService<TSlot, TSlotContentService, TColumnName, …> {
1772
+ readonly slotContentService: TSlotContentService;
1773
+ createStoreSlot(channelMap: IOIMChannelMap): TOIMStoreSlot<TOIMSingleSlotStore<TSlot>, TSlot, TPatch>;
1774
+ findSingleSlot(storeSlot): TSlot | undefined; // mints nothing
1775
+ hasSingleSlotContent(storeSlot): boolean; // mints nothing
1776
+ getSingleSlot(storeSlot): TSlot; // minted if missing, tracked until the next flush
1777
+ holdSingleSlot(storeSlot): TSlot; // minted if missing, holdCount up by one
1778
+ getColumn / holdColumn / getAnyColumn / holdAnyColumn / getColumnGroup / holdColumnGroup / unholdColumn;
1779
+ }
1780
+ ```
1781
+
1782
+ One service serves every single entity of that type: each `createStoreSlot(db)` is another one, and starts
1783
+ with no value until something writes it. Its store slot is a `TOIMSingleSlotStoreSlot`, which is what
1784
+ `@oimdb/persist`'s single-entity source takes.
1785
+
1786
+ ```ts
1787
+ type Settings = { theme: 'light' | 'dark'; zoom: number };
1788
+
1789
+ const settingsService = createOIMSingleEntityService<Settings>(db);
1790
+ const settings = settingsService.createStoreSlot(db);
1791
+ settingsService.slotContentService.upsertEntity(settings, { theme: 'light', zoom: 1 });
1792
+ const settingsSlot = settingsService.holdSingleSlot(settings);
1793
+ const themeColumn = settingsService.holdColumn(settingsSlot, 'theme');
1794
+ const themeEffect = effectService.createEffect(db, [themeColumn], [], () => { console.log(settingsSlot.entity?.theme); });
1795
+ settingsService.slotContentService.updateEntity(settingsSlot, { theme: 'dark' });
1796
+ db.flush();
1797
+ effectService.disposeEffect(themeEffect);
1798
+ settingsService.unholdColumn(themeColumn);
1799
+ settingsSlot.owner.unholdSlot(settingsSlot);
1800
+ ```
1801
+
1802
+ **The slot is reached only through the service.** `getSingleSlot(storeSlot)` hands back the live slot, and
1803
+ mints a fresh one — tracked by the unused-slot collector until the next flush — when there is none;
1804
+ `holdSingleSlot(storeSlot)` does the same and takes a hold. Every store write door of the content service
1805
+ works on it as on a keyed store slot (`upsertEntity`, `clearAllEntities`), and every slot door
1806
+ (`updateEntity`, `updateEntityColumn`, `deleteEntityColumns`, `writeEntity`, `markEntityChanged`,
1807
+ `deleteEntity`) takes that slot.
1808
+
1809
+ The slot is of the keyed entity slot's literal shape, owned by the content service's slot owner —
1810
+ `settingsSlot.owner === settingsService.slotContentService.slotOwner`, and its `storeSlot` is `settings` — so
1811
+ `effectService.createEffect(db, [settingsSlot], [], …)` works and a column of it is a root like any other.
1812
+ Its key is `undefined`, and its store is `{ slot }` (`TOIMSingleSlotStore`). Its lifetime is the
1813
+ `slotReleasePolicy` (the db's, then `OIMSlotReleasePolicyAtEmpty`), so a single entity that holds a value
1814
+ stays; one emptied and unheld is released, and the slot `getSingleSlot` mints next holds no entity. The value
1815
+ is stored by the writer's `entityInsertStrategy`, which by default keeps the object handed in rather than a copy.
1816
+
1817
+ ---
1818
+
1819
+ ## Buckets
1820
+
1821
+ A bucket collection is, like an entity collection, a content service and the collection service over it.
1822
+ The content service is the strategy — how a bucket keeps its member slots — and it is chosen at the factory:
1823
+
1824
+ ```ts signature
1825
+ createOIMBucketService<TBucketMemberSlot = TOIMEntitySlot, TKey = TOIMStoreKey, TSlots?, TSlotContentService?, TStore?>(db, {
1826
+ slotContentService?: OIMSlotContentBucketService<TBucketMemberSlot, TSlots>; // default: array-driven, with commands
1827
+ maxCommandsPerSlot?: number; // for the default content service's OIMBucketCommands
1828
+ bucketSlotStoreService?: IOIMSlotStoreService<TKey, TOIMBucketSlot<TSlots, TKey>, TStore>; // Map; REQUIRED for an array TKey
1829
+ slotReleasePolicy?: IOIMSlotReleasePolicy;
1830
+ columnSlotStoreService?: IOIMSlotStoreService<string, TOIMColumnSlot>;
1831
+ columnNames?: readonly string[];
1832
+ }?): OIMSlotCollectionService<TKey, TOIMBucketSlot<TSlots, TKey>, TStore, TSlotContentService, …>
1833
+
1834
+ new OIMSlotContentBucketServiceSetDriven<TBucketMemberSlot>({ bucketCommands? })
1835
+ new OIMSlotContentBucketServiceArrayDriven<TBucketMemberSlot, TSlots?>({ bucketCommands?, bucketArrayMembership? })
1836
+ new OIMSlotContentBucketServiceSortedArrayDriven<TBucketMemberSlot>({ compareBucketMemberSlots })
1837
+ ```
1838
+
1839
+ A bucket holds bucket member slots of any slot type — entity slots, bucket slots, computeds — and its
1840
+ type parameter `TBucketMemberSlot extends TOIMSlot` (default `TOIMEntitySlot`) is that slot type, not the
1841
+ entity type. The services hold no bucket: `createStoreSlot(db)` is one keyed set of buckets, the read doors
1842
+ of the collection service take that store slot first, and the bucket doors of the content service take the
1843
+ bucket slot.
1844
+
1845
+ ```ts
1846
+ const colourService = createOIMBucketService<TOIMEntitySlot<Card>, TOIMStoreKey, TOIMBucketSlotsSetStore<TOIMEntitySlot<Card>>,
1847
+ OIMSlotContentBucketServiceSetDriven<TOIMEntitySlot<Card>>>(db, {
1848
+ slotContentService: new OIMSlotContentBucketServiceSetDriven<TOIMEntitySlot<Card>>(),
1849
+ });
1850
+ const colours = colourService.createStoreSlot(db);
1851
+ const red = colourService.holdSlotByKey(colours, 'red');
1852
+ colourService.slotContentService.writeBucketMemberSlots(red, [slot, cardService.getSlotByKey(cards, 'c2')]);
1853
+ db.flush();
1854
+ for (const deckSlot of colourService.slotContentService.getBucketMemberSlots(red)) { paint(deckSlot.entity); }
1855
+ colourService.slotContentService.clearBucketMemberSlots(red);
1856
+ red.owner.unholdSlot(red);
1857
+ ```
1858
+
1859
+ Every door below is the bucket content service's and takes the bucket slot:
1860
+
1861
+ | Door | Signature |
1862
+ |---|---|
1863
+ | `writeBucketMemberSlots(bucketSlot, bucketMemberSlots)` | `boolean` — states the whole bucket; idempotent; true when anything arrived or departed |
1864
+ | `addBucketMemberSlot(bucketSlot, bucketMemberSlot)` / `addBucketMemberSlots(bucketSlot, bucketMemberSlots)` | joins the named bucket member slots and touches nothing else. REMOVES NOTHING |
1865
+ | `removeBucketMemberSlot(bucketSlot, bucketMemberSlot)` / `removeBucketMemberSlots(bucketSlot, bucketMemberSlots)` | takes the named bucket member slots out and touches nothing else |
1866
+ | `clearBucketMemberSlots(bucketSlot)` | empties it |
1867
+ | `getBucketMemberSlots(bucketSlot)` | `Iterable<TBucketMemberSlot>` |
1868
+ | `getBucketMemberSlotCount(bucketSlot)` | `number` |
1869
+ | `hasBucketMemberSlot(bucketSlot, bucketMemberSlot)` | `boolean` — the content service's own membership test: one `Set.has` on a set-driven bucket, the membership's `hasSlot` on an array-driven one |
1870
+ | `markBucketColumnsChanged(bucketSlot, patch, changedColumns)` | `void` — tell the bucket's column slots which member columns moved |
1871
+ | `markContentChanged(bucketSlot)` | `void` — re-settle emptiness and re-ask the release guard after a write made another way |
1872
+ | `fillBucketMemberSlots(bucketSlot, bucketMemberSlots)` | `void` — fills a bucket slot nobody was told about yet; it throws on one that was already announced, which `addBucketMemberSlots` / `writeBucketMemberSlots` are for |
1873
+
1874
+ The entities of a bucket by key are the index's door, `indexService.getEntitiesByKey(indexStoreSlot, key)`
1875
+ ([The index that fills itself](#the-index-that-fills-itself--oimindexservice)).
1876
+
1877
+ ### Which door: you hold the answer, or you hold the delta
1878
+
1879
+ `writeBucketMemberSlots` is for a caller who has THE LIST THAT SHOULD BE THERE and does not
1880
+ know what changed — a search result, a page from a server, a filter re-run. One
1881
+ call, and the engine works out the arrivals and the departures. Its cost is
1882
+ proportional to WHAT THE BUCKET HOLDS, not to the change: the difference builds a
1883
+ map over the whole incoming list and walks the standing one, so a batch of ten
1884
+ into a bucket of two hundred pays for two hundred and ten.
1885
+
1886
+ `addBucketMemberSlots` / `removeBucketMemberSlots` are for a caller who KNOWS THE DELTA — an entity arrived
1887
+ over a socket, a card was dragged in, an infinite list appended a page, an entity was
1888
+ deleted. The standing slots keep the holds they already have. Adding costs what you
1889
+ name. Removing k slots from an ordered bucket of n is ONE pass, n + k, through the content service's
1890
+ `removeSlots` (the erase-remove idiom, as in Java's ArrayList.removeAll); k separate
1891
+ `removeBucketMemberSlot` calls are up to k·n. The recorded `remove` commands come
1892
+ in LIST order, not argument order, each at its position after the earlier ones
1893
+ applied, so replaying them draws the same list. A set-driven bucket removes them in
1894
+ the order you name them.
1895
+
1896
+ **`addBucketMemberSlots` removes nothing.** If the authoritative list SHRANK and you call
1897
+ `addBucketMemberSlots`, the entity slots that left stay in the bucket, held. Not knowing what left is
1898
+ exactly when `writeBucketMemberSlots` is the right door, and its cost is the price of not
1899
+ knowing.
1900
+
1901
+ The keyed doors are the collection service's and take the store slot first —
1902
+ `decksService.getSlotByKey(decks, key)`, `decksService.holdSlotByKey(decks, key)` — and a hold is given back
1903
+ with `bucketSlot.owner.unholdSlot(bucketSlot)`. There is no by-key write: resolve the bucket slot, then
1904
+ `decksService.slotContentService.writeBucketMemberSlots(bucketSlot, members)`.
1905
+
1906
+ A bucket's emptiness is the content service's `isSlotEmpty(bucketSlot)`, a member count of zero, so
1907
+ an emptied bucket that nothing holds and nothing watches is released by the
1908
+ guard on the same call that emptied it.
1909
+
1910
+ **A bucket never sorts.** Its order is what the caller put in — `addBucketMemberSlots`
1911
+ appends, `insertBucketMemberSlotAt` inserts, a move moves, `writeBucketMemberSlots` takes the
1912
+ sequence given. A sorted list is a computed over an index:
1913
+ [Sorted lists](#sorted-lists); or the bucket of a `createOIMIndexServiceSortedArrayDriven` index, which
1914
+ orders itself: [Sorted at write](#sorted-at-write).
1915
+
1916
+ `writeBucketMemberSlots(bucketSlot, members)` runs `assertWritable` — the frame guard
1917
+ (`assertNotFlushing`) and the released-identity check — then the reconcile (an empty `arrived` and
1918
+ `departed` is an unchanged write, which wakes nobody), the holds taken on arrivals, the holds given back by
1919
+ departures and the single channel write of the bucket slot. It is handed a slot, so it hands back a
1920
+ `boolean`: true when anything arrived or departed.
1921
+
1922
+ `writeBucketMemberSlots` **joins before it evicts**, so a slot named by both the old and the
1923
+ new list never reaches zero references. It empties with one `drainSlots` rather
1924
+ than one removal at a time — per-slot eviction on an array-driven bucket is
1925
+ n(n-1)/2 element moves.
1926
+
1927
+ The commit itself is the strategy's, through the content service's protected `reconcileSlots` — the
1928
+ reconciliation pattern (diff, then patch): bring the bucket to the new list and fill
1929
+ two out-queues with one entry per hold taken and one per hold given
1930
+ up, so the write links each of the first and unlinks each of the second. Both
1931
+ empty means nothing changed. Each content service reconciles its own way:
1932
+
1933
+ - **Array-driven**: the caller's sequence IS the slots, so the same sequence is
1934
+ left alone and any other sequence — a reordered one included — is drained and
1935
+ re-added whole.
1936
+ - **Set-driven** takes the set difference and never rebuilds: an untouched member
1937
+ keeps its hold.
1938
+
1939
+ **NO FRAME NUMBER IS PASSED ON THIS PATH.** `reconcileSlots` and `drainSlots` take none. A render script lives for exactly one frame:
1940
+ `OIMBucketCommands` records onto the bucket slot (`bucket.commands`) as the writes land and
1941
+ empties it once the flush has fully ended, so a consumer reads it INSIDE the
1942
+ frame and what it reads can only describe the writes it was woken for.
1943
+ `clearBucketMemberSlots` asks whether the bucket is empty **before** it
1944
+ touches the store, so an already-empty bucket records nothing for a call that
1945
+ reports nothing.
1946
+
1947
+ ### Bucket content services
1948
+
1949
+ | Class | Storage type | One add records |
1950
+ |---|---|---|
1951
+ | `OIMSlotContentBucketServiceSetDriven<TBucketMemberSlot>({ bucketCommands? })` | `TOIMBucketSlotsSetStore<TBucketMemberSlot>` = `Set<TBucketMemberSlot>` | one per slot |
1952
+ | `OIMSlotContentBucketServiceArrayDriven<TBucketMemberSlot, TSlots>({ bucketCommands?, bucketArrayMembership? })` | whatever `bucketArrayMembership` builds | whatever `bucketArrayMembership` records |
1953
+ | `OIMSlotContentBucketServiceSortedArrayDriven<TBucketMemberSlot>({ compareBucketMemberSlots })` | `TOIMBucketSlotsSortedArrayStore<TBucketMemberSlot>` | one per slot, at its sorted place |
1954
+
1955
+ The first two are the ones a caller picks between, and neither sorts: the array-driven service keeps the
1956
+ caller's order. The third is the one the sorted-at-write index builds for itself
1957
+ ([Sorted at write](#sorted-at-write)); its `repositionBucketMemberSlot(bucketSlot, bucketMemberSlot)` moves one
1958
+ member to its place after its sort columns changed.
1959
+
1960
+ `bucketArrayMembership` defaults to `OIMBucketArrayMembershipNonUnique.INSTANCE` and is the fork between one
1961
+ entry per mention and one per slot. There is no "ordered unique" class; you build one like this:
1962
+
1963
+ new OIMSlotContentBucketServiceArrayDriven<
1964
+ TOIMEntitySlot<Card>, TOIMBucketSlotsArrayUniqueStore<TOIMEntitySlot<Card>>
1965
+ >({
1966
+ bucketArrayMembership: new OIMBucketArrayMembershipUnique<TOIMEntitySlot<Card>>(),
1967
+ })
1968
+
1969
+ | Membership | Storage it builds | One add records |
1970
+ |---|---|---|
1971
+ | `OIMBucketArrayMembershipNonUnique.INSTANCE` (default) | `TOIMBucketSlotsArrayStore<TBucketMemberSlot>` | one per **mention** |
1972
+ | `new OIMBucketArrayMembershipUnique<TBucketMemberSlot>()` | `TOIMBucketSlotsArrayUniqueStore<TBucketMemberSlot>` | one per slot |
1973
+
1974
+ `IOIMBucketArrayMembership<TBucketMemberSlot, TSlots>`: `createStore`, `addSlot`, `removeSlot`,
1975
+ `hasSlot`, `clearSlots`, `containsTheSameSequence`. `hasBucketMemberSlot` asks the membership's `hasSlot`: the
1976
+ unique membership answers from its `Set` (no array walk), the non-unique one walks the
1977
+ array. Only the array-driven service takes one — a set-driven service keeps its slots IN a `Set`.
1978
+
1979
+ Nothing positional is on the abstract `OIMSlotContentBucketService`: the reads `getBucketMemberSlotAt` and
1980
+ `getBucketMemberSlotPosition` are on the array-based services (array-driven and sorted), and the five
1981
+ position writes on the array-driven one only. The set-driven service has none — a `Set` has no place to name,
1982
+ so asking it does not compile. A content service keeps no per-bucket state.
1983
+
1984
+ The protected `removeSlot` answers WHERE the slot was, so the door recording an edit script never asks for a
1985
+ position the removal itself had to compute. Two of its answers are not positions, and both are exported as
1986
+ named constants:
1987
+
1988
+ | returns | means |
1989
+ |---|---|
1990
+ | `0` and up | the index the slot occupied, now vacated |
1991
+ | `OIM_NOTHING_WAS_REMOVED` (`-1`) | the bucket did not hold it; nothing changed |
1992
+ | `OIM_REMOVED_WITHOUT_A_POSITION` (`-2`) | removed from a store that keeps no positions, which is the set-driven service |
1993
+
1994
+ A removal answering the third is recorded as `recordWholeList(bucketSlot)`: a set has no index to patch,
1995
+ so the consumer is told to re-read the bucket rather than handed a fabricated one.
1996
+
1997
+ `OIM_NOTHING_WAS_INSERTED` (`-1`) is the insert's answer when nothing was inserted, which is a repeat on an
1998
+ `OIMBucketArrayMembershipUnique`. The recorded insert names the index the slot LANDED on — `at` clamped to the
1999
+ ends of the list — and not `getBucketMemberSlotPosition`, because the default ordered list keeps one place per
2000
+ **mention**: inserting a slot the bucket already holds gives it a second position, and the position read
2001
+ names the FIRST. A command script built from it would tell the consumer to patch at an index the slot is not
2002
+ at, and the consumer's list would diverge from the bucket's permanently.
2003
+
2004
+ **A bucket's storage is `bucketSlot.bucketSlotsStore`.** The bucket's own type carries it
2005
+ (`TOIMBucketSlot<TOIMBucketSlotsArrayStore<TOIMEntitySlot<Card>>>`), so a mismatched pairing is a compile
2006
+ error. **One content service instance may serve two bucket collections** — it keeps no per-bucket state.
2007
+
2008
+ ### Positions the caller decides
2009
+
2010
+ A bucket whose positions are the caller's is one over `OIMSlotContentBucketServiceArrayDriven`, which is
2011
+ what `createOIMBucketService(db)` builds when it is given no content service — the `pageService` of the setup
2012
+ block. The two position reads and the five writes are that content service's, they take the bucket
2013
+ slot and no store slot, and an index is valid only until the next write:
2014
+
2015
+ | Door | What it does |
2016
+ |---|---|
2017
+ | `getBucketMemberSlotAt(bucketSlot, index)` | who sits there, `undefined` past the end |
2018
+ | `getBucketMemberSlotPosition(bucketSlot, bucketMemberSlot)` | where it sits, `-1` when it does not |
2019
+ | `insertBucketMemberSlotAt(bucketSlot, at, bucketMemberSlot)` | puts it at `at`, clamped to the ends of the list |
2020
+ | `removeBucketMemberSlotAt(bucketSlot, at)` | takes it out and hands it back, `undefined` past the end |
2021
+ | `removeBucketMemberSlotRange(bucketSlot, at, count)` | takes a run out and hands it back |
2022
+ | `moveBucketMemberSlotToPosition(bucketSlot, from, to)` | moves one slot; refuses `from === to` and either end out of range |
2023
+ | `moveBucketMemberSlotRangeToPosition(bucketSlot, from, count, to)` | moves a run; the landing is read in the list WITHOUT the run in it |
2024
+
2025
+ Every position and count must be a whole number: `NaN` or a fraction throws
2026
+ (`a position must be a whole number`) before anything moves, because the list
2027
+ and `splice` would read it two different ways. `±Infinity` is allowed and clamps
2028
+ like any other out-of-range position.
2029
+
2030
+ Each wraps a store change in the frame guard, the holds, the report and the
2031
+ release question. The default ordered list records one place per MENTION, so
2032
+ `insertBucketMemberSlotAt` of a slot the bucket already holds gives it a second position —
2033
+ build the content service with an `OIMBucketArrayMembershipUnique` when a position must name
2034
+ exactly one entity slot.
2035
+
2036
+ Per strategy, generating code:
2037
+
2038
+ ```ts
2039
+ const slotContentBucketService = pageService.slotContentService;
2040
+
2041
+ // kanban, between columns
2042
+ const cardItWasDroppedOn = cardService.getSlotByKey(cards, 'c2');
2043
+ slotContentBucketService.removeBucketMemberSlotAt(todo, slotContentBucketService.getBucketMemberSlotPosition(todo, slot));
2044
+ slotContentBucketService.insertBucketMemberSlotAt(
2045
+ done, slotContentBucketService.getBucketMemberSlotPosition(done, cardItWasDroppedOn) + 1, slot,
2046
+ );
2047
+
2048
+ // priority queue — raise to the front, then nudge down two
2049
+ slotContentBucketService.moveBucketMemberSlotToPosition(todo, slotContentBucketService.getBucketMemberSlotPosition(todo, slot), 0);
2050
+ slotContentBucketService.moveBucketMemberSlotToPosition(
2051
+ todo, slotContentBucketService.getBucketMemberSlotPosition(todo, slot),
2052
+ slotContentBucketService.getBucketMemberSlotPosition(todo, slot) + 2,
2053
+ );
2054
+
2055
+ // a whole list at once, in the order the server sent
2056
+ slotContentBucketService.writeBucketMemberSlots(todo, cardService.slotContentService.upsertEntities(cards, payload));
2057
+ ```
2058
+
2059
+ ### The render script: `OIMBucketCommands`
2060
+
2061
+ ```ts signature
2062
+ new OIMBucketCommands(options: { maxCommandsPerSlot?: number }) // default OIM_MAX_COMMANDS_PER_SLOT
2063
+
2064
+ // a bucket content service takes it at construction, once:
2065
+ new OIMSlotContentBucketServiceArrayDriven({ bucketCommands: new OIMBucketCommands({ maxCommandsPerSlot: Infinity }) });
2066
+ // createOIMBucketService(db, { maxCommandsPerSlot }) builds the default content service with one
2067
+ ```
2068
+
2069
+ **Recording is a strategy chosen when the bucket content service is built.** `bucketCommands` is read once
2070
+ into a readonly field; left out of a content service built with `new`, it is `OIMBucketCommandsNone`, whose
2071
+ every method is empty, so a bucket nobody renders positionally pays one empty call per write and allocates no
2072
+ command. The default content service of `createOIMBucketService` records. There is no setter: a content
2073
+ service records into the one recorder it was built with. One recorder serves every bucket of every store slot
2074
+ and of every db — it is stateless.
2075
+
2076
+ It writes the script onto the BUCKET slot itself, and is handed the member count for the cap below. The bucket
2077
+ content service already writes the bucket slot on every mutation, so a consumer subscribes to the bucket and
2078
+ reads the script inside the effect:
2079
+
2080
+ ```ts
2081
+ effectService.createEffect(db, [todo], [], () => {
2082
+ if (todo.isWholeListRecorded) render(Array.from(pageService.slotContentService.getBucketMemberSlots(todo)));
2083
+ else applyToTheRenderedList(todo.commands ?? []);
2084
+ });
2085
+ ```
2086
+
2087
+ `TOIMBucketSlot` carries two fields for it, and a consumer reads both:
2088
+
2089
+ | Field | What it says |
2090
+ |---|---|
2091
+ | `commands` | `readonly TOIMOrderedListCommand[]`, or `undefined` before the first write — what this frame's writes did, in order |
2092
+ | `isWholeListRecorded` | the script was given up on; read the bucket's slots and rebuild |
2093
+
2094
+ **READ BOTH INSIDE THE FRAME.** They are reset once the flush has FULLY ended —
2095
+ after `db.flush()` returns, `commands` is empty and `isWholeListRecorded` is
2096
+ `false` — so the only places they exist are an effect body and a
2097
+ `OIMChannelService.INSTANCE.subscribeAfterEachFrame(channelSlot, handler)` handler. No frame number is passed
2098
+ back: a consumer that did not read this frame's script reads the list instead.
2099
+
2100
+ A script is stamped with the clock reading it started at (`commandsStartedAtRevision`/`commandsStartedAtEpoch`),
2101
+ and it is stale once a frame has opened after that stamp (`channelSlot.frame.openedAtRevision`/`openedAtEpoch`).
2102
+ The recorder clears a stale script before it records into it, as well as in the after-flush reset, so a write
2103
+ that lands after a flush but before that reset starts a new script instead of extending one consumers already
2104
+ read. The reset is subscribed with `subscribeAfterEachFlush` on the first script a channel slot ever sees, and
2105
+ walks only the buckets that recorded.
2106
+
2107
+ A positional write is always recorded as a position. `isWholeListRecorded` is set by a write of the whole bucket — `writeBucketMemberSlots`,
2108
+ `clearBucketMemberSlots`, `moveBucketMemberSlotRangeToPosition` — by a removal from a bucket whose
2109
+ content service keeps no positions, which has no index to patch, and by the script
2110
+ outgrowing the list
2111
+ it describes. `maxCommandsPerSlot` is that ratio; it defaults to
2112
+ `OIM_MAX_COMMANDS_PER_SLOT`, which is 1, the same ratio
2113
+ `oimOrderedListCommandsOfTransition` takes as `maxCommandsPerSlot`. `Infinity` refuses the collapse
2114
+ outright, which is what a consumer animating item moves needs: a whole-list answer
2115
+ tells it everything changed and costs it every move it meant to animate.
2116
+
2117
+ `IOIMBucketCommands` is the interface the bucket content service records through:
2118
+ `recordInsert(bucketSlot, index, bucketMemberSlot, bucketMemberSlotCount)`,
2119
+ `recordRemove(bucketSlot, index, count, bucketMemberSlotCount)`, `recordMove(bucketSlot, from, to, bucketMemberSlotCount)`
2120
+ and `recordWholeList(bucketSlot)`. They take positions, not a command object, so the
2121
+ empty strategy is handed nothing to allocate. A consumer does not call the record methods; the
2122
+ content service's own write doors do.
2123
+
2124
+ ### A single bucket
2125
+
2126
+ One bucket under no key is a store slot of an `OIMSingleSlotService` over a bucket content service, with
2127
+ `OIMSlotStoreServiceSingleSlotDriven.INSTANCE` as the store service:
2128
+
2129
+ ```ts signature
2130
+ createOIMSingleBucketService<TBucketMemberSlot = TOIMEntitySlot, TSlots?, TSlotContentService?>(db, {
2131
+ slotContentService?, maxCommandsPerSlot?, slotReleasePolicy?, columnSlotStoreService?, columnNames?,
2132
+ }?): OIMSingleSlotService<TOIMBucketSlot<TSlots, void>, TSlotContentService, …>
2133
+ ```
2134
+
2135
+ ```ts
2136
+ const handService = createOIMSingleBucketService<TOIMEntitySlot<Card>>(db);
2137
+ const hand = handService.createStoreSlot(db);
2138
+ const handSlot = handService.holdSingleSlot(hand);
2139
+ handService.slotContentService.writeBucketMemberSlots(handSlot, [slot]);
2140
+ handService.slotContentService.insertBucketMemberSlotAt(handSlot, 0, cardService.getSlotByKey(cards, 'c2'));
2141
+ effectService.disposeEffect(effectService.createEffect(db, [handSlot], [], () => {
2142
+ render(Array.from(handService.slotContentService.getBucketMemberSlots(handSlot)));
2143
+ }));
2144
+ handSlot.owner.unholdSlot(handSlot);
2145
+ ```
2146
+
2147
+ One bucket, no key: the single-slot store files its one bucket slot under `undefined`. The bucket slot is a
2148
+ plain `TOIMBucketSlot<TSlots, void>`, what an effect subscribes to and what a reader is handed,
2149
+ so the channel's write door still sees three slot shapes. Every door of the bucket content service takes it as
2150
+ it takes a keyed bucket slot, and `markBucketColumnsChanged(bucketSlot, patch, changedColumns)` is how the
2151
+ caller that changed members says which of their columns moved, as the index does for a keyed bucket. Each
2152
+ door opens its own subframe on the channel slot and coalesces the bucket's change to one channel write per
2153
+ subframe.
2154
+
2155
+ **Its lifetime is the `slotReleasePolicy`.** With none named (here or on the db) it is
2156
+ `OIMSlotReleasePolicyAtEmpty.INSTANCE`, so an emptied single bucket that nothing holds or watches is
2157
+ released, and `getSingleSlot(storeSlot)` mints a fresh, empty one on the next read. Hold the
2158
+ slot (`holdSingleSlot(storeSlot)`) for as long as you write into it, or name `OIMSlotReleasePolicyNever.INSTANCE`.
2159
+
2160
+ ### The index that fills itself — `OIMIndexService`
2161
+
2162
+ ```ts signature
2163
+ createOIMIndexService<TEntity, TSourceKey, TColumns?, TKey?, TSourceStore?, TSlots?, TSlotContentService?>(db, {
2164
+ entityService: Pick<OIMSlotCollectionService<TSourceKey, TOIMEntitySlot<TEntity, TSourceKey>, TSourceStore, unknown>, 'getSlots'>;
2165
+ ...TOIMIndexKeyOptions<TKey, TEntity, TColumns>; // { columns } | { keyOf } | { keyOf, columns }
2166
+ slotContentService?: OIMSlotContentBucketService<TOIMEntitySlot<TEntity>, TSlots>; // default: array-driven, no commands
2167
+ bucketSlotStoreService?: IOIMSlotStoreService<TKey, TOIMBucketSlot<TSlots, TKey>, TStore>; // REQUIRED for an array TKey
2168
+ slotReleasePolicy?: IOIMSlotReleasePolicy;
2169
+ columnSlotStoreService?: IOIMSlotStoreService<string, TOIMColumnSlot>;
2170
+ columnNames?: readonly string[];
2171
+ }): OIMIndexService<TKey, TEntity, TSlots, TSourceKey, TSourceStore, TSlotContentService, …>
2172
+
2173
+ class OIMIndexService<…> extends OIMSlotCollectionService<…> {
2174
+ createIndexStoreSlot(sourceStoreSlot: TOIMStoreSlot<TSourceStore, TOIMEntitySlot<TEntity, TSourceKey>, Partial<TEntity>>): TOIMIndexStoreSlot<…>;
2175
+ disposeIndexStoreSlot(indexStoreSlot: TOIMIndexStoreSlot<…>): void;
2176
+ getEntitiesByKey(indexStoreSlot, key): (TEntity | undefined)[];
2177
+ getSlotsByKeys(indexStoreSlot, keys): TBucketSlot[];
2178
+ holdSlotsByKeys(indexStoreSlot, keys): TBucketSlot[];
2179
+ getBucketColumnByKey / holdBucketColumnByKey(indexStoreSlot, key, columnName): TOIMColumnSlot;
2180
+ getAnyBucketColumnByKey / holdAnyBucketColumnByKey(indexStoreSlot, key): TOIMColumnSlot;
2181
+ }
2182
+ ```
2183
+
2184
+ `OIMIndexService` is a collection service of buckets, so it has every read door of one, and its
2185
+ `slotContentService` is the bucket content service narrowed by type to its reads (`getBucketMemberSlots`,
2186
+ `getBucketMemberSlotCount`, `hasBucketMemberSlot`, and the positional reads where the strategy has them) — the
2187
+ index is the only writer of its buckets. The strategy is chosen at the factory:
2188
+ `OIMSlotContentBucketServiceArrayDriven` by default, `OIMSlotContentBucketServiceSetDriven` passed as
2189
+ `slotContentService`, and the sorted one built by `createOIMIndexServiceSortedArrayDriven`. Only the array-based
2190
+ content services answer by position: `getBucketMemberSlotAt(bucketSlot, index)` (O(1), `undefined` past the
2191
+ end) and `getBucketMemberSlotPosition(bucketSlot, entitySlot)` (walks the array, O(n), `-1` for a non-member).
2192
+ The index's default content service records no commands; pass one built with `bucketCommands` for a render
2193
+ script.
2194
+
2195
+ **One index service per entity collection type; one index store slot per entity store slot.**
2196
+ `indexService.createIndexStoreSlot(entityStoreSlot)` registers the index on that entity store slot
2197
+ and hands back the index's bucket store slot, a `TOIMIndexStoreSlot` — a store slot of buckets
2198
+ that also names its `sourceStoreSlot`. Entity slots are filed into it by a key derived from the entity. You
2199
+ read through the index service, passing that store slot; the index is what writes its buckets.
2200
+ **No bucket exists for a key until it is minted**: `getSlotByKey`,
2201
+ `holdSlotByKey`, `getSlotsByKeys` and `holdSlotsByKeys` mint it and fill it from the source entities
2202
+ written before it, without a flush, so minting inside an effect body does not throw.
2203
+ `getSlotsByKeys` / `holdSlotsByKeys` fill every new bucket in ONE pass over the source. How long it lives after that is **the
2204
+ `slotReleasePolicy`'s ordinary business**, exactly as for a hand-held bucket; the index injects no lifetime of its
2205
+ own. With none named it is `OIMSlotReleasePolicyAtEmpty.INSTANCE`: the bucket stands while it has members and is
2206
+ maintained on every write.
2207
+
2208
+ ```ts
2209
+ const byDeckService = createOIMIndexService(db, {
2210
+ entityService: cardService,
2211
+ keyOf: (card: Card) => card.deck,
2212
+ });
2213
+ const byDeck = byDeckService.createIndexStoreSlot(cards);
2214
+
2215
+ const spades = byDeckService.holdSlotByKey(byDeck, 'spades');
2216
+ effectService.createEffect(db, [spades], [], () => render(Array.from(byDeckService.slotContentService.getBucketMemberSlots(spades))));
2217
+ ```
2218
+
2219
+ A set-driven index names its content service, whose bucket member slot type is spelled out —
2220
+ `new OIMSlotContentBucketServiceSetDriven()` infers `<TOIMSlot>` and fails to type-check:
2221
+
2222
+ ```ts
2223
+ const bySuitService = createOIMIndexService(db, {
2224
+ entityService: cardService,
2225
+ columns: ['deck'],
2226
+ slotContentService: new OIMSlotContentBucketServiceSetDriven<TOIMEntitySlot<Card>>(),
2227
+ });
2228
+ const bySuit = bySuitService.createIndexStoreSlot(cards);
2229
+ ```
2230
+
2231
+ - **Passing the entity store slot to `createIndexStoreSlot` is the whole wiring.** It registers
2232
+ a content-change listener on that store slot (`OIMContentChangeListenerService.INSTANCE.addContentChangeListener`);
2233
+ the entity services know nothing about indexes. Call `createIndexStoreSlot` twice over one entity
2234
+ store slot and it files into both. `entityService` is used only for its `getSlots`, to fill a new bucket
2235
+ from the source.
2236
+ - **The key comes from `columns`, `keyOf`, or both** — the same pair as the sorter's
2237
+ `sortColumns` / `comparator` (`TOIMIndexKeyOptions`). `{ columns: ['deck'] }`: the key is
2238
+ the value of that ONE column, typed by `TOIMIndexKey<TEntity, TColumns>` from the
2239
+ column itself. `{ keyOf }`: any function, read again on every write, because a
2240
+ function does not say which columns it reads. `{ keyOf, columns }`: the columns are the
2241
+ ones `keyOf` reads. With `columns`, a patch or put whose changed columns include none of
2242
+ them keeps the entity's key and calls nothing; a write of the whole value (a fill, a
2243
+ clear, `markEntityChanged(slot)` with no names) always reads the key again.
2244
+ - **A key of several columns** takes its own door: `createOIMIndexServiceByCompositeKey(db,
2245
+ { entityService, columns: ['deck', 'suit'] })` makes the key the array of those values in order, typed as a
2246
+ tuple. It builds the bucket store on `OIMSlotStoreServiceTreeDriven` over
2247
+ `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()`, and only that store knows the key is an array (its
2248
+ `isSameKey`), so a fresh array with the same parts moves nothing. `createOIMIndexService` takes a scalar key
2249
+ unless it is handed a `bucketSlotStoreService`: two columns or an array `keyOf` without one do not compile.
2250
+ `getSlotsByKeys` / `holdSlotsByKeys` fill array-keyed buckets one pass over the
2251
+ source per key (the cost of `getSlotByKey` each); only a scalar key gets one pass for all.
2252
+ - **Entity slots are filed on write**, using the index's key. Batch doors (`upsertEntities`,
2253
+ `insertEntities`/`fillEntities`, `deleteEntities`, `clearAllEntities`) notify once per
2254
+ batch; the index groups the entity slots by key and moves each group with one bucket write.
2255
+ - **Reading**, each taking the index store slot first: `getSlotByKey`, `holdSlotByKey`, `findSlotByKey`,
2256
+ `hasSlotContentByKey`, `getSlotsByKeys`, `holdSlotsByKeys`, `getSlots`, `getSlotCount`, `getEntitiesByKey`, and the bucket column
2257
+ slot doors `getAnyBucketColumnByKey`, `holdAnyBucketColumnByKey`,
2258
+ `getBucketColumnByKey`, `holdBucketColumnByKey`; on a bucket slot, `slotContentService.getBucketMemberSlots`,
2259
+ `getBucketMemberSlotCount`, `hasBucketMemberSlot`; and `unholdColumn` (below).
2260
+ `getEntitiesByKey` resolves the bucket with `getSlotByKey`, so a key with no bucket MINTS one — one `keyOf`
2261
+ walk over the source — and an empty unheld bucket is released at the flush, so reading an empty key every
2262
+ frame walks every frame. Hold a key you read repeatedly.
2263
+ - **The channel** is the index store slot's `channelSlot`, the source store slot's — what a
2264
+ React hook checks before subscribing.
2265
+ - **`disposeIndexStoreSlot(indexStoreSlot)`** unsubscribes from the source store slot, empties that
2266
+ store slot's buckets and gives back every entity slot it held. The minting doors (`getSlotByKey`,
2267
+ `holdSlotByKey`, `getSlotsByKeys`, `holdSlotsByKeys`, `getEntitiesByKey`, the bucket column doors)
2268
+ throw after it, for that store slot;
2269
+ the two give-backs (`slot.owner.unholdSlot`, `indexService.unholdColumn`)
2270
+ still take back a hold given out before it, so a cleanup that runs late does not
2271
+ throw.
2272
+ - **A key with no bucket is not maintained at all**, so a write under such a key
2273
+ costs nothing beyond one key read and one comparison — no bucket store, no
2274
+ maintenance nodes. What decides it is the BUCKET, not the hold: a standing bucket
2275
+ is maintained whether anyone holds it or not.
2276
+ - **Order is not the bucket's business, unless you pick the strategy that makes it so.**
2277
+ A bucket keeps its arrival order; a sorted list is
2278
+ `createOIMComputedServiceSorted(db, { indexService, sortColumns?, comparator? })`
2279
+ — see [Sorted lists](#sorted-lists) — or the index is built with
2280
+ `createOIMIndexServiceSortedArrayDriven`, whose buckets are already in order — see
2281
+ [Sorted at write](#sorted-at-write). A second computed over the bucket handles
2282
+ projection or filtering.
2283
+ - **Members are read through the bucket content service**: `Array.from(indexService.slotContentService.getBucketMemberSlots(bucketSlot))`.
2284
+ There is no `.value`.
2285
+ - **Subscription is on the bucket, not a computed.** The bucket is a ROOT slot, so it
2286
+ goes in the FIRST array of `createEffect` — not the second. **The bucket wakes on
2287
+ membership only**: an entity slot joining or leaving. A write to an entity that stays in the
2288
+ bucket does not wake it — subscribe to that entity's own slot for that.
2289
+ - **Bucket lifetime is the `slotReleasePolicy` option, not the index's.** Name nothing (here or on the db)
2290
+ and it is `OIMSlotReleasePolicyAtEmpty.INSTANCE`: an unheld bucket stays while it has members and goes
2291
+ once empty; `OIMSlotReleasePolicyNever.INSTANCE` keeps it for good; pass an `OIMSlotReleasePolicyAfterIdle`
2292
+ and it cools on its queue; pass `OIMSlotReleasePolicyAtUnhold.INSTANCE` and it dies at the last give-back,
2293
+ and the next hold builds a fresh one by walking the source again.
2294
+
2295
+ **The bucket's any-column slot exists, but it is not the recommended path.**
2296
+ `holdAnyBucketColumnByKey(indexStoreSlot, key)` (and `getAnyBucketColumnByKey(indexStoreSlot, key)`) returns a
2297
+ `TOIMColumnSlot` on the bucket slot that wakes when the content of a member changes —
2298
+ any write to a member inside. A member joining or leaving is NOT its business: that wakes
2299
+ the bucket slot (`getSlotByKey` / `holdSlotByKey`). A reader that wants both subscribes
2300
+ to both, and the subscriber picks by which slots it puts in `createEffect`.
2301
+
2302
+ ```ts
2303
+ const deckIndexService = createOIMIndexService(db, { entityService: cardService, keyOf: (card: Card) => card.deck });
2304
+ const deckIndex = deckIndexService.createIndexStoreSlot(cards);
2305
+ const spadesBucket = deckIndexService.holdSlotByKey(deckIndex, 'spades');
2306
+ const spadesAnyColumn = deckIndexService.holdAnyBucketColumnByKey(deckIndex, 'spades');
2307
+ const entitiesEffect = effectService.createEffect(db, [spadesBucket, spadesAnyColumn], [], () => {
2308
+ console.log(deckIndexService.getEntitiesByKey(deckIndex, 'spades'));
2309
+ });
2310
+ effectService.disposeEffect(entitiesEffect);
2311
+ deckIndexService.unholdColumn(spadesAnyColumn);
2312
+ spadesBucket.owner.unholdSlot(spadesBucket);
2313
+ ```
2314
+
2315
+ A column slot taken with `getAnyBucketColumnByKey` and subscribed to is held by the
2316
+ effect, so a plain getter is enough: the slot lives while the effect does and goes at the
2317
+ next `flush()` after `disposeEffect`; one nobody kept is gone at the next `flush()`.
2318
+
2319
+ **Do not reach for it to render a list.** Every edit of every member wakes whoever
2320
+ reads it, so a list bound to it re-renders whole for a one-field change. Subscribe
2321
+ the list to the bucket (membership) and each item to its own entity slot — that is the
2322
+ path the engine is built for. The any-column slot is for a reader that genuinely
2323
+ needs the whole list each time: an export, an aggregate over the entities, a log.
2324
+
2325
+ It costs nothing until someone asks for one: a write is checked against it only after
2326
+ the index has been asked for the first any-column slot.
2327
+
2328
+ **The content-change hook.** `OIMContentChangeListenerService.INSTANCE.addContentChangeListener(storeSlot, listener, readColumnNames): void`
2329
+ and `removeContentChangeListener(storeSlot, listener): boolean` file and unfile a listener on that one store
2330
+ slot, by the listener object. `addReadColumn` / `removeReadColumn` / `addReadAnyColumn` / `removeReadAnyColumn`
2331
+ declare reads without a listener of their own, which is how an index declares its bucket columns.
2332
+ **Which columns a listener reads** is declared when it is added: `readColumnNames` lists them, and
2333
+ `undefined` means every column. Removing the listener gives its columns back. An
2334
+ index store slot declares its key `columns` (all columns when the key is an opaque `keyOf`), and
2335
+ adds each column its bucket column slots watch, and all columns once a bucket any-column slot was
2336
+ asked for. Column slots on entities are not in it: a write finds them on the entity
2337
+ itself. A write whose changed columns no listener reads costs one `Map.has` per name:
2338
+ no listener is called, nothing is buffered, and the names array is reused across writes. A write that names no columns
2339
+ (a fill, a clear) always reaches the listeners. A listener of your own that must see
2340
+ every column write is added with `readColumnNames` `undefined`. **The filter is the union, per store
2341
+ slot, not per listener**: once a write touches a column ANY listener of that store reads, every
2342
+ listener of that store is called, so a listener that declared a narrower set still checks
2343
+ `changedColumns` itself, as `OIMIndexService` does against its key columns.
2344
+ `IOIMContentChangeListener<TSlot, TPatch>` has two methods:
2345
+ `onContentChanged(slot, patch, changedColumns)` and
2346
+ `onContentChangedMany(slotChannelQueue, patchChannelQueue, changedColumnsChannelQueue)` — three
2347
+ parallel `OIMChannelQueue`s read with `getEntryCount()` / `getEntryAt(i)`, valid only during the
2348
+ call, so a listener copies what it keeps — `patch` is what an update
2349
+ wrote, `changedColumns` the names `markEntityChanged(slot, columns)` was given, either
2350
+ `undefined` when the write carries none. A content service holds a store slot's notifications until ITS
2351
+ outermost content subframe closes: a single write delivers one `onContentChanged` when its own
2352
+ subframe closes, and a many-entity door (`upsertEntities`, `insertEntities`, `fillEntities`,
2353
+ `deleteEntities`, `clearAllEntities`) delivers ONE
2354
+ `onContentChangedMany` when the door returns. A subframe the caller opens on the channel slot
2355
+ does not hold them — an index is up to date as soon as the content service's own door returns.
2356
+ This hook is the only seam an index uses; an index of your own is a listener.
2357
+
2358
+ **Bucket column slots.** An index hands out one column slot per bucket and column:
2359
+ `getBucketColumnByKey(indexStoreSlot, key, columnName)`, `holdBucketColumnByKey(indexStoreSlot, key, columnName)`,
2360
+ `indexService.unholdColumn(columnSlot)`. It is a `TOIMColumnSlot` whose `carrierSlot` is the
2361
+ bucket slot, and it is written when a write changes that column on any member of the
2362
+ bucket (the index calls its bucket content service's `markBucketColumnsChanged(bucketSlot, patch, changedColumns)`,
2363
+ as the caller does on a single bucket).
2364
+ The any-column doors `getAnyBucketColumnByKey(indexStoreSlot, key)` / `holdAnyBucketColumnByKey(indexStoreSlot, key)`
2365
+ return the same kind of slot, written when any column of any member changes. A give-back is
2366
+ `slot.owner.unholdSlot(slot)`.
2367
+
2368
+ ```ts
2369
+ const decksService = createOIMIndexService(db, { entityService: cardService, keyOf: (card: Card) => card.deck });
2370
+ const decks = decksService.createIndexStoreSlot(cards);
2371
+ const rankOfSpades = decksService.holdBucketColumnByKey(decks, 'spades', 'rank');
2372
+ effectService.disposeEffect(effectService.createEffect(db, [rankOfSpades], [], () => {}));
2373
+ decksService.unholdColumn(rankOfSpades);
2374
+ ```
2375
+
2376
+ ### Sorted lists
2377
+
2378
+ ```ts signature
2379
+ createOIMComputedServiceSorted<TKey, TEntity extends object, TSlots, TSourceKey, TSourceStore, TComputedStore?>({
2380
+ indexService: OIMIndexService<TKey, TEntity, TSlots, TSourceKey, TSourceStore>;
2381
+ sortColumns?: readonly TOIMSortColumn<TEntity>[]; // { column: TOIMSortableColumnName<TEntity>; direction: 'asc' | 'desc' }
2382
+ comparator?: (left: TEntity, right: TEntity) => number;
2383
+ slotReleasePolicy?: IOIMSlotReleasePolicy; // default OIMSlotReleasePolicyAtUnhold.INSTANCE
2384
+ columnSlotStoreService?: IOIMSlotStoreService<string, TOIMComputedColumnSlot>;
2385
+ computedSlotStoreService?: IOIMSlotStoreService<TKey, TOIMComputedSlot<…>, TComputedStore>; // Map; REQUIRED for any other store
2386
+ }): OIMComputedServiceSorted<TKey, TEntity, TSlots, TSourceKey, TSourceStore, TComputedStore>
2387
+
2388
+ createSortedStoreSlot(indexStoreSlot: TOIMIndexStoreSlot<…>): TOIMStoreSlot<TComputedStore, TOIMSortedComputedSlot<TEntity, TKey>>
2389
+ readonly computedCollectionService: OIMSlotCollectionService<TKey, TOIMSortedComputedSlot<TEntity, TKey>, TComputedStore, …>
2390
+ ```
2391
+
2392
+ `slotReleasePolicy` decides whether a key's sorted list outlives its last hold. The
2393
+ default lets it go at the unhold, so holding the key again sorts from scratch;
2394
+ `OIMSlotReleasePolicyNever.INSTANCE` keeps every key that was ever read and re-sorts it
2395
+ only when a read finds it changed — the arm for a view that switches between groups.
2396
+ Pass the same policy to the index the list reads, or its bucket is rebuilt instead.
2397
+
2398
+ An `OIMComputedServiceSorted` takes no db: its store slots run on the channel slot of the index store slot
2399
+ they sort. One per index service. `createSortedStoreSlot(indexStoreSlot)` makes one sorted store slot over
2400
+ one index store slot, and the read doors of `computedCollectionService` take that store slot first:
2401
+ `getSlotByKey`, `holdSlotByKey`, `findSlotByKey`, `getSlots`, `getSlotCount`, and the column doors; per key a
2402
+ computed slot whose value, read through `slot.computedValueService.getValueForSlot(slot)`, is that key's entity
2403
+ slots sorted. A hold is given back with `slot.owner.unholdSlot(slot)`.
2404
+
2405
+ - **`sortColumns` decide which writes re-sort**: only a write that changes one of them on a
2406
+ member. A `TOIMSortableColumnName<TEntity>` is a column whose values are strings,
2407
+ numbers or bigints.
2408
+ - **`comparator` decides the order.** Without it the order is `oimColumnsComparator(sortColumns)`:
2409
+ the columns in turn, each by its `direction`, an undefined or null value last. An
2410
+ entity slot with no entity sorts last.
2411
+ - **Only `comparator`** → any write to a member re-sorts, since nothing names the
2412
+ columns it reads.
2413
+ - **Neither** → a member keeps the place it first took; an arrival is appended.
2414
+ - **Both `sortColumns` and `comparator`** → the two split the job: `comparator` orders,
2415
+ `sortColumns` is the list of columns whose change wakes the sort. **List every column
2416
+ the comparator reads**: a change to a column it reads but `sortColumns` omits does not
2417
+ re-sort, and the list stays in its old order until something listed changes. This
2418
+ is the combination to reach for when the order needs a rule `oimColumnsComparator`
2419
+ cannot express — a locale compare, a computed key, a tie-break — and the sort
2420
+ should still wake only for the columns that rule reads, instead of for every write
2421
+ to a member as with `comparator` alone.
2422
+
2423
+ ```ts
2424
+ const cardsInDeckService = createOIMIndexService(db, {
2425
+ entityService: cardService,
2426
+ keyOf: (card: Card) => card.deck,
2427
+ });
2428
+ createOIMComputedServiceSorted({
2429
+ indexService: cardsInDeckService,
2430
+ sortColumns: [{ column: 'title', direction: 'asc' }, { column: 'rank', direction: 'asc' }],
2431
+ comparator: (l, r) => l.title.localeCompare(r.title, 'de') || l.rank - r.rank,
2432
+ }).createSortedStoreSlot(cardsInDeckService.createIndexStoreSlot(cards));
2433
+ // a write to `title` or `rank` re-sorts; a write to `note` does not
2434
+ ```
2435
+
2436
+ Its sources are the bucket slot of the key (membership) plus the bucket column slot
2437
+ of each column — or the bucket's any-column slot when only a comparator is given. It is
2438
+ a pull computed: re-derived on the read that asks; an effect subscribes to it in the
2439
+ SECOND array of `createEffect`. A re-sort that yields the same sequence
2440
+ (`oimSameSlotSequence`) keeps the previous array, so its identity is stable.
2441
+
2442
+ ```ts
2443
+ const decksService = createOIMIndexService(db, { entityService: cardService, keyOf: (card: Card) => card.deck });
2444
+ const decks = decksService.createIndexStoreSlot(cards);
2445
+ const byRankInDeckService = createOIMComputedServiceSorted({
2446
+ indexService: decksService,
2447
+ sortColumns: [{ column: 'rank', direction: 'asc' }],
2448
+ });
2449
+ const byRankInDeck = byRankInDeckService.createSortedStoreSlot(decks);
2450
+ const spadesByRank = byRankInDeckService.computedCollectionService.holdSlotByKey(byRankInDeck, 'spades');
2451
+ const sortedEffect = effectService.createEffect(db, [], [spadesByRank], () => {
2452
+ render(spadesByRank.computedValueService.getValueForSlot(spadesByRank));
2453
+ });
2454
+ effectService.disposeEffect(sortedEffect);
2455
+ spadesByRank.owner.unholdSlot(spadesByRank);
2456
+ ```
2457
+
2458
+ ### Sorted at write
2459
+
2460
+ A second way to get a sorted list. `createOIMComputedServiceSorted` sorts at read, over any
2461
+ index. `createOIMIndexServiceSortedArrayDriven` keeps the index's own buckets sorted at write, and
2462
+ the user picks one at construction.
2463
+
2464
+ ```ts signature
2465
+ createOIMIndexServiceSortedArrayDriven<TEntity extends object, TSourceKey, TColumns?, TKey extends TOIMStoreKey?, TSourceStore?, TStore?>(db, {
2466
+ entityService, ...TOIMIndexKeyOptions<TKey, TEntity, TColumns>, // as createOIMIndexService, scalar key
2467
+ sortColumns: readonly TOIMSortColumn<TEntity>[]; // REQUIRED
2468
+ bucketSlotStoreService?, slotReleasePolicy?, columnSlotStoreService?, columnNames?,
2469
+ }): OIMIndexServiceSortedArrayDriven<…> // its slotContentService is an OIMSlotContentBucketServiceSortedArrayDriven
2470
+ ```
2471
+
2472
+ ```ts
2473
+ declare const someCardSlot: TOIMEntitySlot<Card, TOIMStoreKey>;
2474
+ const cardsByDeckService = createOIMIndexServiceSortedArrayDriven(db, {
2475
+ entityService: cardService,
2476
+ columns: ['deck'],
2477
+ sortColumns: [{ column: 'rank', direction: 'asc' }, { column: 'id', direction: 'desc' }],
2478
+ });
2479
+ const cardsByDeck = cardsByDeckService.createIndexStoreSlot(cards);
2480
+ const spades = cardsByDeckService.holdSlotByKey(cardsByDeck, 'spades');
2481
+ for (const cardSlot of cardsByDeckService.slotContentService.getBucketMemberSlots(spades)) { /* in rank order */ }
2482
+ cardsByDeckService.slotContentService.getBucketMemberSlotAt(spades, 0);
2483
+ cardsByDeckService.slotContentService.getBucketMemberSlotPosition(spades, someCardSlot);
2484
+ ```
2485
+
2486
+ - **Reads come back in order**: `getBucketMemberSlots`, `getBucketMemberSlotAt` and
2487
+ `getBucketMemberSlotPosition` on `OIMSlotContentBucketServiceSortedArrayDriven` answer in `sortColumns` order,
2488
+ so no sorted computed is needed.
2489
+ - **A lazy insertion buffer.** A write appends the arrival to an unsorted tail of the bucket's
2490
+ array (`TOIMBucketSlotsSortedArrayStore`: the array store plus `sortedCount`). The first read
2491
+ settles the tail: binary insertion when the tail is short, one sort of the array when it is
2492
+ long. A bucket nobody reads is never sorted.
2493
+ - **The index re-files a member when one of its sort columns changes, and only then.** A write
2494
+ to a column that is neither the key nor a sort column neither reorders the bucket nor wakes it.
2495
+ A re-file wakes the bucket once per frame.
2496
+ - **There are no positional writes** on this bucket (`insertBucketMemberSlotAt` and the other
2497
+ position writes belong to `OIMSlotContentBucketServiceArrayDriven`): the order is the comparator's,
2498
+ not the caller's.
2499
+ - **Bucket commands are always `OIMBucketCommandsNone`**: this strategy records no per-flush
2500
+ script.
2501
+ - **Parts**: `OIMSlotContentBucketServiceSortedArrayDriven` (options
2502
+ `TOIMSlotContentBucketServiceSortedArrayDrivenOptions`: `compareBucketMemberSlots`; adds
2503
+ `repositionBucketMemberSlot(bucketSlot, bucketMemberSlot)`), `OIMIndexServiceSortedArrayDriven`.
2504
+
2505
+ **Choosing between the two arms.** Sort-at-write suits point updates: a change to a sort
2506
+ column re-files one member. Sort-at-read (`createOIMComputedServiceSorted` over any index)
2507
+ suits bulk loads. Both are shipped; neither is a default.
2508
+
2509
+ **`indexKeys` on each entity slot.** Each indexable slot carries, per index store slot, the key the entity is
2510
+ filed under now, so a moved entity slot leaves its old bucket in O(1), also under the in-place
2511
+ writer. Only the index writes it, through the `IOIMIndexKeysService` (`get(slot, position)`,
2512
+ `set(slot, position, indexKey)`), at the position `createIndexStoreSlot` allocated
2513
+ (`indexStoreSlot.indexKeysPosition`). Positions are allocated per entity store slot
2514
+ (`sourceStoreSlot.indexKeysPositions`). Pick the strategy once per db, `new OIMDB({ indexKeysService })`
2515
+ (default `OIMDefaults.INDEX_KEYS_SERVICE.getValue()`):
2516
+
2517
+ | strategy | on `disposeIndexStoreSlot` | cost |
2518
+ |---|---|---|
2519
+ | `OIMIndexKeysServiceAppending` (default) | the index's position is never reused; its stale entry stays on each entity slot | an entity slot's `indexKeys` grows to the highest position any index wrote on it (K indexes made and disposed → an array of K on an entity slot the last one filed), and a stale entry keeps its key reachable. Writes pay nothing for a disposed index: only live listeners are kept |
2520
+ | `OIMIndexKeysServiceReclaiming` | wipes that position on every entity slot of the source store slot and reuses it | O(N) per dispose |
2521
+
2522
+ Switch to `OIMIndexKeysServiceReclaiming` only if you create indexes dynamically and often. Creating
2523
+ indexes dynamically and often is expensive, so avoid it.
2524
+
2525
+ ### The channel of a bucket store slot
2526
+
2527
+ A bucket store slot takes its channel slot at `createStoreSlot(db)` (an index store slot, from its source
2528
+ store slot). The refusal to write during a flush lives inside `OIMChannelService.INSTANCE.write`.
2529
+
2530
+ ---
2531
+
2532
+ ## Derivations
2533
+
2534
+ ```ts signature
2535
+ type TOIMComputedSources = {
2536
+ readonly sourceSlots: readonly TOIMSlot[];
2537
+ readonly sourceComputeds: readonly TOIMComputedSlot[];
2538
+ };
2539
+ ```
2540
+
2541
+ In a `createSources` result (of `createOIMComputedService`'s options, or of `createStoreSlot`) both `sourceSlots` and `sourceComputeds` are optional; a missing one means none.
2542
+
2543
+ ```ts signature
2544
+ type TOIMComputedSlot<TValue = unknown, TKey = unknown> = TOIMIndexableSlot<TKey> & Readonly<TOIMDerivation> & {
2545
+ readonly owner: IOIMSlotOwner;
2546
+ readonly computedValueService: IOIMComputedValueService<TValue>;
2547
+ readonly value: TValue;
2548
+ readonly checkedAtRevision: number;
2549
+ readonly checkedAtEpoch: number;
2550
+ readonly isDeriving: boolean;
2551
+ readonly allRootSlots: readonly TOIMSlot[] | undefined;
2552
+ };
2553
+
2554
+ interface IOIMComputedValueService<TValue = unknown> {
2555
+ getValueForSlot(computedSlot: TOIMComputedSlot<TValue>): TValue;
2556
+ }
2557
+ ```
2558
+
2559
+ A computed is a SLOT, the plain data type `TOIMComputedSlot`. Its owner is the slot owner of the
2560
+ `OIMComputedService` that minted it, and the service is the only writer of the slot. The value service
2561
+ (`OIMComputedValueService`, which every computed slot carries as `computedValueService`) holds the
2562
+ `sameValueGuard` ONCE, the store slot holds its `derive` and `createSources` and its channel slot's clock,
2563
+ and the value service serves the value. Every source list in this engine —
2564
+ `createEffect`'s third argument and `sourceComputeds` — is typed
2565
+ `readonly TOIMComputedSlot[]`; a source computed is a slot taken from its own
2566
+ service, through `computedCollectionService.getSlotByKey(storeSlot, key)` (`undefined` for a single). Inside
2567
+ `derive`, `computedSlot.value` is the previous value.
2568
+
2569
+ A derivation is **asked, never woken**. A chain short-circuits on "did anything I
2570
+ read move?", so `filter → sort → page` over quiet roots is a walk of integer
2571
+ comparisons. A cycle cannot be built.
2572
+
2573
+ **Creating a service or a store slot takes no hold.** A computed slot is held on every slot in its
2574
+ `sourceSlots` and on every entry of its `sourceComputeds`, and the holder is its
2575
+ owner: it takes them when it mints the slot and gives them back when the slot's content is released.
2576
+ The slot dies when nothing holds it; the store slot lives on and mints a fresh slot on
2577
+ the next read or hold.
2578
+
2579
+ ### One derivation per key
2580
+
2581
+ ```ts signature
2582
+ class OIMComputedService<TKey, TValue, TComputedStore> {
2583
+ constructor(options: TOIMComputedServiceOptions<TKey, TValue, TComputedStore>);
2584
+ readonly computedCollectionService: OIMSlotCollectionService<TKey, TOIMComputedSlot<TValue, TKey>, TComputedStore,
2585
+ OIMComputedValueService<TKey, TValue>, Extract<keyof TValue, string>, TOIMComputedColumnSlot, OIMColumnOwnerPullDriven>;
2586
+ createComputedStoreSlot(channelMap: IOIMChannelMap, derive?, createSources?): TOIMComputedStoreSlot<TComputedStore, TValue, TKey>;
2587
+ }
2588
+
2589
+ createOIMComputedService<TKey, TValue, TComputedStore = Map<TKey, TOIMComputedSlot<TValue, TKey>>>({
2590
+ createSources?: (key: TKey, storeSlot: TOIMStoreSlot | undefined) => Partial<TOIMComputedSources>;
2591
+ derive?: (computedSlot: TOIMComputedSlot<TValue, TKey>) => TValue;
2592
+ sameValueGuard?: TOIMSameValueGuard<TValue>; // default Object.is
2593
+ initialValue?: TValue;
2594
+ slotReleasePolicy?: IOIMSlotReleasePolicy; // default OIMSlotReleasePolicyAtUnhold.INSTANCE
2595
+ columnSlotStoreService?: IOIMSlotStoreService<string, TOIMComputedColumnSlot>; // OIMDefaults.COMPUTED_COLUMN_SLOT_STORE_SERVICE
2596
+ columnNames?: readonly Extract<keyof TValue, string>[];
2597
+ computedSlotStoreService?: IOIMSlotStoreService<TKey, TOIMComputedSlot<TValue, TKey>, TComputedStore>; // Map; REQUIRED for any other store
2598
+ }): OIMComputedService<TKey, TValue, TComputedStore>
2599
+ ```
2600
+
2601
+ The factory takes no db: a computed service is configuration, and each store slot takes its channel slot at
2602
+ `createComputedStoreSlot(db, …)`.
2603
+
2604
+ **`derive` and `createSources` come from the store slot or from the service.** Each one a store
2605
+ slot is given (`createComputedStoreSlot(db, derive, createSources)`) is used for that store slot; each one it is
2606
+ not given comes from the service's options. A store slot that would end up without one of the two throws by name at
2607
+ `createComputedStoreSlot`. So one service per value type can serve many derivations, each its own store slot.
2608
+
2609
+ Doors of a keyed computed store slot are the read doors of `computedCollectionService`, each taking it first:
2610
+ `getSlotByKey(storeSlot, key)`, `holdSlotByKey(storeSlot, key)`, `findSlotByKey(storeSlot, key)`,
2611
+ `getSlots(storeSlot)`, `getSlotCount(storeSlot)`, and the column doors; the value is
2612
+ `slot.computedValueService.getValueForSlot(slot)`, and a give-back is `slot.owner.unholdSlot(slot)`.
2613
+
2614
+ **One service, both computed shapes.** A keyed store slot and a single one — a store slot of a
2615
+ `void`-keyed service over `OIMSlotStoreServiceSingleSlotDriven.INSTANCE` — outlive every computed slot they
2616
+ mint, and mint a fresh one on the next read or hold once the previous one is gone. There is no
2617
+ single-computed class and no single-computed door: the single one is read by key `undefined`.
2618
+
2619
+ ```ts
2620
+ const topRankService = createOIMComputedService<void, number, TOIMSingleSlotStore<TOIMComputedSlot<number, void>>>({
2621
+ computedSlotStoreService:
2622
+ OIMSlotStoreServiceSingleSlotDriven.INSTANCE as unknown as OIMSlotStoreServiceSingleSlotDriven<TOIMComputedSlot<number, void>>,
2623
+ sameValueGuard: oimSameValueZeroGuard,
2624
+ initialValue: -1,
2625
+ });
2626
+ const topRank = topRankService.createComputedStoreSlot(
2627
+ db,
2628
+ () => cardService.getSlotByKey(cards, 'c1').entity?.rank ?? -1,
2629
+ () => ({ sourceSlots: [cardService.getSlotByKey(cards, 'c1')] }),
2630
+ );
2631
+ const heldTopRank = topRankService.computedCollectionService.holdSlotByKey(topRank, undefined); // the same slot while anything holds it
2632
+ heldTopRank.computedValueService.getValueForSlot(heldTopRank);
2633
+ heldTopRank.owner.unholdSlot(heldTopRank); // gone; the next holdSlotByKey(topRank, undefined) mints a new one
2634
+ ```
2635
+
2636
+ A single computed store slot has one key, `undefined`, and a store of one slot. `getSlotByKey(storeSlot,
2637
+ undefined)` hands a slot without a hold — a fresh one goes to the unused-slot collector and is let go at the
2638
+ next flush unless something holds it — and `holdSlotByKey(storeSlot, undefined)` holds the live slot, minting
2639
+ one if the previous is gone. A computed slot goes at its last unhold once no effect reads it
2640
+ (`OIMSlotReleasePolicyAtUnhold`, the default), and gives back the holds it took on its
2641
+ sources. A last unhold during a flush releases at the end of that flush, through the
2642
+ db's unused-slot collector, as for every non-empty slot. The holds a computed takes on its sources and
2643
+ gives back are one stateless service, shared by both shapes.
2644
+
2645
+ **`createSources` takes the key and the store slot, and nothing else. There is no `keep`.** A build that
2646
+ needs a source resolves it with `holdSlotByKey` or `getSlotByKey` like any other caller,
2647
+ and the holds the service itself takes are the ones named in
2648
+ `sourceSlots` / `sourceComputeds` — so the source list IS the dependency
2649
+ list, and a source a build closes over without naming is a source nothing holds.
2650
+
2651
+ **A derivation is never empty.** `OIMComputedService` answers
2652
+ "empty" with `false`, so a filed derivation is released only when nothing holds
2653
+ it, nothing watches it, and the grace is `0` — which is what
2654
+ `OIMSlotReleasePolicyAtUnhold` answers, and the default when no `slotReleasePolicy` is given.
2655
+
2656
+ ```ts setup
2657
+ const rankService = createOIMComputedService<TOIMStoreKey, number>({
2658
+ createSources: (id: TOIMStoreKey) => ({ sourceSlots: [cardService.getSlotByKey(cards, id)] }),
2659
+ derive: (computedSlot) => cardService.getSlotByKey(cards, computedSlot.key).entity?.rank ?? -1,
2660
+ sameValueGuard: oimSameValueZeroGuard,
2661
+ initialValue: -1,
2662
+ });
2663
+ const ranks = rankService.createComputedStoreSlot(db);
2664
+ ```
2665
+
2666
+ ```ts
2667
+ const rank = rankService.computedCollectionService.holdSlotByKey(ranks, 'c1');
2668
+ rank.computedValueService.getValueForSlot(rank);
2669
+ rank.owner.unholdSlot(rank);
2670
+ ```
2671
+
2672
+ ### Computed columns
2673
+
2674
+ A computed's value can be subscribed to by field. `getColumn(slot, columnName)`, `holdColumn(slot, columnName)`,
2675
+ `getColumnGroup(names)`, `holdColumnGroup(slot, group)`, `unholdColumn(columnSlot)`, and the
2676
+ any-column pair `getAnyColumn(slot)` / `holdAnyColumn(slot)` are on `computedCollectionService`
2677
+ (so on the sorted and memo services too), for keyed and single store slots alike; a single one passes
2678
+ `getSlotByKey(storeSlot, undefined)` as the slot. Column names are typed as the keys of the value.
2679
+
2680
+ A computed service's column owner is the derive-driven strategy: each column slot is a computed slot
2681
+ over the carrier computed, its value `carrier.value[column]` (a tuple of the group's fields, ordered by the sorted column names, for a group,
2682
+ the whole value for the any-column), compared with `Object.is`. It is a `TOIMComputedColumnSlot`
2683
+ (`TOIMComputedSlot<unknown, string>`), so it goes in the SECOND array of `createEffect`, and its value
2684
+ is read off `columnSlot.value`. It wakes an effect only when that field of the derived value changes.
2685
+ Pinned by `facade/computed/tests/what-a-computed-column-wakes-on.test.ts`.
2686
+
2687
+ ```ts
2688
+ type Summary = { top: number; count: number };
2689
+ const summaryService = createOIMComputedService<void, Summary, TOIMSingleSlotStore<TOIMComputedSlot<Summary, void>>>({
2690
+ computedSlotStoreService:
2691
+ OIMSlotStoreServiceSingleSlotDriven.INSTANCE as unknown as OIMSlotStoreServiceSingleSlotDriven<TOIMComputedSlot<Summary, void>>,
2692
+ });
2693
+ const summary = summaryService.createComputedStoreSlot(
2694
+ db,
2695
+ () => ({ top: slot.entity?.rank ?? -1, count: cardService.getSlotCount(cards) }),
2696
+ () => ({ sourceSlots: [slot] }),
2697
+ );
2698
+ const summaryCollectionService = summaryService.computedCollectionService;
2699
+ const summaryTop = summaryCollectionService.holdColumn(summaryCollectionService.getSlotByKey(summary, undefined), 'top');
2700
+ const topEffect = effectService.createEffect(db, [], [summaryTop], () => { paint(slot.entity); });
2701
+ effectService.disposeEffect(topEffect);
2702
+ summaryCollectionService.unholdColumn(summaryTop);
2703
+ ```
2704
+
2705
+ ### The order of a bucket, as one derivation per key
2706
+
2707
+ ```ts signature
2708
+ createOIMComputedServiceBucketMemo<TKey, TBucketMemberSlot extends TOIMSlot, TSlots, TBucketStore, TComputedStore?>({
2709
+ bucketCollectionService: OIMSlotCollectionService<TKey, TOIMBucketSlot<TSlots, TKey>, TBucketStore, OIMSlotContentBucketService<TBucketMemberSlot, TSlots>, …>;
2710
+ slotReleasePolicy?: IOIMSlotReleasePolicy; // default OIMSlotReleasePolicyAtUnhold.INSTANCE
2711
+ columnSlotStoreService?: IOIMSlotStoreService<string, TOIMComputedColumnSlot>;
2712
+ computedSlotStoreService?: IOIMSlotStoreService<TKey, TOIMComputedSlot<readonly TBucketMemberSlot[], TKey>, TComputedStore>;
2713
+ }): OIMComputedServiceBucketMemo<TKey, TBucketMemberSlot, TSlots, TBucketStore, TComputedStore>
2714
+
2715
+ createBucketMemoStoreSlot(bucketStoreSlot: TOIMStoreSlot<TBucketStore, TOIMBucketSlot<TSlots, TKey>, readonly TBucketMemberSlot[]>): TOIMStoreSlot<TComputedStore, TOIMBucketMemoComputedSlot<TBucketMemberSlot, TKey>>
2716
+ ```
2717
+
2718
+ It takes no db: a memo store slot runs on the channel slot of the bucket store slot it reads. The class is
2719
+ `new OIMComputedServiceBucketMemo(options)` (options type `TOIMComputedServiceBucketMemoOptions`), whose
2720
+ `computedCollectionService` reads the per-key derivations; `createBucketMemoStoreSlot(bucketStoreSlot)` makes
2721
+ one memo store slot over one bucket store slot.
2722
+
2723
+ Subscribe to THIS, not to the bucket, when a consumer wants the list as an array
2724
+ it can compare by identity. The value is the bucket's slots in the bucket's order;
2725
+ it is produced on the read that asks for it and never while nothing reads it, and
2726
+ its `changedAtRevision` and `changedAtEpoch` move only when the list a reader would
2727
+ see actually changed. The list is handed back by IDENTITY: it is the same array for
2728
+ as long as the list holds, so a consumer memoising on identity needs no comparison
2729
+ of its own. For a SORTED list use [`createOIMComputedServiceSorted`](#sorted-lists).
2730
+
2731
+ The source list is ONE slot — the bucket — and it never grows: a bucket
2732
+ change is a move of the bucket's value, not of the derivation's sources.
2733
+
2734
+ ```ts
2735
+ const orderService = createOIMComputedServiceBucketMemo({
2736
+ bucketCollectionService: orderedService,
2737
+ });
2738
+ const order = orderService.createBucketMemoStoreSlot(ordered);
2739
+ const deck = orderService.computedCollectionService.holdSlotByKey(order, 'spades'); // mints the bucket too, and holds it
2740
+ effectService.createEffect(db, [], [deck], () => render(deck.computedValueService.getValueForSlot(deck)));
2741
+ ```
2742
+
2743
+ ---
2744
+
2745
+ ## Key stores
2746
+
2747
+ ```ts signature
2748
+ interface IOIMSlotStoreService<TKey, TSlot extends TOIMKeyedSlot = TOIMKeyedSlot, TStore = unknown> {
2749
+ createStore(): TStore;
2750
+ ownKey<TOwnedKey extends TKey>(key: TOwnedKey): TOwnedKey;
2751
+ isSameKey(key: TKey, otherKey: TKey): boolean;
2752
+ get(store: TStore, key: TKey): TSlot | undefined;
2753
+ set(store: TStore, key: TKey, slot: TSlot): void;
2754
+ remove(store: TStore, key: TKey): void;
2755
+ getSlots(store: TStore): IterableIterator<TSlot>;
2756
+ getSlotCount(store: TStore): number;
2757
+ }
2758
+ ```
2759
+
2760
+ **The store service is STATELESS and the storage is its first argument.** It
2761
+ builds the storage with `createStore()` and the store slot keeps it, as `storeSlot.store`; `getSlotCount` is a
2762
+ method taking that storage, not a getter. One store service instance can
2763
+ therefore serve every store slot of every service in a process.
2764
+
2765
+ **`ownKey(key)` is the key a slot is minted with, and the store never touches a
2766
+ slot.** The content service asks it once, before the slot owner's `createSlot`, so the slot's `key` is
2767
+ written by its owner and no one else. The map-driven store answers the key itself;
2768
+ the tree-driven one answers a copy of the composite key, so a caller reusing the
2769
+ array it handed in cannot change the key of a slot already filed.
2770
+
2771
+ **`isSameKey(key, otherKey)` is what the store means by one key.** The map-driven
2772
+ store answers SameValueZero; the tree-driven one compares length and then every
2773
+ part by SameValueZero, so two arrays with the same parts are one key. Every collection
2774
+ service answers the same question as `service.isSameKey(key, otherKey)`, asking
2775
+ its own store service; an index deciding whether a key moved first checks `===` (the key
2776
+ columns did not change) and otherwise asks its bucket store service.
2777
+
2778
+ ```ts signature
2779
+ type TOIMSlotStoreTree<TSlot, TCompositeKeyPart> = {
2780
+ readonly root: TOIMCompositeKeyNode<TSlot, TCompositeKeyPart>;
2781
+ readonly slots: Set<TSlot>;
2782
+ };
2783
+
2784
+ type TOIMCompositeKeyNode<TAtKey = unknown, TCompositeKeyPart = TOIMEntitySlot> = {
2785
+ childNodes: Map<TCompositeKeyPart, TOIMCompositeKeyNode<TAtKey, TCompositeKeyPart>> | undefined;
2786
+ atThisKey: TAtKey | undefined;
2787
+ };
2788
+ ```
2789
+
2790
+ `TOIMSlotStoreTree` is what `OIMSlotStoreServiceTreeDriven.createStore()` builds:
2791
+ the tree's root plus a flat `Set` of every filed slot, which makes `getSlots()` and
2792
+ `getSlotCount()` O(1) rather than a walk, and keeps `getSlots()` in filing order like every other store.
2793
+ `TOIMCompositeKeyNode` is one level of that tree — a `Map` from composite key part to the
2794
+ level below, created only when the first longer key is filed under it (a leaf has
2795
+ `childNodes: undefined`), and the slot, if any, whose key ends here. Both are exported so a caller can annotate the storage they
2796
+ are holding; neither is something you build by hand.
2797
+
2798
+ | Store | Key type |
2799
+ |---|---|
2800
+ | `OIMSlotStoreServiceMapDriven<TSlot>` | `TOIMStoreKey` = `string \| number` |
2801
+ | `OIMSlotStoreServiceTreeDriven<TOIMStoreKey, TSlot>` built with `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()` | `readonly TOIMStoreKey[]` — **a chain of VALUES** |
2802
+ | `OIMSlotStoreServiceTreeDriven<TOIMEntitySlot, TSlot>` built with `OIMDefaults.KEY_TREE_WALK_COMPOSITE.getValue()` | `readonly TOIMEntitySlot[]` — **a chain of entity SLOTS** |
2803
+ | `OIMSlotStoreServiceSingleSlotDriven.INSTANCE` | `void` — one slot per store slot, its store a `TOIMSingleSlotStore` (`{ slot }`); what every single is built on |
2804
+
2805
+ Two stores for keys, one per shape of address, and the single-slot store for no key. The chain store descends one `Map`
2806
+ lookup per composite key part, keeps `[a]` and `[a, b]` apart, and has **no encoding step**:
2807
+ `JSON.stringify` and a NUL-join were both rejected — the latter was not
2808
+ injective, so `['a b']` and `['a', 'b']` were one key.
2809
+
2810
+ **The two DESCENTS are a fork chosen at construction, and the choice is about
2811
+ lifetime.** The store takes an `IOIMKeyTreeWalk<TCompositeKeyPart>` as its only
2812
+ constructor argument; there is no runtime branch on what a composite key part is, and the
2813
+ first type parameter is the composite key part type. Do not treat the strategies as one store
2814
+ with looser typing, and do not expect a class per strategy — there is one store class
2815
+ and two walks.
2816
+
2817
+ ```ts signature
2818
+ interface IOIMKeyTreeWalk<TCompositeKeyPart> {
2819
+ mintNodeAt<TAtKey>(
2820
+ rootCompositeKeyNode: TOIMCompositeKeyNode<TAtKey, TCompositeKeyPart>,
2821
+ compositeKey: readonly TCompositeKeyPart[],
2822
+ ): TOIMCompositeKeyNode<TAtKey, TCompositeKeyPart>;
2823
+ nodeAt<TAtKey>(
2824
+ rootCompositeKeyNode: TOIMCompositeKeyNode<TAtKey, TCompositeKeyPart>,
2825
+ compositeKey: readonly TCompositeKeyPart[],
2826
+ ): TOIMCompositeKeyNode<TAtKey, TCompositeKeyPart> | undefined;
2827
+ deleteKey<TAtKey>(
2828
+ rootCompositeKeyNode: TOIMCompositeKeyNode<TAtKey, TCompositeKeyPart>,
2829
+ compositeKey: readonly TCompositeKeyPart[],
2830
+ ): TAtKey | undefined;
2831
+ }
2832
+ ```
2833
+
2834
+ `deleteKey` takes the value filed at the key and hands it back, in one descent:
2835
+ one `Map.get` per key part and no array of the path. On the way back up it
2836
+ unlinks every node the delete left empty and stops at the first one that still
2837
+ holds a value or leads to a longer key, so `['u1', 'p7', 'x']` survives a delete
2838
+ of `['u1', 'p7']`. A key that is only a prefix of filed keys, or reaches nothing,
2839
+ deletes nothing and returns `undefined`.
2840
+
2841
+ | Walk | Class | Shared instance | What a composite key part is | Holds? |
2842
+ |---|---|---|---|---|
2843
+ | scalar | `OIMKeyTreeWalkScalar` | `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()` | `TOIMStoreKey` | no |
2844
+ | composite | `OIMKeyTreeWalkComposite` | `OIMDefaults.KEY_TREE_WALK_COMPOSITE.getValue()` | `TOIMEntitySlot` | **yes — one hold per edge** |
2845
+
2846
+ **The hold is the whole difference.** `OIMKeyTreeWalkComposite.mintNodeAt` calls
2847
+ `holdCompositeKeyPart` the first time it creates an edge, which runs
2848
+ `entitySlot.owner.holdSlot(entitySlot)`; `deleteKey` calls `unholdCompositeKeyPart` on
2849
+ exactly the edges it unlinked, deepest first and only after every unlink is done, which runs `entitySlot.owner.unholdSlot(entitySlot)` unless
2850
+ `oimIsSlotReleased(entitySlot)` — so an entity slot stays addressable for
2851
+ as long as its composite key parts are, and a composite key part nothing else refers to goes the
2852
+ moment the last entity keyed on it does. The scalar walk holds nothing: a value has
2853
+ no identity to keep alive. Both throw `an empty key is not a key` on `[]`.
2854
+
2855
+ Construct the classes yourself only if you want your own instance; both walks are
2856
+ stateless, and the shared one on `OIMDefaults` is what you normally pass.
2857
+
2858
+ ```ts
2859
+ // A CHAIN OF VALUES — `['deck-1', 'red']`.
2860
+ type TScalarChain = readonly TOIMStoreKey[];
2861
+ type Tally = { deck: string; day: string; count: number };
2862
+ type TTallyStore = import('@oimdb/core').TOIMSlotStoreTree<TOIMEntitySlot<Tally, TScalarChain>, TOIMStoreKey>;
2863
+
2864
+ const tallyService = createOIMSlotCollectionService({
2865
+ slotContentService: createOIMSlotContentEntityService<Tally, keyof Tally, TScalarChain, TTallyStore>(db, {
2866
+ slotStoreService: new OIMSlotStoreServiceTreeDriven<TOIMStoreKey, TOIMEntitySlot<Tally, TScalarChain>>(
2867
+ OIMDefaults.KEY_TREE_WALK_SCALAR.getValue(),
2868
+ ),
2869
+ keyOf: (t) => [t.deck, t.day],
2870
+ }),
2871
+ });
2872
+ const tallies = tallyService.createStoreSlot(db);
2873
+ tallyService.slotContentService.upsertEntity(tallies, { deck: 'spades', day: 'mon', count: 1 });
2874
+ tallyService.getSlotByKey(tallies, ['spades', 'mon']);
2875
+ ```
2876
+
2877
+ ```ts
2878
+ // A CHAIN OF IDENTITIES — each composite key part is a slot, and each edge takes a counted
2879
+ // hold on it, so the entity keeps its composite key parts resolvable for as long as it is
2880
+ // addressable.
2881
+ type User = { id: string };
2882
+ type Group = { id: string };
2883
+ declare const alice: TOIMEntitySlot<User, TOIMStoreKey>;
2884
+ declare const admins: TOIMEntitySlot<Group, TOIMStoreKey>;
2885
+
2886
+ type Enrolment = {
2887
+ user: TOIMEntitySlot<User, TOIMStoreKey>;
2888
+ group: TOIMEntitySlot<Group, TOIMStoreKey>;
2889
+ since: number;
2890
+ };
2891
+ type TChain = readonly TOIMEntitySlot[];
2892
+ type TEnrolmentStore = import('@oimdb/core').TOIMSlotStoreTree<TOIMEntitySlot<Enrolment, TChain>, TOIMEntitySlot>;
2893
+
2894
+ const enrolmentService = createOIMSlotCollectionService({
2895
+ slotContentService: createOIMSlotContentEntityService<Enrolment, keyof Enrolment, TChain, TEnrolmentStore>(db, {
2896
+ slotStoreService: new OIMSlotStoreServiceTreeDriven<TOIMEntitySlot, TOIMEntitySlot<Enrolment, TChain>>(
2897
+ OIMDefaults.KEY_TREE_WALK_COMPOSITE.getValue(),
2898
+ ),
2899
+ keyOf: (m) => [m.user, m.group],
2900
+ }),
2901
+ });
2902
+ const enrolments = enrolmentService.createStoreSlot(db);
2903
+ enrolmentService.slotContentService.upsertEntity(enrolments, { user: alice, group: admins, since: 2026 });
2904
+ enrolmentService.getSlotByKey(enrolments, [alice, admins]);
2905
+ ```
2906
+
2907
+ **Never put a scalar in a composite-walk key.** It is a type error, and
2908
+ past a cast it throws a TypeError inside the mint, setting `holdCount` on a
2909
+ string, after the edge is already in the tree. `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()` is
2910
+ the walk for that key — the same store class, the other strategy.
2911
+
2912
+ **Compare a chain with the strategy that matches it**: `oimSameSlotSequence` for identities
2913
+ (reference, per composite key part), `oimSameKeySequence` for a chain or list of values.
2914
+ `oimSameSlotSequence` over a scalar chain is the `NaN` defect — it answers "a different
2915
+ address" for the address the tree is looking at.
2916
+
2917
+ ---
2918
+
2919
+ ## Lifetime — the owner protocol
2920
+
2921
+ **`slot.owner` is the only reference a slot carries, and it is never
2922
+ `undefined`.** Every slot minted anywhere has one; a slot nothing filed carries
2923
+ `OIMDefaults.STANDALONE_SLOT_OWNER.getValue()`, a slot a collection filed carries that collection type's slot owner (an `OIMSlotOwnerService`) — keyed or single alike — and a slot that has been released KEEPS its owner
2924
+ and raises the RELEASED flag in `slot.slotFlags` instead, which the doors below read.
2925
+
2926
+ ```ts signature
2927
+ interface IOIMSlotOwner {
2928
+ holdSlot(slot: TOIMSlot): void;
2929
+ unholdSlot(slot: TOIMSlot): void;
2930
+ releaseSlotIfUnheld(slot: TOIMSlot): void;
2931
+ subscribeToSlot(slot: TOIMSlot, effect: TOIMEffect, edge: number): number;
2932
+ unsubscribeFromSlot(slot: TOIMSlot, at: number, effect: TOIMEffect): boolean;
2933
+ }
2934
+ ```
2935
+
2936
+ At a deadline the release queue reads its MARKER off the slot, decides, and calls
2937
+ `releaseSlotIfUnheld` itself. The owner has no "how long is left" question.
2938
+
2939
+ | Door | What it is for |
2940
+ |---|---|
2941
+ | `holdSlot(slot)` | `holdCount++`. That is all — no handle, no `{ slot, release }` record. **Throws by name** on a released slot |
2942
+ | `unholdSlot(slot)` | `holdCount--`, then run `releaseSlotIfUnheld`. **It opens no window** — nothing is stamped here, so a slot whose deadline has passed goes at this call. **Throws by name** on a released slot, and when `holdCount` is already `0`: a second unhold against one hold would release a slot somebody else is reading |
2943
+ | `releaseSlotIfUnheld(slot)` | run the release guard. Releases only an unheld, unreferenced, still-owned, out-of-grace slot; **returns silently** on one already released |
2944
+ | `subscribeToSlot(slot, effect, edge)` | push the effect onto `slot.effects` and its `edge` (which of the effect's sources this slot is) onto `slot.effectEdges`, and hand back **the position**, which the effect remembers. **It takes a hold** through `holdSlot`, so a subscribed slot is a held one and nothing can subscribe without holding. **Throws by name** on a released slot: a subscriber filed on one would never be woken |
2945
+ | `unsubscribeFromSlot(slot, at, effect)` | swap the tail of both arrays into `at`, tell the moved effect its new position at its edge (one write, no search), and **if that emptied the list, `onSlotIdle(slot)` opens a fresh idle window**, then give the subscription's hold back through `unholdSlot`; hand back whether it unfiled. Unfiling by remembered position is why taking N subscribers off one identity is O(N) and not quadratic. **Throws by name** on a released slot |
2946
+
2947
+ **`OIMSlotOwner`** is the base class and it is concrete: it implements all five.
2948
+ Its `releaseSlotIfUnheld` does nothing, and a `protected
2949
+ onSlotIdle` does nothing; a `protected assertNotReleased` is what every refusing
2950
+ door above calls. `OIMSlotOwnerService` overrides `onSlotIdle` and
2951
+ `releaseSlotIfUnheld`, asking its release policy and its content strategy; the clock is the slot's channel slot's.
2952
+ Extend `OIMSlotOwner` when you own slots that no collection files.
2953
+ **`OIMDefaults.STANDALONE_SLOT_OWNER`** is one shared instance of it — the owner of a
2954
+ computed slot nobody has filed. It never releases anything, which is exactly what
2955
+ "filed nowhere, so nothing can unfile it" means. A single's slot is not such a slot: its
2956
+ owner is its collection type's slot owner, and that release policy applies to it.
2957
+
2958
+ ### The column owner
2959
+
2960
+ **Columns are a module of every collection.** The column slots of a carrier slot live
2961
+ in `carrierSlot.columnSlotStore`, a field of the base `TOIMSlot`, so any slot can carry
2962
+ them — an entity slot, a bucket slot, a computed slot. `OIMColumnOwner` is abstract, an
2963
+ `OIMSlotOwner` subclass, with two strategies:
2964
+ `OIMColumnOwnerPushDriven` (plain column slots woken by marks on a write; entity and bucket collections,
2965
+ keyed and single) and `OIMColumnOwnerPullDriven` (each column slot a computed slot over its carrier;
2966
+ computed collections, keyed and single). The factory hands the strategy's factory to the slot owner as
2967
+ `createColumnOwner`. Each collection type has its own column owner, and every column slot it mints names that
2968
+ owner as its `owner`. The owner holds only the column slot store service; the column state — the column
2969
+ groups and which columns are referenced — lives on the store slot. The collection exposes it only
2970
+ through its column doors: `getColumn(slot, columnName)`, `holdColumn(slot, columnName)`,
2971
+ `unholdColumn(columnSlot)`, `getColumnGroup(names)`, `holdColumnGroup(slot, group)` (on
2972
+ a single the same doors, with the slot `getSingleSlot(storeSlot)` hands back; the computed ones take a computed slot
2973
+ and return a `TOIMComputedColumnSlot`), and `getAnyColumn(slot)` / `holdAnyColumn(slot)`.
2974
+ A column slot is released the moment its last effect is disposed and nothing holds it:
2975
+ disposing the effect gives its hold back through the column owner, which then runs its own
2976
+ `releaseSlotIfUnheld`, which stops on `holdCount > 0` or a non-empty `effects`,
2977
+ takes the column out of the carrier slot's column store — dropping that store
2978
+ entirely when it empties — sets the RELEASED flag of `columnSlot`, and then runs
2979
+ `carrierSlot.owner.releaseSlotIfUnheld(carrierSlot)`, because a carrier kept alive only by
2980
+ a watched column is one nothing watches any more. This guard is the one place a store
2981
+ lookup survives — it compares `columnSlotStoreService.get(columnSlotStore, columnSlot.key)`
2982
+ against the column slot — because a column is filed on its carrier slot, not in the
2983
+ store slot that the `owner` check covers.
2984
+ `OIMSlotOwnerService`'s release guard refuses while `slot.columnSlotStore` stands,
2985
+ so a slot with any live column slot is never released.
2986
+
2987
+ ---
2988
+
2989
+ ## Ordered lists
2990
+
2991
+ ### Disposers
2992
+
2993
+ The disposers this package hands back are the unsubscribes `OIMChannelService.INSTANCE.subscribeAfterEachFrame`
2994
+ and its siblings return, and the one `effectService.subscribeEffect(db, sourceSlots, sourceComputeds, body)`
2995
+ returns. An effect from `createEffect` has none, so the caller writes
2996
+ `() => effectService.disposeEffect(effect)`. A hold is given back through its owner, not through a disposer.
2997
+
2998
+ ```ts
2999
+ const effect = effectService.createEffect(db, [slot], [], () => { paint(slot.entity); });
3000
+ const disposers: TOIMDisposer[] = [];
3001
+ disposers.push(() => { effectService.disposeEffect(effect); });
3002
+ disposers.push(() => { slot.owner.unholdSlot(slot); });
3003
+ for (let i = disposers.length - 1; i >= 0; i--) disposers[i]();
3004
+ ```
3005
+
3006
+ ### `oimOrderedListCommandsOfTransition`
3007
+
3008
+ ```ts signature
3009
+ oimOrderedListCommandsOfTransition(
3010
+ prev: readonly TOIMEntitySlot[],
3011
+ next: readonly TOIMEntitySlot[],
3012
+ maxCommandsPerSlot?: number, // commands per slot of `next`; left out, it NEVER resets
3013
+ ): TOIMOrderedListCommand[]
3014
+
3015
+ type TOIMOrderedListCommand =
3016
+ | { type: 'insert'; index: number; slot: TOIMEntitySlot }
3017
+ | { type: 'remove'; index: number }
3018
+ | { type: 'move'; from: number; to: number }
3019
+ | { type: 'reset'; slots: readonly TOIMEntitySlot[] };
3020
+ ```
3021
+
3022
+ A free function, not a method on `OIMChannelService`: a class's methods cannot be
3023
+ dropped one at a time by a bundler.
3024
+
3025
+ `prev` MUST BE A SNAPSHOT TAKEN WHERE YOU RENDERED. Every member read on a bucket
3026
+ content service hands back the LIVE list — declared `Iterable`, not an array, so
3027
+ `Array.from(bucketService.slotContentService.getBucketMemberSlots(bucketSlot))` is required and is the copy. A `prev` kept
3028
+ live is the same array as `next` on the following frame: the diff is empty,
3029
+ no reorder is ever drawn, and nothing throws. The other safe `prev` is
3030
+ `createOIMComputedServiceBucketMemo`'s value — a fresh array on every write that
3031
+ changed the list, the same array while it did not.
3032
+
3033
+ A LIST MAY NAME ONE SLOT TWICE, and then it holds that slot twice.
3034
+ `OIMBucketArrayMembershipNonUnique` — the default strategy of `OIMSlotContentBucketServiceArrayDriven` —
3035
+ records every mention, so an ordered bucket's list is exactly such a list. The
3036
+ mentions of one identity are matched in the order they appear, and the script
3037
+ reproduces `next` mention for mention. The minimal move count (`common − LIS`)
3038
+ is claimed for lists whose identities are distinct: where the two lists name one
3039
+ identity a different number of times, which mentions are matched decides how
3040
+ long the common subsequence can be, and matching them in order does not always
3041
+ leave the longest.
3042
+
3043
+ ---
3044
+
3045
+ ## Types
3046
+
3047
+ ```ts signature
3048
+ type TOIMStoreKey = string | number;
3049
+ type TOIMDisposer = () => void;
3050
+ type TOIMEffectBody = () => (() => void) | void; // a returned function is the undo
3051
+ type TOIMEffectBodyWithSourceTracking = (effectSourceTracker: OIMEffectSourceTracker) => (() => void) | void;
3052
+ type TOIMSameValueGuard<TValue = unknown> = (prev: TValue, next: TValue) => boolean;
3053
+ type TOIMChannelScheduler = (flush: () => void) => void;
3054
+ type TOIMSortColumn<TEntity extends object> = { column: TOIMSortableColumnName<TEntity>; direction: 'asc' | 'desc' };
3055
+ type TOIMSortableColumnName<TEntity extends object> = // a column whose values are string | number | bigint
3056
+ { [TColumn in keyof TEntity]-?: NonNullable<TEntity[TColumn]> extends string | number | bigint ? TColumn : never }[keyof TEntity] & string;
3057
+
3058
+ // EVERY property is `readonly`. A slot is written through its owner and its
3059
+ // service, never by assigning to it; `TOIMSlotEditable` is how the engine
3060
+ // itself drops that guard where it is the one mutator.
3061
+ type TOIMSlot = {
3062
+ readonly effects: TOIMEffect[] | undefined;
3063
+ readonly effectEdges: number[] | undefined; // beside each effect, which of its sources this slot is
3064
+ readonly changedAtRevision: number;
3065
+ readonly changedAtEpoch: number;
3066
+ readonly holdCount: number;
3067
+ readonly releaseDueAfterWriteMs: number; // -1 until a write stamps it
3068
+ readonly releaseDueAfterIdleMs: number; // -1 until idleness stamps it
3069
+ readonly releaseDueAfterUnholdMs: number; // -1 until the last unhold stamps it
3070
+ readonly slotFlags: number; // OIM_SLOT_FLAG_EMPTY = 1, OIM_SLOT_FLAG_RELEASED = 2
3071
+ readonly columnSlotStore: unknown; // the column slots this slot carries; undefined while none
3072
+ readonly storeSlot: TOIMStoreSlot | undefined;
3073
+ readonly owner: IOIMSlotOwner; // never undefined, never cleared
3074
+ };
3075
+
3076
+ // A store slot is NOT a slot: nothing holds, subscribes or wakes it. It is the state of one
3077
+ // collection instance — its store, its channel slot, its listeners and column state — and every
3078
+ // service that touches it is stateless. Only the fields a consumer reads are shown.
3079
+ type TOIMStoreSlot<TStore = unknown, TSlot extends TOIMSlot = TOIMSlot, TPatch = unknown> = {
3080
+ readonly store: TStore;
3081
+ readonly channelSlot: TOIMChannelSlot;
3082
+ readonly contentChangeListeners: IOIMContentChangeListener<TSlot, TPatch>[] | undefined;
3083
+ readonly contentChangeListenerReadColumnNames: (readonly string[] | undefined)[] | undefined;
3084
+ readonly indexKeysPositions: TOIMIndexKeysPositions | undefined;
3085
+ readonly sourceStoreSlot: TOIMStoreSlot | undefined; // an index store slot's source
3086
+ readonly isDisposed: boolean; // an index store slot after disposeIndexStoreSlot
3087
+ // … column-reader counts, column groups, subframe queues, bucket write queues: the services' own
3088
+ };
3089
+
3090
+ type TOIMSingleSlotStore<TSlot> = { slot: TSlot | undefined };
3091
+ type TOIMSingleSlotStoreSlot<TSlot extends TOIMSlot, TPatch = unknown> = TOIMStoreSlot<TOIMSingleSlotStore<TSlot>, TSlot, TPatch>;
3092
+
3093
+ type TOIMIndexStoreSlot<TStore, TBucketSlot, TBucketPatch, TSourceSlot, TSourcePatch, TKey, TSourceStore> =
3094
+ TOIMStoreSlot<TStore, TBucketSlot, TBucketPatch> & {
3095
+ readonly sourceStoreSlot: TOIMStoreSlot<TSourceStore, TSourceSlot, TSourcePatch>;
3096
+ readonly indexKeysPosition: number;
3097
+ readonly contentChangeListener: IOIMContentChangeListener<TSourceSlot, TSourcePatch>;
3098
+ readonly requestedKeys: readonly TKey[] | undefined;
3099
+ readonly bucketColumnNames: Set<string>;
3100
+ readonly isAnyBucketColumnRead: boolean;
3101
+ readonly isDisposed: boolean;
3102
+ };
3103
+
3104
+ type TOIMComputedStoreSlot<TStore = unknown, TValue = unknown, TKey = unknown> =
3105
+ TOIMStoreSlot<TStore, TOIMComputedSlot<TValue, TKey>> & {
3106
+ readonly derive: ((computedSlot: TOIMComputedSlot<TValue, TKey>) => TValue) | undefined;
3107
+ readonly createSources: ((key: TKey, storeSlot: TOIMStoreSlot | undefined) => Partial<TOIMComputedSources>) | undefined;
3108
+ };
3109
+
3110
+ type TOIMSlotEditable<TSlot> = { -readonly [K in keyof TSlot]: TSlot[K] };
3111
+
3112
+ type TOIMKeyedSlot<TKey = unknown> = TOIMSlot & { readonly key: TKey };
3113
+ type TOIMIndexableSlot<TKey = unknown> = TOIMKeyedSlot<TKey> & { readonly indexKeys: unknown[] | undefined };
3114
+
3115
+ type TOIMEntitySlot<TEntity extends object = object, TKey = unknown> = TOIMIndexableSlot<TKey> & {
3116
+ readonly entity: TEntity | undefined;
3117
+ readonly data: unknown[] | undefined;
3118
+ };
3119
+
3120
+ // `data` belongs to the consumer: the core never reads or writes it, and clearing or
3121
+ // removing the slot (`slotContentService.deleteEntity(slot)`) does not reset it. Each index keeps its
3122
+ // last key for the entity slot in `indexKeys`, through the db's `IOIMIndexKeysService`.
3123
+
3124
+ type TOIMColumnSlot = TOIMSlot & {
3125
+ readonly carrierSlot: TOIMSlot; // the entity slot or bucket slot it hangs off
3126
+ readonly key: string;
3127
+ };
3128
+
3129
+ type TOIMComputedColumnSlot = TOIMComputedSlot<unknown, string>; // a computed's getColumn / holdColumn return it; it goes in createEffect's second list
3130
+
3131
+ type TOIMBucketSlot<TSlots = unknown, TKey = unknown> = TOIMIndexableSlot<TKey> & {
3132
+ readonly owner: IOIMSlotOwner;
3133
+ readonly storeSlot: TOIMStoreSlot<unknown, TOIMBucketSlot<TSlots>>;
3134
+ readonly bucketSlotsStore: TSlots;
3135
+ readonly commands: readonly TOIMOrderedListCommand[] | undefined; // emptied when the flush has ended
3136
+ readonly isWholeListRecorded: boolean; // reset to false with it
3137
+ readonly commandsStartedAtRevision: number; // the clock the script started at
3138
+ readonly commandsStartedAtEpoch: number;
3139
+ readonly wasEmptyBeforeChange: boolean | undefined;
3140
+ readonly isChangedAgainInSubframe: boolean;
3141
+ };
3142
+
3143
+ // `holdSlotByKey(storeSlot, key)` hands back the slot itself; give the hold back with `slot.owner.unholdSlot(slot)`.
3144
+
3145
+ type TOIMBucketSlotsSetStore<TBucketMemberSlot extends TOIMSlot = TOIMSlot> = Set<TBucketMemberSlot>;
3146
+ type TOIMBucketSlotsArrayStore<TBucketMemberSlot extends TOIMSlot = TOIMSlot> = {
3147
+ bucketMemberSlots: TBucketMemberSlot[];
3148
+ };
3149
+ type TOIMBucketSlotsArrayUniqueStore<TBucketMemberSlot extends TOIMSlot = TOIMSlot> =
3150
+ TOIMBucketSlotsArrayStore<TBucketMemberSlot> & { uniqueBucketMemberSlots: Set<TBucketMemberSlot> };
3151
+
3152
+ type TOIMEntityColumnName<TEntity extends object> =
3153
+ [TEntity] extends [never] ? never
3154
+ : object extends TEntity ? string : keyof TEntity & string;
3155
+
3156
+ type TOIMDerivation = TOIMComputedSources & { // what a computed slot and a TOIMEffect share
3157
+ ranAtRevision: number; ranAtEpoch: number;
3158
+ };
3159
+
3160
+ type TOIMTimer = (run: () => void, inMs: number) => TOIMDisposer;
3161
+ ```
3162
+
3163
+ **`TOIMSlotEditable<TSlot>` strips `readonly` from every property.** It is exported
3164
+ because the rule "data has one mutator" is enforced by the slot types being
3165
+ readonly everywhere else: a class that IS the sole mutator of a slot's property
3166
+ casts through this type at the one place it writes, and every other reader is
3167
+ stopped by the compiler. `IOIMSlotStamp.stamp` takes one, which is what lets a
3168
+ stamp write `releaseDueAfterWriteMs` and nothing else write it. **Do not reach for this to
3169
+ work around a compile error** — if you need it and you do not own the property,
3170
+ the mutation belongs on whoever does.
3171
+
3172
+ Comparison helpers — one per shape of address, plus the value rule. Use the one
3173
+ that matches what you are comparing; they are not interchangeable.
3174
+
3175
+ | Helper | Compares | Rule |
3176
+ |---|---|---|
3177
+ | `oimSameValueZeroGuard(prev, next)` | a stored value, or one scalar address | SameValueZero |
3178
+ | `oimSameSlotSequence(prev, next)` | `readonly TOIMSlot[]` — a chain of identities | positional, by reference |
3179
+ | `oimSameKeySequence(keys, otherKeys)` | `readonly TKey[]` — a list of keys or a chain of values | positional, SameValueZero per key |
3180
+
3181
+ ---
3182
+
3183
+ ## Full export list
3184
+
3185
+ **200 names, every export of `packages/core/src/index.ts`, grouped by topic.**
3186
+
3187
+ *Channel and database slots* — `OIMChannelService` · `TOIMChannelSlot` · `TOIMChannelSlotOptions` · `createOIMChannelSlot` ·
3188
+ `IOIMChannelMap` · `TOIMDBOptions` · `OIMFlushSchedulerService` · `TOIMFlushSchedulerSlot`
3189
+
3190
+ *Collector services and slots* — `OIMUnusedSlotCollectorService` · `TOIMUnusedSlotCollectorSlot` ·
3191
+ `createOIMUnusedSlotCollectorSlot` · `TOIMCollectorSlot` · `TOIMCollectorSlotOptions` · `createOIMCollectorSlot` ·
3192
+ `OIMReleaseSlotCollectorService` · `OIMReleaseSlotCollectorServiceAfterIdle` · `OIMReleaseSlotCollectorServiceAfterUnhold` ·
3193
+ `OIMReleaseSlotCollectorServiceAfterWrite` · `OIMReleaseSlotQueueService` · `TOIMReleaseSlotCollectorSlot` ·
3194
+ `createOIMReleaseSlotCollectorSlot`
3195
+
3196
+ *Slot services* — `OIMSlotColumnService` (abstract; the column doors both slot services inherit) ·
3197
+ `OIMSlotCollectionService` · `TOIMSlotCollectionServiceOptions` · `createOIMSlotCollectionService` ·
3198
+ `OIMSingleSlotService` · `TOIMSingleSlotServiceOptions` · `createOIMSingleSlotService` · `OIMSlotOwnerService` (**type only**) ·
3199
+ `TOIMSlotOwnerServiceOptions` · `IOIMSlotContentStrategy` · `IOIMSlotContentFlushStrategy` · `IOIMSubframeCloseListener` ·
3200
+ `OIMContentChangeListenerService`
3201
+
3202
+ *Content services* — `OIMSlotContentEntityService` · `TOIMSlotContentEntityServiceOptions` ·
3203
+ `createOIMSlotContentEntityService` · `TOIMSlotContentEntityServiceFactoryOptions` · `TOIMEntitySlotOwnerService` · `createOIMEntityService` and
3204
+ `createOIMSingleEntityService` (convenience wrappers) · `OIMSlotContentBucketService` ·
3205
+ `OIMSlotContentBucketServiceArrayBased` · `OIMSlotContentBucketServiceArrayDriven` · `OIMSlotContentBucketServiceSetDriven` ·
3206
+ `OIMSlotContentBucketServiceSortedArrayDriven` · `TOIMSlotContentBucketServiceOptions` ·
3207
+ `TOIMSlotContentBucketServiceSortedArrayDrivenOptions` · `TOIMBucketSlotOwnerService` · `createOIMBucketService` and
3208
+ `createOIMSingleBucketService` (convenience wrappers)
3209
+
3210
+ *Index and computed store slots* — `createOIMIndexService` · `TOIMIndexServiceFactoryOptions` ·
3211
+ `createOIMIndexServiceByCompositeKey` (convenience wrapper) ·
3212
+ `TOIMIndexServiceStoreSlot` · `TOIMSortedComputedSlot` · `TOIMBucketMemoComputedSlot`
3213
+
3214
+ *Runtime* — `TOIMChannelFrame` · `TOIMChannelClock` ·
3215
+ `TOIMChannelScheduler` · `TOIMDisposer` · `IOIMErrorPolicy` · `OIMErrorPolicyLogAndContinue` ·
3216
+ `TOIMErrorPolicyLogAndContinueOptions` · `OIMErrorPolicyThrowAtOnce` · `OIMChannelQueue` (**type only**) ·
3217
+ `oimIsAnySlotChangedAfter`
3218
+
3219
+ `oimIsAnySlotChangedAfter(slots, revision, epoch): boolean` answers whether any of the slots was
3220
+ written after that clock reading — `channelSlot.clock` read earlier.
3221
+
3222
+ *Derivations* — `OIMEffectService` · `IOIMEffectService` · `TOIMEffect` (**type only** —
3223
+ `OIMEffectService.createEffect` is the constructor) · `TOIMEffectBody` · `TOIMEffectBodyWithSourceTracking` ·
3224
+ `OIMEffectSourceTracker` (**type only** — the service builds it) · `TOIMComputedSlot` · `TOIMComputedSources` ·
3225
+ `OIMComputedValueService` · `IOIMComputedValueService` · `TOIMComputedValueServiceOptions` · `oimRefreshSourceComputeds` · `TOIMDerivation`
3226
+
3227
+ `oimRefreshSourceComputeds(sourceComputeds)` brings every computed in the list current. A binding
3228
+ read outside an effect (`useOIMValue` in `@oimdb/react`, `exoBinding` in `@oimdb/exodra`) calls it
3229
+ on its `sourceComputeds` before `derive`, so the derive never reads a stale computed.
3230
+
3231
+ *Slots and their owners* — `TOIMSlot` · `TOIMStoreSlot` · `TOIMSingleSlotStoreSlot` ·`IOIMStoreSlotListener` · `OIMStoreSlotListenerNone` ·`oimIsSlotEmpty` · `oimIsSlotReleased` · `TOIMSlotEditable` · `TOIMKeyedSlot` · `TOIMIndexableSlot` ·
3232
+ `TOIMEntitySlot` · `TOIMColumnSlot` · `TOIMComputedColumnSlot` · `TOIMColumnGroup` · `TOIMColumnReaders` · `OIMColumnOwner` ·
3233
+ `OIMColumnOwnerPushDriven` · `OIMColumnOwnerPullDriven` · `TOIMColumnOwnerOptions` · `TOIMBucketSlot` ·
3234
+ `TOIMSameValueGuard` · `OIMSlotOwner` ·
3235
+ `IOIMSlotOwner` · `IOIMSlotEmptiness` ·
3236
+ `IOIMSlotStamp` · `oimHoldSlots` · `oimUnholdSlotsIfAlive` · `IOIMContentChangeListener`
3237
+
3238
+ `oimIsSlotEmpty(slot): boolean` reads the EMPTY bit of `slot.slotFlags`.
3239
+ `oimIsSlotReleased(slot): boolean` reads its RELEASED bit.
3240
+
3241
+ *Release policies* — `IOIMSlotReleasePolicy` ·
3242
+ `OIMSlotReleasePolicyAfterWrite` · `TOIMSlotReleasePolicyAfterWriteOptions` ·
3243
+ `OIMSlotReleasePolicyAfterIdle` · `TOIMSlotReleasePolicyAfterIdleOptions` ·
3244
+ `OIMSlotReleasePolicyAfterUnhold` · `TOIMSlotReleasePolicyAfterUnholdOptions` ·
3245
+ `OIMSlotReleasePolicyAtUnhold` · `OIMSlotReleasePolicyAtEmpty` · `OIMSlotReleasePolicyNever` · `OIMSlotReleasePolicyEarliest` ·
3246
+ `TOIMTimer` · `oimSliceOfMs` · `OIM_SLICE_LENGTH_MS`
3247
+
3248
+ *Entity writers* — `IOIMEntityWriter` · `createOIMEntityWriterInPlace` ·
3249
+ `createOIMEntityWriterReplacing` · `TOIMEntityWriterOptions` · `TOIMEntityInsertStrategy` ·
3250
+ `oimInsertEntityWithCopy` · `oimInsertEntityWithoutCopy` · `oimInsertEntityWithSanitizedCopy` · `oimPatchEntityColumns` · `oimPatchEntityColumn` · `oimPatchEntityColumnsGuarded` · `oimPatchEntityColumnGuarded` · `oimPutEntityColumns` · `oimPutEntityColumnsGuarded` · `TOIMEntityColumnName` · `TOIMEntityColumnValue`
3251
+
3252
+ *Buckets* — `TOIMBucketCollectionFactoryArguments` ·
3253
+ `OIMBucketCommands` · `TOIMBucketCommandsOptions` · `IOIMBucketCommands` ·
3254
+ `IOIMBucketArrayMembership` · `OIMBucketArrayMembershipNonUnique` ·
3255
+ `OIMBucketArrayMembershipUnique` · `TOIMBucketSlotsSetStore` · `TOIMBucketSlotsArrayStore` ·
3256
+ `TOIMBucketSlotsSortedArrayStore` · `TOIMBucketSlotsArrayUniqueStore` · `OIM_NOTHING_WAS_INSERTED` ·
3257
+ `OIM_NOTHING_WAS_REMOVED` · `OIM_FROZEN_EMPTY_SLOTS` · `OIM_REMOVED_WITHOUT_A_POSITION`
3258
+
3259
+ *Index services* — `OIMIndexService` · `OIMIndexServiceSortedArrayDriven` · `TOIMIndexServiceOptions` · `TOIMIndexStoreSlot` ·
3260
+ `createOIMIndexServiceSortedArrayDriven` · `TOIMIndexKeyOptions` · `TOIMIndexKey` ·
3261
+ `IOIMIndexKeysService` · `TOIMIndexKeysPositions` · `OIMIndexKeysService` ·
3262
+ `OIMIndexKeysServiceAppending` · `OIMIndexKeysServiceReclaiming`
3263
+
3264
+ *Computed services* — `OIMComputedService` · `TOIMComputedServiceOptions` ·
3265
+ `createOIMComputedService` · `TOIMComputedStoreSlot` · `OIMComputedServiceSorted` · `TOIMComputedServiceSortedOptions` ·
3266
+ `createOIMComputedServiceSorted` · `OIMComputedServiceBucketMemo` · `TOIMComputedServiceBucketMemoOptions` ·
3267
+ `createOIMComputedServiceBucketMemo` · `TOIMSortColumn` · `TOIMSortableColumnName` · `oimColumnsComparator`
3268
+
3269
+ *Stores and keys* — `TOIMStoreKey` · `IOIMSlotStoreService` ·
3270
+ `OIMSlotStoreServiceMapDriven` · `OIMSlotStoreServiceTreeDriven` ·
3271
+ `OIMSlotStoreServiceSingleSlotDriven` · `TOIMSingleSlotStore` · `TOIMDefaultSlotStore` ·
3272
+ `TOIMSlotStoreTree` · `IOIMKeyTreeWalk` · `TOIMCompositeKeyNode` ·
3273
+ `OIMKeyTreeWalk` · `OIMKeyTreeWalkComposite` · `OIMKeyTreeWalkScalar`
3274
+
3275
+ *Assemblies* — `OIMDB` · `OIMDefaults` · `OIMDefaultValue` · `TOIMFactoryOptions`
3276
+
3277
+ *Comparison and diffing* — `oimSameValueZeroGuard` ·
3278
+ `oimSameSlotSequence` · `oimSameKeySequence` · `oimOrderedListCommandsOfTransition` · `TOIMOrderedListCommand`
3279
+
3280
+ *Shared defaults* — `OIMDefaults.STANDALONE_SLOT_OWNER`,
3281
+ `OIMDefaults.KEY_TREE_WALK_SCALAR` and `OIMDefaults.KEY_TREE_WALK_COMPOSITE`.
3282
+
3283
+ **Nothing else is exported.** If a symbol is not on this list, it does not exist.
3284
+
3285
+ ---
3286
+
3287
+ ## Known defects
3288
+
3289
+ ### A scalar in a `KEY_TREE_WALK_COMPOSITE` key throws
3290
+
3291
+ `OIMSlotStoreServiceTreeDriven` built with `OIMDefaults.KEY_TREE_WALK_COMPOSITE.getValue()` keys on
3292
+ `readonly TOIMEntitySlot[]`, so a string or a number in the key throws
3293
+ a TypeError past a cast, setting `holdCount` on a string. The store for a tuple
3294
+ of scalars is `OIMSlotStoreServiceTreeDriven` built with
3295
+ `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()` —
3296
+ see **Key stores** above. The fix is the other walk, never a cast.
3297
+
3298
+ ### `TOIMEffect` is a type-only export
3299
+
3300
+ `effectService.createEffect(...)` and `effectService.createEffectImmediate(...)` are the whole of
3301
+ legitimate construction. An object literal typed `TOIMEffect` subscribes to nothing and
3302
+ schedules nothing: when one of its sources already moved in the open batch, a
3303
+ later write to that source is swallowed by the coalescing guard and the effect
3304
+ silently sits out the frame it is owed. Scheduling it is what `createEffect`
3305
+ does after constructing it.
3306
+
3307
+ ### Mixing `import` and `require` of `@oimdb/core` throws at the second load
3308
+
3309
+ Every `@oimdb` package ships both formats — `dist/index.js` for `import`,
3310
+ `dist/index.cjs` for `require` (with `dist/index.d.cts` under the `require`
3311
+ condition so a CommonJS consumer's declarations match its runtime), both named
3312
+ in the `exports` map. A runtime loads a package once per format, so a process
3313
+ that reaches the core both ways would hold two copies of every class and every
3314
+ module-level value — two `OIMDefaults`, so a default set through one never
3315
+ reaches a service built by the other.
3316
+
3317
+ So `src/index.ts` registers its copy under `Symbol.for('@oimdb/core')` on
3318
+ `globalThis`, and the second copy to load throws
3319
+ `@oimdb/core is loaded twice in this process`. The same throw fires for two
3320
+ installed copies of the package.
3321
+
3322
+ `@oimdb/react` loaded twice fails loudly instead: every hook throws
3323
+ `useOIMDB: no <OIMDBProvider db={...}> above this component.` with the provider plainly above it, because a React
3324
+ context's identity is its lookup key.
3325
+
3326
+ Do not generate code that reaches `@oimdb/core` — or any package that passes
3327
+ core objects around — through both spellings in one process. Pick one.
3328
+
3329
+ ---
3330
+
3331
+ ## Other packages
3332
+
3333
+ `@oimdb/react`, `@oimdb/persist` (with its five backends) and `@oimdb/exodra` build
3334
+ on the current core — persist and exodra have sections of their own below. Every
3335
+ remaining package imports only names the core exports. Read a package's own
3336
+ source before generating code against it.
3337
+
3338
+ `@oimdb/react` — `<OIMDBProvider db={db}>` carries the `OIMDB` (`useOIMDB()`) and nothing else: the effect
3339
+ service is stateless (`OIMEffectService.INSTANCE`) and every hook subscribes with
3340
+ `OIMEffectService.INSTANCE.subscribeEffect(db, …)`. A hook that resolves or takes a slot filed on another `OIMDB`
3341
+ throws, since nothing below would wake. Every hook that reads a collection takes the collection service, typed as a
3342
+ `Pick` of `OIMSlotCollectionService` with only the members it calls, and then the store slot:
3343
+ `useOIMEntity(entityCollectionService, entityStoreSlot, key)`,
3344
+ `useOIMField(entityCollectionService, slot, name)` (calls `getColumn(slot, name)`; no store slot, the slot carries it),
3345
+ `useOIMBucketSlots(bucketCollectionService, bucketSlot)` and
3346
+ `useOIMBucketSlotsByKey(bucketCollectionService, bucketStoreSlot, key)` (both read
3347
+ `bucketCollectionService.slotContentService.getBucketMemberSlots`; an index service and its index store slot fit too), and
3348
+ `useOIMValue(binding)`, where a binding is `{ sourceSlots?, sourceComputeds?, derive }` (a missing list means none)
3349
+ and must keep its identity across renders.
3350
+ `useOIMComputedValue(computedSlot: TOIMComputedSlot<TValue>): TValue` is the one-computed case: it subscribes to
3351
+ the slot, settles it and reads `computedSlot.value`, with no binding to memoise.
3352
+ `useOIMComputedValueByKey(computedService.computedCollectionService, computedStoreSlot, key)` reads one key of a
3353
+ computed store slot (plain, sorted, bucket memo) with the same by-key body as `useOIMEntity` /
3354
+ `useOIMBucketSlotsByKey`: resolve on commit with `holdSlotByKey`, effect over that slot, resolve-hold given back,
3355
+ a key built inline settled by meaning.
3356
+ The computed SERVICE is the caller's, built once at module scope with `createOIMComputedService(options)` (no db).
3357
+ `useOIMComputedCollection(computedService, derive, createSources, deps): TOIMComputedStoreSlot` builds only a store
3358
+ slot per mount — `computedService.createComputedStoreSlot(db, derive, createSources)` in a `useMemo` on `deps`;
3359
+ `derive` / `createSources` may be `undefined` when the service has them.
3360
+ `useOIMSingleComputedValue(computedService, derive, createSources, deps): TValue` is the same for a void-keyed
3361
+ service whose `computedSlotStoreService` is `OIMSlotStoreServiceSingleSlotDriven.INSTANCE`, read through
3362
+ `useOIMComputedValueByKey(computedService.computedCollectionService, computedStoreSlot, undefined)`, so a flush
3363
+ between render and subscribe is safe.
3364
+ `useOIMSlotsByKeys(slotCollectionService, storeSlot, keys)` holds the identities its keys name for as long as the
3365
+ component is mounted (a `holdSlotByKey` per key on commit, the same holds given back on unmount or when the list
3366
+ moves) and subscribes to nothing; it needs no provider.
3367
+ Singles are not keyed collections: `createOIMSingleEntityService(db)` / `createOIMSingleBucketService(db)` return an
3368
+ `OIMSingleSlotService` (`getSingleSlot(storeSlot)`, `holdSingleSlot(storeSlot)`), read by
3369
+ `useOIMSingleEntity(singleSlotService, singleSlotStoreSlot): TEntity | undefined` and
3370
+ `useOIMSingleBucketSlots(singleSlotService, singleSlotStoreSlot): readonly TOIMEntitySlot<TEntity>[]` — the by-key
3371
+ mechanics without a key (getSingleSlot on render, holdSingleSlot on commit, resolve-hold given back once the
3372
+ effect exists; the bucket members are copied per render). See `packages/react/README.md`.
3373
+
3374
+ What each imports from `@oimdb/core`, read out of `src/`. The source moves —
3375
+ re-read the imports rather than trusting this table.
3376
+
3377
+ | Package | Imports from the core | Imports a name the core does not export? |
3378
+ |---|---|---|
3379
+ | `@oimdb/persist` | `IOIMChannelMap`, `IOIMContentChangeListener`, `IOIMEntityWriter`, `OIMChannelService`, `OIMColumnOwnerPushDriven`, `OIMContentChangeListenerService`, `OIMEffectService`, `OIMSingleSlotService`, `OIMSlotCollectionService`, `OIMSlotContentBucketService`, `OIMSlotContentEntityService`, `TOIMBucketSlot`, `TOIMChannelClock`, `TOIMChannelSlot`, `TOIMColumnSlot`, `TOIMDefaultSlotStore`, `TOIMDisposer`, `TOIMEffect`, `TOIMEntitySlot`, `TOIMSingleSlotStore`, `TOIMSingleSlotStoreSlot`, `TOIMSlot`, `TOIMStoreKey`, `TOIMStoreSlot`, `oimHoldSlots`, `oimIsAnySlotChangedAfter`, `oimIsSlotReleased`, `oimSameValueZeroGuard`, `oimUnholdSlotsIfAlive` | no |
3380
+ | `@oimdb/redux-adapter` | `IOIMChannelMap`, `IOIMContentChangeListener`, `OIMChannelService`, `OIMColumnOwnerPushDriven`, `OIMContentChangeListenerService`, `OIMEffectService`, `OIMSlotCollectionService`, `OIMSlotContentBucketService`, `OIMSlotContentEntityService`, `TOIMBucketSlot`, `TOIMColumnSlot`, `TOIMDefaultSlotStore`, `TOIMDisposer`, `TOIMEffect`, `TOIMEntityColumnName`, `TOIMEntitySlot`, `TOIMStoreKey`, `TOIMStoreSlot` | no |
3381
+ | `@oimdb/exodra` | `IOIMChannelMap`, `OIMChannelService`, `OIMEffectService`, `OIMSlotContentBucketService`, `OIM_FROZEN_EMPTY_SLOTS`, `TOIMBucketSlot`, `TOIMComputedSlot`, `TOIMDisposer`, `TOIMEffect`, `TOIMEntitySlot`, `TOIMOrderedListCommand`, `TOIMSlot`, `TOIMStoreKey`, `oimOrderedListCommandsOfTransition`, `oimRefreshSourceComputeds`, `oimSameKeySequence` | no |
3382
+ | `@oimdb/react` | `IOIMChannelMap`, `OIMComputedService`, `OIMDB`, `OIMEffectService`, `OIMSingleSlotService`, `OIMSlotCollectionService`, `OIMSlotContentBucketService`, `OIM_FROZEN_EMPTY_SLOTS`, `TOIMBucketSlot`, `TOIMColumnSlot`, `TOIMComputedSlot`, `TOIMComputedSources`, `TOIMComputedStoreSlot`, `TOIMDisposer`, `TOIMEntityColumnName`, `TOIMEntityColumnValue`, `TOIMEntitySlot`, `TOIMIndexableSlot`, `TOIMKeyedSlot`, `TOIMSingleSlotStore`, `TOIMSlot`, `TOIMStoreKey`, `TOIMStoreSlot`, `oimHoldSlots`, `oimRefreshSourceComputeds`, `oimSameKeySequence`, `oimUnholdSlotsIfAlive` | no |
3383
+ | `@oimdb/devtools` | `IOIMChannelMap`, `IOIMStoreSlotListener`, `OIMChannelService`, `OIMComputedService`, `OIMDefaults`, `OIMSlotCollectionService`, `TOIMBucketSlot`, `TOIMComputedStoreSlot`, `TOIMDefaultSlotStore`, `TOIMDisposer`, `TOIMEntitySlot`, `TOIMKeyedSlot`, `TOIMStoreKey`, `TOIMStoreSlot` | no |
3384
+ | `@oimdb/async` | — | deprecated stub, `export {}` |
3385
+ | `@oimdb/snapshot-manager` | — | empty stub |
3386
+
3387
+ Every package in the table has a `type-check` script (`tsc --noEmit`) that CI
3388
+ runs on each commit, and `@oimdb/core`, `@oimdb/persist`, `@oimdb/react`,
3389
+ `@oimdb/redux-adapter` and `@oimdb/exodra` pass it. Their documentation pages
3390
+ name only shipped exports, but the source is still the final word on a signature.
3391
+
3392
+ ## `@oimdb/persist`
3393
+
3394
+ The persistence engine for `@oimdb/core` and its five backend packages. The
3395
+ engine names the parts and wires them; a backend package supplies the storage.
3396
+ Every constructor in the family takes ONE options object.
3397
+
3398
+ | package | exports |
3399
+ |---|---|
3400
+ | `@oimdb/persist` | the engine, plus the key-value backend `OIMPersistKeyValueBackend` and its strategies; error types `TOIMPersistErrorHandler`, `TOIMPersistErrorContext`, `TOIMPersistOperation` and `TOIMPersistErrorReport` (`{ error, persistErrorContext }`, one failure as `onPersistError` receives it or as it lands in the `AggregateError`) |
3401
+ | `@oimdb/persist-memory` | `OIMPersistMemoryBackend`, `OIMPersistMemoryWriteBatch`, `OIMPersistMemoryRecordsStrategy`, `OIMPersistMemorySnapshotStrategy`, `TOIMPersistMemoryStrategyOptions` |
3402
+ | `@oimdb/persist-json` | `OIMPersistJsonBackend`, `TOIMPersistJsonBackendOptions`, `TOIMPersistJsonDump`; depends on `@oimdb/persist-memory` |
3403
+ | `@oimdb/persist-idb` | `OIMPersistIndexedDbBackend`, `OIMPersistIndexedDbWriteBatch`, `OIMPersistIndexedDbRecordsStrategy`, `OIMPersistIndexedDbSnapshotStrategy`, `TOIMPersistIndexedDbBackendOptions`, `TOIMPersistIndexedDbRecordsStrategyOptions`, `TOIMPersistIndexedDbSnapshotStrategyOptions`, `TOIMPersistIndexedDbPrimaryKey`, `TOIMPersistIndexedDbTableRecord` |
3404
+ | `@oimdb/persist-localstorage` | `OIMPersistLocalStorageBackend`, `TOIMPersistLocalStorageBackendOptions` |
3405
+ | `@oimdb/persist-async-kv` | `OIMPersistAsyncKVStorage`, `TOIMPersistAsyncKVStorageOptions`, `TOIMPersistAsyncKVMultiStorage` |
3406
+
3407
+ ### The wiring
3408
+
3409
+ A persistor owns one backend, one scheduler and a list of resources. A resource is
3410
+ one thing being persisted: a source (the in-memory side), a strategy (where it
3411
+ lives in the backend) and what to do on hydrate.
3412
+
3413
+ ```ts
3414
+ import {
3415
+ OIMPersistEntityWriter, OIMPersistRecordSourceCollectionWriteDriven, OIMPersistResourceRecords,
3416
+ OIMPersistSchedulerMicrotaskDriven, OIMPersistor,
3417
+ } from '@oimdb/persist';
3418
+ import { OIMPersistMemoryBackend, OIMPersistMemoryRecordsStrategy, OIMPersistMemoryWriteBatch } from '@oimdb/persist-memory';
3419
+
3420
+ const persistEntityWriter = new OIMPersistEntityWriter<Card, TOIMStoreKey>({
3421
+ entityWriter: createOIMEntityWriterInPlace(),
3422
+ });
3423
+ const persistedCardCollectionService = createOIMEntityService<Card>(db, {
3424
+ keyOf: card => card.id,
3425
+ entityWriter: persistEntityWriter,
3426
+ });
3427
+ const persistedCardEntityStoreSlot = persistedCardCollectionService.createStoreSlot(db);
3428
+
3429
+ const persistMemoryBackend = new OIMPersistMemoryBackend();
3430
+ const persistor = new OIMPersistor<OIMPersistMemoryBackend, OIMPersistMemoryWriteBatch>({
3431
+ persistBackend: persistMemoryBackend,
3432
+ persistScheduler: new OIMPersistSchedulerMicrotaskDriven(),
3433
+ onPersistError: (error, persistErrorContext) => {
3434
+ console.error(persistErrorContext.persistOperation, error);
3435
+ },
3436
+ });
3437
+ const cardPersistResourceRecords = persistor.addPersistResource(new OIMPersistResourceRecords({
3438
+ persistRecordSource: new OIMPersistRecordSourceCollectionWriteDriven({
3439
+ entityCollectionService: persistedCardCollectionService,
3440
+ entityStoreSlot: persistedCardEntityStoreSlot,
3441
+ persistEntityWriter,
3442
+ }),
3443
+ persistRecordsStrategy: new OIMPersistMemoryRecordsStrategy<TOIMStoreKey, Card>({ storageKey: 'cards' }),
3444
+ }));
3445
+
3446
+ await persistor.hydrate();
3447
+ persistor.start();
3448
+ persistedCardCollectionService.slotContentService.upsertEntity(
3449
+ persistedCardEntityStoreSlot, { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' },
3450
+ );
3451
+ await persistor.persist();
3452
+ await persistor.unload(cardPersistResourceRecords, ['c1']);
3453
+ await persistor.load(cardPersistResourceRecords, ['c1']);
3454
+ ```
3455
+
3456
+ Rules a generator must keep:
3457
+
3458
+ - Pass both generics of `OIMPersistor<TPersistBackend, TWriteBatch>`: the backend
3459
+ class and its write batch class (`OIMPersistMemoryWriteBatch`, `OIMPersistIndexedDbWriteBatch`,
3460
+ `OIMPersistKeyValueWriteBatch`; `OIMPersistJsonBackend` uses `OIMPersistMemoryWriteBatch`,
3461
+ `OIMPersistLocalStorageBackend` uses `OIMPersistKeyValueWriteBatch`).
3462
+ - Pass the generics of strategies, codecs and `OIMPersistEntityWriter<TEntity, TKey>`
3463
+ explicitly; their options carry no type to infer them from.
3464
+ - An entity collection persisted through a collection source is built with the
3465
+ `OIMPersistEntityWriter` as its `entityWriter`, and the same writer goes into the
3466
+ source's `persistEntityWriter`. The source takes the collection service and ONE of its store slots
3467
+ (`entityCollectionService`, `entityStoreSlot`). The writer belongs to the content service, not to the store
3468
+ slot, and the source keeps only the writes whose `entitySlot.storeSlot` is its own store slot; a write to
3469
+ another store slot of the same collection type is ignored by it.
3470
+ `new OIMPersistEntityWriter({ entityWriter })` wraps the `IOIMEntityWriter` that
3471
+ really writes, and reports the entity slot of every fill, put, patch, column patch,
3472
+ column delete and clear that changed something (`subscribeEntitySlotWritten`).
3473
+ - The entity collection service a source takes is any collection service with `getSlots`, `findSlotByKey` and a
3474
+ `slotContentService` with `upsertEntities`, `deleteEntity` and `deleteEntities`
3475
+ (`TOIMPersistEntityCollectionService`), so `createOIMEntityService(db, …)` and the base-layer assembly both fit.
3476
+ - A source refuses, by name, a store slot on another channel slot than the one it was given (the bucket source
3477
+ checks both the entity and the bucket store slot).
3478
+ - Order: `addPersistResource` every resource, `await persistor.hydrate()`, then
3479
+ `persistor.start()`.
3480
+
3481
+ ### Constructors and option keys
3482
+
3483
+ | constructor | options |
3484
+ |---|---|
3485
+ | `OIMPersistor` | `{ persistBackend, persistScheduler, onPersistError? }` |
3486
+ | `OIMPersistResourceRecords` | `{ persistRecordSource, persistRecordsStrategy, persistHydrateReconcile? }` |
3487
+ | `OIMPersistResourceSnapshot` | `{ persistSnapshotSource, persistSnapshotStrategy, persistCodec, persistHydrateReconcile? }` |
3488
+ | `OIMPersistRecordSourceCollectionWriteDriven`, `OIMPersistRecordSourceCollectionChangeDriven` | `{ entityCollectionService, entityStoreSlot, persistEntityWriter }` (`TOIMPersistRecordSourceCollectionOptions`) |
3489
+ | `OIMPersistRecordSourceBucketDriven` | `{ channelMap, entityCollectionService, entityStoreSlot, slotContentBucketService, bucketSlot }` — `channelMap` is the db, `slotContentBucketService` the bucket collection's `slotContentService` |
3490
+ | `OIMPersistSnapshotSourceSingleEntityDriven` | `{ channelMap, singleSlotService, entityStoreSlot }` — a single entity service (`createOIMSingleEntityService`) and one of its store slots (`TOIMSingleSlotStoreSlot`) |
3491
+ | `OIMPersistEntityWriter` | `{ entityWriter }` |
3492
+ | `OIMPersistSchedulerMicrotaskDriven` | none |
3493
+ | `OIMPersistSchedulerFrameDriven` | `{ channelSlot }` — `db.defaultChannelSlot` |
3494
+ | `OIMPersistCodecIdentity` | none |
3495
+ | `OIMPersistCodecVersioned` | `{ version, migrations, persistCodec }` |
3496
+ | `OIMPersistMemoryBackend` | none |
3497
+ | `OIMPersistMemoryRecordsStrategy`, `OIMPersistMemorySnapshotStrategy` | `{ storageKey }` |
3498
+ | `OIMPersistJsonBackend` | `{ initialPersistJsonDump? }`, the object itself optional |
3499
+ | `OIMPersistIndexedDbBackend` | `{ databaseName, databaseVersion?, indexedDb? }` |
3500
+ | `OIMPersistIndexedDbRecordsStrategy` | `{ tableName }` |
3501
+ | `OIMPersistIndexedDbSnapshotStrategy` | `{ tableName, primaryKey }` |
3502
+ | `OIMPersistKeyValueBackend` | `{ persistKeyValueStorage, persistKeyValueSerializer? }` |
3503
+ | `OIMPersistKeyValueStorageItemDriven` | `{ persistKeyValueItemStorage }` |
3504
+ | `OIMPersistKeyValueEntryStrategy` | `{ storageKey }` |
3505
+ | `OIMPersistKeyValuePathStrategy` | `{ storageKey, path }` |
3506
+ | `OIMPersistLocalStorageBackend` | `{ persistKeyValueItemStorage?, persistKeyValueSerializer? }`, the object itself optional |
3507
+ | `OIMPersistSourceChangeMarkSlotDriven` | `{ channelClock, slots }` |
3508
+ | `OIMPersistSourceChangeMarkSubscriptionDriven` | `{ subscribeSourceChanged }` |
3509
+ | `OIMPersistAsyncKVStorage` | `{ persistAsyncKVMultiStorage }` |
3510
+
3511
+ `onPersistError(error, persistErrorContext)` receives a `TOIMPersistErrorContext`,
3512
+ `{ persistResource, persistOperation }`. `TOIMPersistOperation` is
3513
+ `'start' | 'persist' | 'hydrate' | 'clear' | 'quarantine'`. `TOIMPersistErrorReport`
3514
+ is one failure, `{ error, persistErrorContext }`, as it is handed to `onPersistError`
3515
+ or collected into the `AggregateError`.
3516
+
3517
+ ### Two resource kinds, and when to pick each
3518
+
3519
+ | | `OIMPersistResourceRecords` | `OIMPersistResourceSnapshot` |
3520
+ |---|---|---|
3521
+ | source | a record source: entity store slot or bucket | any snapshot source: entity store slot, bucket or single entity |
3522
+ | strategy | `OIMPersistMemoryRecordsStrategy`, `OIMPersistIndexedDbRecordsStrategy` | `OIMPersistMemorySnapshotStrategy`, `OIMPersistIndexedDbSnapshotStrategy`, `OIMPersistKeyValueEntryStrategy`, `OIMPersistKeyValuePathStrategy` |
3523
+ | autosave writes | the delta: one upsert or one deletion per entity slot written since the last drain | the whole snapshot, read from the source and encoded |
3524
+ | `persist()` writes | every record, replacing what is stored; while any key is unloaded, every in-memory record as an upsert over what is stored, plus the deletions the resource already heard | the whole snapshot |
3525
+ | codec | none | `persistCodec`, required |
3526
+ | `unload` | yes | no |
3527
+ | `load` | yes | no |
3528
+
3529
+ Pick records whenever the backend has a records strategy (memory, JSON,
3530
+ IndexedDB): autosave then costs one entry per written entity slot, independent of
3531
+ the store slot's size. Pick snapshot for a single entity, for the key-value
3532
+ backends (localStorage, async KV), which have no records strategy, or when a
3533
+ versioned codec must migrate what is stored. A record source is also a snapshot
3534
+ source whose snapshot is `TOIMPersistRecords` (`{ records: { key, entity }[] }`),
3535
+ so an entity store slot or a bucket goes into either kind.
3536
+
3537
+ A records resource keeps its dirty state as a set of entity slots, read through the
3538
+ slot with no key lookup; a slot written several times between drains is one entry.
3539
+ At the drain each slot becomes an upsert of `{ key, entity }` when it holds an entity
3540
+ and is not released, and a deletion of its key otherwise. When a key has both, a
3541
+ released slot and a live slot minted again for that key inside one batch, the live
3542
+ slot wins: an upsert, not a deletion.
3543
+
3544
+ ### The sources, and the scheduler each pairs with
3545
+
3546
+ | source | reports | scheduler |
3547
+ |---|---|---|
3548
+ | `OIMPersistRecordSourceCollectionWriteDriven` | synchronously, at the write, from `OIMPersistEntityWriter` only | `OIMPersistSchedulerMicrotaskDriven` |
3549
+ | `OIMPersistRecordSourceCollectionChangeDriven` | synchronously, at the write, from `OIMPersistEntityWriter` and from a content-change listener on the entity store slot | `OIMPersistSchedulerMicrotaskDriven` |
3550
+ | `OIMPersistRecordSourceBucketDriven` | from effects (`OIMEffectService`), when the channel runs its frame | `OIMPersistSchedulerFrameDriven({ channelSlot })` |
3551
+ | `OIMPersistSnapshotSourceSingleEntityDriven` | from an effect, when the channel runs its frame | `OIMPersistSchedulerFrameDriven({ channelSlot })` |
3552
+
3553
+ - **Collection** (an entity collection service and one of its store slots): every entity slot of that store slot that holds an entity. Starting
3554
+ reports nothing; content present before `start()` is written by `persist()` or by
3555
+ its next write. An entity removed for any reason, a slot release policy included,
3556
+ goes through the writer's `clearEntity` and reaches the backend as a deletion;
3557
+ `unload` is the call that removes from memory and keeps the stored value. The two
3558
+ collection sources are one fork, chosen at construction, with the same options:
3559
+
3560
+ | collection source | in-place edit announced with `slotContentService.markEntityChanged(entitySlot, changedColumns?)` | cost while a resource listens | pick it when |
3561
+ |---|---|---|---|
3562
+ | `OIMPersistRecordSourceCollectionWriteDriven` | NOT persisted: it hears only the writer | none: every write stays on the core fast path | every change goes through the upsert / update / delete doors |
3563
+ | `OIMPersistRecordSourceCollectionChangeDriven` | persisted: while subscribed it adds a content-change listener of every column to the store slot | EVERY write to that store slot leaves the core fast path, not only `markEntityChanged`: one subframe open/close, changed-column bookkeeping and the listener loop per write | the app edits entities in place |
3564
+ - **Bucket**: the entities one bucket slot holds. Get the slot with
3565
+ `bucketCollectionService.holdSlotByKey(bucketStoreSlot, key)`. The source watches the
3566
+ bucket slot with one effect and each member entity slot with one effect. An entity
3567
+ that leaves the bucket is deleted from the backend and stays in the entity
3568
+ store slot. The first subscription reports every member, so `start()` writes the
3569
+ current members on the first drain, except members still holding the value a
3570
+ hydrate restored.
3571
+ - **Single entity**: the snapshot is the entity itself (a store slot of a single entity service, read through
3572
+ `findSingleSlot(entityStoreSlot)`), so it
3573
+ goes only into `OIMPersistResourceSnapshot`. Its first subscription reports the
3574
+ current value unless it is the value a hydrate restored. `readSnapshot` throws when
3575
+ the entity slot holds no value.
3576
+
3577
+ The bucket and single-entity sources tell their own restored writes apart from user
3578
+ writes by comparing the entity, column by column with SameValueZero (NaN equals NaN, as core's write guard does), against a copy taken when
3579
+ it was restored (`OIMPersistRestoredEntities`). A wake is not written while the source
3580
+ still holds the restored value. The first time a different value is seen, the memo for
3581
+ that key is gone, so an edit and a later revert to the restored value, in another
3582
+ frame, are both written.
3583
+
3584
+ ```ts
3585
+ import {
3586
+ OIMPersistRecordSourceBucketDriven, OIMPersistResourceRecords, OIMPersistSchedulerFrameDriven, OIMPersistor,
3587
+ } from '@oimdb/persist';
3588
+ import { OIMPersistMemoryBackend, OIMPersistMemoryRecordsStrategy, OIMPersistMemoryWriteBatch } from '@oimdb/persist-memory';
3589
+
3590
+ type TDeckCardSlot = TOIMEntitySlot<Card, TOIMStoreKey>;
3591
+ const deckCardCollectionService = createOIMEntityService<Card>(db, { keyOf: card => card.id });
3592
+ const deckCardEntityStoreSlot = deckCardCollectionService.createStoreSlot(db);
3593
+ const deckBucketService = createOIMBucketService<TDeckCardSlot>(db);
3594
+ const deckBucketStoreSlot = deckBucketService.createStoreSlot(db);
3595
+ const spadesBucketSlot = deckBucketService.holdSlotByKey(deckBucketStoreSlot, 'spades');
3596
+
3597
+ const deckPersistor = new OIMPersistor<OIMPersistMemoryBackend, OIMPersistMemoryWriteBatch>({
3598
+ persistBackend: new OIMPersistMemoryBackend(),
3599
+ persistScheduler: new OIMPersistSchedulerFrameDriven({ channelSlot: db.defaultChannelSlot }),
3600
+ });
3601
+ deckPersistor.addPersistResource(new OIMPersistResourceRecords({
3602
+ persistRecordSource: new OIMPersistRecordSourceBucketDriven({
3603
+ channelMap: db,
3604
+ entityCollectionService: deckCardCollectionService,
3605
+ entityStoreSlot: deckCardEntityStoreSlot,
3606
+ slotContentBucketService: deckBucketService.slotContentService,
3607
+ bucketSlot: spadesBucketSlot,
3608
+ }),
3609
+ persistRecordsStrategy: new OIMPersistMemoryRecordsStrategy<TOIMStoreKey, Card>({ storageKey: 'spades' }),
3610
+ }));
3611
+ await deckPersistor.hydrate();
3612
+ deckPersistor.start();
3613
+
3614
+ const aceSlot = deckCardCollectionService.slotContentService.upsertEntity(
3615
+ deckCardEntityStoreSlot, { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' },
3616
+ );
3617
+ deckBucketService.slotContentService.writeBucketMemberSlots(spadesBucketSlot, [aceSlot]);
3618
+ db.flush();
3619
+ await deckPersistor.whenIdle();
3620
+ ```
3621
+
3622
+ ### Schedulers
3623
+
3624
+ `IOIMPersistScheduler` is `attachDrain(drain)`, `start()`, `stop()`, `requestDrain()`.
3625
+ The persistor calls `attachDrain` from its constructor; both shipped schedulers throw
3626
+ when a second persistor attaches, so construct one scheduler per persistor.
3627
+
3628
+ - `OIMPersistSchedulerMicrotaskDriven`: `requestDrain()` queues one microtask; the
3629
+ requests that follow before it runs are absorbed by it. `stop()` makes a queued
3630
+ microtask do nothing.
3631
+ - `OIMPersistSchedulerFrameDriven({ channelSlot })`: while started it subscribes with
3632
+ `OIMChannelService.INSTANCE.subscribeAfterEachFrame(channelSlot, …)` and drains after a frame only when a drain was
3633
+ requested since the last one; `stop()` unsubscribes and forgets the request. It
3634
+ holds no slot and never asks the channel for a frame: it drains only after a frame
3635
+ the channel runs anyway.
3636
+
3637
+ **The pairing is a rule.** A write to an entity slot that nobody holds causes no frame
3638
+ (`OIMChannelService.INSTANCE.writeWithoutFlushCheck` returns at once when `holdCount` is `0`), so a
3639
+ collection source on the frame scheduler can report changes that no frame ever
3640
+ drains. Pair `OIMPersistRecordSourceCollectionWriteDriven` and
3641
+ `OIMPersistRecordSourceCollectionChangeDriven` with
3642
+ `OIMPersistSchedulerMicrotaskDriven`. The bucket and single-entity sources report from
3643
+ effects, which cause the frames they run in, so pair them with
3644
+ `OIMPersistSchedulerFrameDriven`. Resources of both groups in one persistor share its
3645
+ one scheduler; give them two persistors when they need different schedulers.
3646
+
3647
+ `whenIdle()` resolves when the work queued at the moment of the call has settled. On
3648
+ the frame scheduler the drain runs inside `db.flush()`, so `db.flush(); await
3649
+ persistor.whenIdle()` covers the autosave. On the microtask scheduler the autosave
3650
+ is queued on the microtask after the write: let one microtask pass before calling
3651
+ `whenIdle()`, or call `persist()`.
3652
+
3653
+ ### Persistor methods
3654
+
3655
+ | method | what it does |
3656
+ |---|---|
3657
+ | `addPersistResource(persistResource)` | registers and returns it; on a started persistor it starts the resource first and registers it only if that start succeeded (a failed start is thrown from `addPersistResource`) |
3658
+ | `removePersistResource(persistResource)` | stops it and drops its pending write |
3659
+ | `start()` / `stop()` | subscribe / unsubscribe every resource and the scheduler; `start()` skips quarantined resources; pending writes survive `stop()`. A resource whose subscribe throws does not stop the others: each failure goes to `onPersistError` with `persistOperation: 'start'`, or, without a handler, all are thrown as one `AggregateError`. A resource whose start failed stays registered and unsubscribed, with no autosave, until `stop()` and `start()` again; `addPersistResource` on a started persistor does not register a resource whose start throws. A bucket or single-entity source reports its current value at subscribe time (except a hydrate-restored value), so the first drain after `start()` writes it; a collection source reports nothing then |
3660
+ | `destroy()` | `stop()`, then forgets every resource |
3661
+ | `hydrate()` | reads every resource and writes it into its source; ends the upsert-only mode of an unloaded resource |
3662
+ | `persist()` | writes every resource in full, started or not, in one write batch; a records resource with unloaded keys writes its in-memory records as upserts over the stored table instead of replacing it, plus the deletions it already heard |
3663
+ | `clearPersisted()` | deletes every resource's stored data in one write batch; memory is untouched; ends the upsert-only mode of an unloaded resource |
3664
+ | `unload(persistResourceRecords, keys)` | writes the keys' current values plus the resource's pending change in one write batch, repeated until the keys have no unwritten change (unload holds the keys' entity slots while it writes and compares their revision stamps, a slot's stamp moving only on a real write; it also checks the resource's pending set and whether a key's slot was replaced), then removes the keys from memory only |
3665
+ | `load(persistResourceRecords, keys)` | the pair of `unload`: queued after the pending writes, reads only those keys from the backend (`readRecordsOfKeys`) and puts the stored entities back without writing them back; skips a key that holds a value in memory or has a change the resource has not written yet; the keys stop counting as unloaded |
3666
+ | `whenIdle()` | resolves when the queued work settles; never rejects |
3667
+
3668
+ Every promise-returning call and every autosave runs on one ordered chain, one at a
3669
+ time, in request order. A snapshot is read when its work runs, not when it was queued.
3670
+
3671
+ ### The write batch, retry and quarantine
3672
+
3673
+ - **One batch per drain.** A drain takes every dirty resource with a pending write,
3674
+ calls `persistBackend.createWriteBatch()` once, lets each resource write into it,
3675
+ and calls `commit()` once. `persist()` and `clearPersisted()` do the same for every
3676
+ registered resource.
3677
+ - **Whole-batch failure.** Every resource in the batch puts its in-flight change back
3678
+ (a records resource returns the failed entity slots to its dirty set; a snapshot
3679
+ resource marks itself pending), then the persistor writes each resource again in a
3680
+ batch of its own, so the healthy ones commit and the failure is attributed to the
3681
+ resource that caused it. A failed batch of N resources costs 1 + N commits.
3682
+ - **Retry.** A failed autosave leaves the resource dirty; the next drain writes it.
3683
+ The next drain comes from the next drain request (a change in any resource), a
3684
+ `persist()` or an `unload`. There is no retry timer.
3685
+ - **Quarantine.** Three consecutive autosave failures of one resource stop it
3686
+ listening to its source and drop its pending write; it is reported once with
3687
+ `persistOperation: 'quarantine'`, or on `console.error` without `onPersistError`.
3688
+ A successful `persist()` of it lifts the quarantine and, on a started persistor,
3689
+ starts it again; `start()` skips a quarantined resource. Any successful write
3690
+ resets the count, whichever operation committed it (autosave, `persist()`,
3691
+ `clearPersisted()`, `unload`); `persist()` failures do not add to it.
3692
+ - **Errors.** With `onPersistError`, each failure is handed over and the promise
3693
+ resolves; a failed autosave below quarantine is reported with `'persist'`, a failed
3694
+ resource subscribe in `start()` with `'start'`. Without it, `start()` (throws),
3695
+ `hydrate()`, `persist()` and `clearPersisted()` attempt every resource, then
3696
+ reject with one `AggregateError` whose `errors` holds every failure; a failed
3697
+ autosave below quarantine is silently retained, and the quarantine logs to the
3698
+ console. `unload` rejects its own promise when its write fails, and then leaves
3699
+ the keys in memory.
3700
+
3701
+ ### Hydrate: reconcile and refusal
3702
+
3703
+ `hydrate()` reads each resource through its strategy. A read that resolves
3704
+ `undefined` means nothing is stored, and nothing is applied. Anything else, an empty
3705
+ `{ records: [] }` included, is applied: decoded through `persistCodec` on a snapshot
3706
+ resource, passed through `persistHydrateReconcile(currentSnapshot, incomingSnapshot)`
3707
+ when set, then written into the source. Hydrate's own writes are not reported as
3708
+ changes, so `start()` after `hydrate()` writes nothing back.
3709
+
3710
+ What the source does with it:
3711
+
3712
+ - collection: upserts every record into the entity store slot, then deletes every entity the records do not
3713
+ name; the store slot becomes exactly the stored content;
3714
+ - bucket: upserts every record into the entity store slot and sets the bucket's
3715
+ members to exactly the restored entities;
3716
+ - single entity: `singleSlotService.slotContentService.upsertEntity(entityStoreSlot, entity)`.
3717
+
3718
+ **Refusal.** If the source changes while the backend read is in flight and the
3719
+ resource has no `persistHydrateReconcile`, hydrate throws instead of applying, because
3720
+ applying would revert that change. The source keeps its newer state; the error goes
3721
+ to `onPersistError` with `'hydrate'`, or into the `AggregateError`. Before the read
3722
+ the resource calls `persistSnapshotSource.markSourceChanges()`, which returns an
3723
+ `IOIMPersistSourceChangeMark` (`isSourceChangedSinceMark()`, `dispose()`): the
3724
+ single-entity and bucket sources return an `OIMPersistSourceChangeMarkSlotDriven`
3725
+ that holds the slots and compares their revision stamps (`changedAtRevision`, `changedAtEpoch`) with the
3726
+ channel clock reading taken at the mark (`oimIsAnySlotChangedAfter`), the
3727
+ collection sources an `OIMPersistSourceChangeMarkSubscriptionDriven` whose flag their
3728
+ own report sets. A change made during the read is seen even when no frame runs, and
3729
+ the guard runs whether or not the persistor is started.
3730
+
3731
+ **Reconcile.** `oimReconcileByKey<TKey, TEntity>(resolveEntity)` builds a
3732
+ `TOIMPersistHydrateReconcile<TOIMPersistRecords<TKey, TEntity>>`.
3733
+ `resolveEntity(currentEntity, incomingEntity, key)` runs for every stored key, with
3734
+ `currentEntity` `undefined` when memory lacks the key, and returns the entity to keep
3735
+ or `undefined` to drop it. Keys only in memory are kept. Order: memory's keys first,
3736
+ then the stored keys memory lacked. A snapshot resource over a single entity takes any
3737
+ `(currentSnapshot, incomingSnapshot) => snapshot` function.
3738
+
3739
+ ```ts
3740
+ import { oimReconcileByKey } from '@oimdb/persist';
3741
+
3742
+ const answerReconcile = oimReconcileByKey<TOIMStoreKey, Card>(
3743
+ (currentEntity, incomingEntity) => currentEntity === undefined
3744
+ ? incomingEntity
3745
+ : { ...currentEntity, rank: incomingEntity.rank },
3746
+ );
3747
+ ```
3748
+
3749
+ ### unload, load and clearPersisted
3750
+
3751
+ `persistor.unload(persistResourceRecords, keys)` takes an `OIMPersistResourceRecords`
3752
+ only. It first drains the persistor's dirty resources onto the chain, then, in its
3753
+ own turn, writes in one write batch that resource's pending change and the current
3754
+ value of every unloaded key (`persistRecordSource.readRecordsDelta(persistRecordSource.findEntitySlotsByKeys(keys))`), so
3755
+ an edit no drain had reached, or one made while the persistor was stopped or the
3756
+ resource quarantined, is saved. unload holds the keys' entity slots while it writes, and after each commit it
3757
+ compares their revision stamps (a slot's stamp moves only on a real write), checks the
3758
+ resource's pending set and whether a key's slot was replaced; while any of those moved it
3759
+ writes another batch of the same shape, so an edit made during any commit
3760
+ (and, on the bucket source, an edit the channel has not flushed yet) reaches the
3761
+ backend. Only then does it remove
3762
+ the keys from the source with this resource's reporting suppressed, so the removal is
3763
+ not written by it. The backend keeps the keys; `load` brings those keys back and the
3764
+ next `hydrate()` brings back everything, and a restored entity persists its next edit like any other. A failed write rejects
3765
+ the unload and leaves the keys in memory. On a collection source and on the bucket source
3766
+ the keys are deleted from the entity store slot.
3767
+ Deleting an entity does not take it out of a bucket: the bucket keeps the slot as a
3768
+ member until it is refiled. The bucket source remembers the deletion as restored for
3769
+ every key that is a member of the bucket at unload time, whether or not the persistor
3770
+ is listening.
3771
+
3772
+ While any key of a records resource is unloaded, `persist()` writes that resource's
3773
+ in-memory records as upserts over the stored table instead of replacing it, plus the
3774
+ deletions the resource already heard (its pending deletions), so the unloaded records
3775
+ stay on the backend. A deletion it never heard is not written in that mode.
3776
+ `hydrate()` and `clearPersisted()` end that mode, and so does a `load` of every
3777
+ unloaded key.
3778
+
3779
+ `persistor.load(persistResourceRecords, keys)` is the pair of `unload` and takes an
3780
+ `OIMPersistResourceRecords` only (a key-value backend has no records strategy, so a
3781
+ snapshot resource has no `load`). It drains the dirty resources onto the chain and
3782
+ runs after those writes. In its turn it reads only those keys through
3783
+ `persistRecordsStrategy.readRecordsOfKeys(persistBackend, keys)`: on IndexedDB one
3784
+ `readonly` transaction with one `get` per key, on memory a lookup of each key in the
3785
+ stored map; a key the backend does not hold is left out. It skips every key that holds
3786
+ a value in memory or has a change the resource has not written yet (pending or in a
3787
+ commit), so neither a write nor a heard deletion made during the read is overwritten.
3788
+ The rest goes to `persistRecordSource.loadRecords(persistRecords)` with this
3789
+ resource's reporting suppressed, so the loaded entities are not written back. A
3790
+ collection source upserts them into the entity store slot; the bucket source upserts them
3791
+ into the entity store slot, adds the slots back to the bucket
3792
+ (`slotContentBucketService.addBucketMemberSlots`) and remembers them as restored. The keys
3793
+ stop counting as unloaded, so once none is, `persist()` replaces the stored table
3794
+ again. Another resource over the same entity store slot is not suppressed and hears
3795
+ the upserts as writes. A failed read rejects the promise `load` returned.
3796
+
3797
+ When to unload and when to load (on a screen change, on idle, on a read miss) is the
3798
+ application's decision, or an orchestrator's; persist offers the two doors and decides
3799
+ neither.
3800
+
3801
+ **Limit:** another resource over the same entity store slot is not suppressed. It
3802
+ reports the unloaded keys as deletions, and its next autosave deletes them from ITS
3803
+ storage. Unload only keys no other resource persists.
3804
+
3805
+ **Limit:** a deletion made while the persistor is stopped, or the resource is
3806
+ quarantined, is not heard. While any key of the resource is unloaded, `persist()`
3807
+ writes no deletion it did not hear, and `unload` writes it only while the entity's slot
3808
+ still exists (once the slot was released no slot is found for the key); otherwise the
3809
+ record comes back on the next `hydrate()`. Call `persist()` while started, and with no
3810
+ key unloaded, to make a deletion the resource never heard durable.
3811
+
3812
+ `persistor.clearPersisted()` calls every resource's strategy delete
3813
+ (`deleteRecords` / `deleteSnapshot`) inside one write batch with one commit, under the
3814
+ same whole-batch split and error rules as `persist()`. Memory is untouched.
3815
+
3816
+ ### Codecs
3817
+
3818
+ A snapshot resource encodes on every write and decodes on hydrate through its
3819
+ required `persistCodec`, an `IOIMPersistCodec<TSourceSnapshot, TPersistedSnapshot>`
3820
+ (`encode`, `decode`). A records resource has no codec: it stores entities as the
3821
+ source holds them.
3822
+
3823
+ - `new OIMPersistCodecIdentity<TSnapshot>()`: passes the snapshot through.
3824
+ - `new OIMPersistCodecVersioned<TSourceSnapshot, TPersistedSnapshot>({ version, migrations, persistCodec })`:
3825
+ stores a `TOIMPersistVersionedShape`, `{ version, data }`, where `data` is the
3826
+ inner `persistCodec`'s encoding (required; use `OIMPersistCodecIdentity` when
3827
+ nothing transforms). `migrations` is a `Record<number, (data: unknown) => unknown>`
3828
+ keyed by the version each migration produces. On decode it runs, ascending, every
3829
+ migration whose key is above the stored `version` and at most the current one,
3830
+ then decodes with the inner codec. A stored `version` above the codec's own is
3831
+ refused: `decode` throws and the hydrate reports it with `'hydrate'`. The strategy
3832
+ is typed with `TOIMPersistVersionedShape`.
3833
+
3834
+ ```ts
3835
+ import {
3836
+ OIMPersistCodecIdentity, OIMPersistCodecVersioned, OIMPersistKeyValueBackend, OIMPersistKeyValueEntryStrategy,
3837
+ OIMPersistKeyValueStorageItemDriven, OIMPersistKeyValueWriteBatch, OIMPersistResourceSnapshot,
3838
+ OIMPersistSchedulerFrameDriven, OIMPersistSnapshotSourceSingleEntityDriven, OIMPersistor,
3839
+ } from '@oimdb/persist';
3840
+ import type { TOIMPersistVersionedShape } from '@oimdb/persist';
3841
+
3842
+ type TSettings = { theme: 'light' | 'dark'; density: 'compact' | 'comfortable' };
3843
+
3844
+ const settingsSingleEntityService = createOIMSingleEntityService<TSettings>(db);
3845
+ const settingsEntityStoreSlot = settingsSingleEntityService.createStoreSlot(db);
3846
+ settingsSingleEntityService.slotContentService.upsertEntity(settingsEntityStoreSlot, { theme: 'light', density: 'compact' });
3847
+ const itemByStorageKey = new Map<string, string>();
3848
+ const settingsPersistor = new OIMPersistor<OIMPersistKeyValueBackend, OIMPersistKeyValueWriteBatch>({
3849
+ persistBackend: new OIMPersistKeyValueBackend({
3850
+ persistKeyValueStorage: new OIMPersistKeyValueStorageItemDriven({
3851
+ persistKeyValueItemStorage: {
3852
+ getItem: storageKey => itemByStorageKey.get(storageKey) ?? null,
3853
+ setItem: (storageKey, item) => { itemByStorageKey.set(storageKey, item); },
3854
+ removeItem: storageKey => { itemByStorageKey.delete(storageKey); },
3855
+ },
3856
+ }),
3857
+ }),
3858
+ persistScheduler: new OIMPersistSchedulerFrameDriven({ channelSlot: db.defaultChannelSlot }),
3859
+ });
3860
+ settingsPersistor.addPersistResource(new OIMPersistResourceSnapshot({
3861
+ persistSnapshotSource: new OIMPersistSnapshotSourceSingleEntityDriven({
3862
+ channelMap: db, singleSlotService: settingsSingleEntityService, entityStoreSlot: settingsEntityStoreSlot,
3863
+ }),
3864
+ persistSnapshotStrategy: new OIMPersistKeyValueEntryStrategy<TOIMPersistVersionedShape>({ storageKey: 'settings' }),
3865
+ persistCodec: new OIMPersistCodecVersioned<TSettings, TSettings>({
3866
+ version: 2,
3867
+ migrations: { 2: data => ({ density: 'comfortable', ...(data as object) }) },
3868
+ persistCodec: new OIMPersistCodecIdentity<TSettings>(),
3869
+ }),
3870
+ }));
3871
+ await settingsPersistor.hydrate();
3872
+ settingsPersistor.start();
3873
+
3874
+ settingsSingleEntityService.slotContentService.updateEntity(
3875
+ settingsSingleEntityService.getSingleSlot(settingsEntityStoreSlot), { theme: 'dark' },
3876
+ );
3877
+ db.flush();
3878
+ await settingsPersistor.whenIdle();
3879
+ ```
3880
+
3881
+ ### Backends and their strategies
3882
+
3883
+ **Memory** (`@oimdb/persist-memory`). `new OIMPersistMemoryBackend()`. Snapshot values
3884
+ and records live in two separate maps, so a snapshot and a records resource may share
3885
+ a `storageKey`. `OIMPersistMemoryRecordsStrategy<TKey, TEntity>({ storageKey })` keeps one
3886
+ `Map` of key to entity per `storageKey`: autosave sets and deletes keys, `persist()`
3887
+ replaces the map (while the resource has unloaded keys, sets keys and deletes only the deletions the resource already heard), `clearPersisted()` deletes it, and a `load` looks each of its keys up in the map through `readRecordsOfKeys`. `OIMPersistMemorySnapshotStrategy<TSnapshot>({ storageKey })`
3888
+ keeps one value. `readRecords(storageKey)` and `readSnapshotValue(storageKey)` read
3889
+ what was committed. `OIMPersistMemoryWriteBatch` queues every change and applies them in
3890
+ order on `commit()`.
3891
+
3892
+ **JSON** (`@oimdb/persist-json`, depends on `@oimdb/persist-memory`).
3893
+ `OIMPersistJsonBackend` extends `OIMPersistMemoryBackend` and takes the memory
3894
+ strategies. `dehydrate()` returns a `TOIMPersistJsonDump`,
3895
+ `{ snapshotValues: Record<storageKey, value>, recordsByStorageKey: Record<storageKey, { key, entity }[]> }`;
3896
+ `new OIMPersistJsonBackend({ initialPersistJsonDump })` writes such a dump straight into
3897
+ its maps. The SSR flow: on the server, `await persistor.persist()` and then
3898
+ `JSON.stringify(persistJsonBackend.dehydrate())`; on the client,
3899
+ `new OIMPersistJsonBackend({ initialPersistJsonDump: JSON.parse(jsonText) })`, register
3900
+ the same resources under the same `storageKey`s, `await persistor.hydrate()`. To merge
3901
+ a durable cache over the server's content, hydrate the JSON persistor first, then the
3902
+ durable persistor with `persistHydrateReconcile`, then `start()` the durable one.
3903
+
3904
+ **IndexedDB** (`@oimdb/persist-idb`). `new OIMPersistIndexedDbBackend({ databaseName, databaseVersion?, indexedDb? })`;
3905
+ `indexedDb` defaults to `globalThis.indexedDB` and the constructor throws without
3906
+ either. `OIMPersistIndexedDbRecordsStrategy<TKey, TEntity>({ tableName })` stores one value
3907
+ per entity key in an object store: autosave puts and deletes per key, `persist()`
3908
+ clears the table and puts every entity (while the resource has unloaded keys, only
3909
+ puts plus a delete per deletion the resource already heard), `clearPersisted()` clears it,
3910
+ and a `load` reads its keys through `readRecordsOfKeys` in one `readonly` transaction,
3911
+ one `get` per key.
3912
+ `OIMPersistIndexedDbSnapshotStrategy<TSnapshot>({ tableName, primaryKey })` stores one value
3913
+ at `primaryKey`. One commit is one `readwrite` transaction over every table the batch
3914
+ touches. A commit opens the database and creates missing tables by a version upgrade
3915
+ (retried when another connection upgrades first, up to five attempts; an upgrade
3916
+ blocked for five seconds rejects); a read never creates a table, and a missing table
3917
+ reads as `undefined`. A `TOIMPersistIndexedDbPrimaryKey` is an `IDBValidKey` or a plain
3918
+ object; a plain object is encoded as the string `oimdb:object:` plus its JSON with
3919
+ sorted property names, and is read back as that string.
3920
+
3921
+ **Key-value** (`@oimdb/persist`). `new OIMPersistKeyValueBackend({ persistKeyValueStorage, persistKeyValueSerializer? })`
3922
+ stores serialized strings under string keys. `persistKeyValueStorage` is an
3923
+ `IOIMPersistKeyValueStorage` (`readItems(storageKeys)`, `writeItems(itemByStorageKey)`,
3924
+ `removeItems(storageKeys)`); `OIMPersistKeyValueStorageItemDriven({ persistKeyValueItemStorage })`
3925
+ adapts any `TOIMPersistKeyValueItemStorage` (`getItem`, `setItem`, `removeItem`,
3926
+ synchronous or returning promises) with one call per key. The serializer is a
3927
+ `TOIMPersistKeyValueSerializer` (`serialize`, `deserialize`) and defaults to
3928
+ `OIM_PERSIST_KEY_VALUE_JSON_SERIALIZER`. Strategies, both snapshot strategies:
3929
+
3930
+ | strategy | stored as |
3931
+ |---|---|
3932
+ | `OIMPersistKeyValueEntryStrategy<TSnapshot>({ storageKey })` | the whole snapshot under `storageKey` |
3933
+ | `OIMPersistKeyValuePathStrategy<TSnapshot>({ storageKey, path })` | the snapshot at `path` (a `string[]`) inside an object under `storageKey`; several resources may share the key; the constructor throws on an empty `path` |
3934
+
3935
+ `OIMPersistKeyValueWriteBatch` commits with at most one `readItems` (keys with path
3936
+ writes or path deletions and no whole-value write in the batch), one `writeItems` and
3937
+ one `removeItems`; it serializes every value before writing any. Path segments are
3938
+ own properties, so a stored `__proto__` is data.
3939
+
3940
+ - **localStorage** (`@oimdb/persist-localstorage`): `OIMPersistLocalStorageBackend`
3941
+ extends `OIMPersistKeyValueBackend` over a `TOIMPersistKeyValueItemStorage`
3942
+ (`getItem`, `setItem`, `removeItem`) given as `persistKeyValueItemStorage`, which
3943
+ defaults to `globalThis.localStorage`; the constructor throws without either. One `setItem` per written key.
3944
+ - **async KV** (`@oimdb/persist-async-kv`): no backend class. Use
3945
+ `new OIMPersistKeyValueBackend({ persistKeyValueStorage: new OIMPersistAsyncKVStorage({ persistAsyncKVMultiStorage }) })`,
3946
+ where `persistAsyncKVMultiStorage` is a `TOIMPersistAsyncKVMultiStorage` (`multiGet`, `multiSet`,
3947
+ `multiRemove`, each returning a promise), the shape of React Native's
3948
+ `AsyncStorage`. One `multiGet`, one `multiSet` and one `multiRemove` per commit at
3949
+ most.
3950
+
3951
+ ### Writing a backend, a strategy, a source
3952
+
3953
+ - Backend: `IOIMPersistBackend<TWriteBatch>` with `createWriteBatch()`; its batch is an
3954
+ `IOIMPersistWriteBatch` with `commit(): Promise<void>`, collecting changes and
3955
+ applying them in `commit`. `OIMPersistWriteBatchChanges<TWriteBatchTarget>` is the list a
3956
+ batch keeps them in: `addWriteBatchChange(change)`, `hasWriteBatchChanges`,
3957
+ `applyWriteBatchChanges(target)`. `oimWritePersistRecordsDelta(delta, putRecord, deleteRecord)`
3958
+ walks a `TOIMPersistRecordsDelta` in the one order every backend uses: upserts, then deletions.
3959
+ - Snapshot strategy: `IOIMPersistSnapshotStrategy<TPersistBackend, TWriteBatch, TSnapshot>`
3960
+ with `readSnapshot(persistBackend)`, `writeSnapshot(writeBatch, snapshot)`,
3961
+ `deleteSnapshot(writeBatch)`. Records strategy:
3962
+ `IOIMPersistRecordsStrategy<TPersistBackend, TWriteBatch, TKey, TEntity>` with
3963
+ `readRecords(persistBackend)`, `readRecordsOfKeys(persistBackend, keys)`,
3964
+ `writeRecords(writeBatch, persistRecords)`,
3965
+ `writeRecordsDelta(writeBatch, persistRecordsDelta)` (a `TOIMPersistRecordsDelta`,
3966
+ `{ upserts, deletedKeys }`), `deleteRecords(writeBatch)`. A read resolves
3967
+ `undefined` only when nothing is stored; `readRecordsOfKeys` resolves the records of
3968
+ the keys the backend holds and leaves out the rest.
3969
+ - Source: `IOIMPersistSnapshotSource<TSnapshot>` (`readSnapshot`, `writeSnapshot`,
3970
+ `subscribeSnapshotChanged`, `markSourceChanges`) or
3971
+ `IOIMPersistRecordSource<TKey, TEntity>`, which adds `subscribeEntitySlotChanged`,
3972
+ `readRecordsDelta(entitySlots)`, `findEntitySlotsByKeys(keys)`, `markEntitySlotChanges(entitySlots)`,
3973
+ `loadRecords(persistRecords)`, `unloadKeys(keys)`.
3974
+ `markSourceChanges()` is required and returns an `IOIMPersistSourceChangeMark`
3975
+ (`isSourceChangedSinceMark()`, `dispose()`); build it with
3976
+ `new OIMPersistSourceChangeMarkSlotDriven({ channelClock, slots })` or
3977
+ `new OIMPersistSourceChangeMarkSubscriptionDriven({ subscribeSourceChanged })`.
3978
+ It must accept more than one subscription, each with its own `TOIMDisposer`.
3979
+ - Scheduler: `IOIMPersistScheduler`, above.
3980
+
3981
+ ### Limits
3982
+
3983
+ - **No key-value records strategy.** Over `OIMPersistKeyValueBackend` (localStorage,
3984
+ async KV) an entity store slot or a bucket goes through `OIMPersistResourceSnapshot`: every
3985
+ autosave walks every entity slot of the source and serializes the whole snapshot
3986
+ under one key.
3987
+ - **A bucket membership change walks the bucket.** Each change of the bucket slot
3988
+ re-reads every member and every watched slot, and the source keeps one effect per
3989
+ member entity slot.
3990
+ - **The memory and JSON backends keep values by reference.** With
3991
+ `createOIMEntityWriterInPlace()` the stored entity is the live entity object, so an
3992
+ in-place write moves the stored value before any commit, and `dehydrate()` returns
3993
+ the live objects. Use `createOIMEntityWriterReplacing()` with
3994
+ `entityInsertStrategy: oimInsertEntityWithCopy` where the stored value must stay
3995
+ what was committed, and serialize a dump at once.
3996
+ - **Hydrate without a reconcile replaces.** The collection source deletes every
3997
+ entity the stored records do not name, and an existing but empty records table
3998
+ (IndexedDB after `clearPersisted()`) reads as an empty list and empties the
3999
+ entity store slot.
4000
+ - **A key-value commit is not atomic across keys**: `writeItems` and `removeItems` are
4001
+ separate calls, and the item-driven storage calls `setItem` once per key.
4002
+ - **One backend and one scheduler per persistor.** Sources needing different
4003
+ schedulers, or two backends, take two persistors.
4004
+
4005
+ ---
4006
+
4007
+ ## `@oimdb/exodra`
4008
+
4009
+ Turns a core read into an Exodra `TExoBindable` or `TExoBindableList`. It depends
4010
+ only on the type-only `@exodra/reactivity-types`.
4011
+
4012
+ ### One door: `exoBinding(channelMap, binding)`
4013
+
4014
+ A subscription here is one call — `OIMEffectService.INSTANCE.subscribeEffect(channelMap, sourceSlots, sourceComputeds, onChange)` —
4015
+ and the value is read off the slots afterwards. So one adapter covers an entity, a
4016
+ column, a bucket's slots and a derivation:
4017
+
4018
+ ```ts
4019
+ import { exoBinding } from '@oimdb/exodra';
4020
+
4021
+ const rank = rankService.computedCollectionService.holdSlotByKey(ranks, 'c1');
4022
+
4023
+ exoBinding(db, { sourceSlots: [slot], derive: () => slot.entity });
4024
+ exoBinding(db, { sourceSlots: [todo], derive: () => Array.from(pageService.slotContentService.getBucketMemberSlots(todo)) });
4025
+ exoBinding(db, { sourceComputeds: [rank], derive: () => rank.computedValueService.getValueForSlot(rank) });
4026
+ ```
4027
+
4028
+ `TOIMExoBinding<TValue>` is `{ sourceSlots?, sourceComputeds?, derive }`; both source keys are optional and default to none. `sourceSlots` is typed
4029
+ `readonly TOIMSlot[]` and `sourceComputeds` `readonly TOIMComputedSlot[]`.
4030
+
4031
+ What `exoBinding` owns, each failing silently if a caller writes the pair by hand:
4032
+ it **settles every derivation before the read** (a derivation is asked and never
4033
+ woken); and it
4034
+ arms **one effect** however many downstream subscribers there are, and none while
4035
+ nobody listens.
4036
+
4037
+ **There is no observable wrapper on either side of this bridge**, and there must
4038
+ not be one: the core hands over no object carrying a value and a subscribe, so an
4039
+ intermediate type would have one producer and one consumer, both inside this
4040
+ package. Build the binding directly.
4041
+
4042
+ ### Lifetime is the caller's
4043
+
4044
+ `exoBinding` **holds nothing**, and that is deliberate: a bindable is lazy, so it
4045
+ outlives its own subscriptions, and a hold scoped to one of them buys nothing —
4046
+ a hold's give-back ends in the owner's release guard, so returning it at teardown
4047
+ leaves exactly the state the teardown would have left. Whoever resolved the
4048
+ identity holds it, and says so with `TOIMExoDisposable<TValue>` =
4049
+ `TExoBindable<TValue> & { readonly dispose: TOIMDisposer }`:
4050
+
4051
+ ```ts
4052
+ import { exoBinding } from '@oimdb/exodra';
4053
+
4054
+ const rankFor = (id: TOIMStoreKey) => {
4055
+ const held = rankService.computedCollectionService.holdSlotByKey(ranks, id);
4056
+ return Object.assign(
4057
+ exoBinding(db, { sourceComputeds: [held], derive: () => held.computedValueService.getValueForSlot(held) }),
4058
+ { dispose: () => { held.owner.unholdSlot(held); } },
4059
+ );
4060
+ };
4061
+ ```
4062
+
4063
+ Only a valueless identity is exposed by getting this wrong: an empty bucket, a key
4064
+ resolved before its entity arrived, an entity since removed.
4065
+
4066
+ ### The views
4067
+
4068
+ - `exoSource(read, subscribe)` — the lazy, ref-counted base. For sources that are
4069
+ **not** this engine's.
4070
+ - `exoCombine(sources, fn)` — fan-in across any `TOIMExoReadable`. Not glitch-free;
4071
+ keep heavy fan-in inside one computed.
4072
+ - `exoFollowKey(key, makeBindable)` — repoints as a selection moves. The factory
4073
+ must return a `TOIMExoDisposable`.
4074
+ - `exoRows(keys, render)` — identity-stable children keyed by primary key. `render`
4075
+ returns `TOIMExoRenderedRow<TSchema>` = `{ schema, dispose }`.
4076
+
4077
+ ### Lists are a fork, and both arms ship
4078
+
4079
+ Who decided the order is the difference, and the core already forks on it.
4080
+
4081
+ | | `exoDerivedList(channelMap, computedSlot, render, maxCommandsPerSlot?)` | `exoPositionalList(channelMap, slotContentBucketService, bucketSlot, render)` |
4082
+ |---|---|---|
4083
+ | takes | a `TOIMComputedSlot<readonly TOIMEntitySlot[]>` | a bucket content service built with an `OIMBucketCommands` (the default of `createOIMBucketService`) and one of its bucket slots, from any of its store slots |
4084
+ | the script | **derived**, by `oimOrderedListCommandsOfTransition` | **recorded**, by `OIMBucketCommands`, read inside the frame |
4085
+ | per changed frame | one diff: two passes plus an LIS pass, `common − LIS` moves | one pass over the frame's own script |
4086
+
4087
+ Both hand back `TOIMExoList<TSchema>` = `TExoBindableList<TSchema> & { dispose }`,
4088
+ and both are **attached from construction**, not from their first subscriber —
4089
+ that is what lets `snapshot()` promise the list every later op is measured
4090
+ against. Each item's schema is memoised in a `WeakMap` inside the bridge, so a
4091
+ `reset` hands back the objects the previous snapshot did.
4092
+
4093
+ The derived script comes from `oimOrderedListCommandsOfTransition`. Items are
4094
+ memoised per list, so two lists showing one entity slot render it twice.
4095
+
4096
+ ### Full export list
4097
+
4098
+ `exoBinding`, `exoSource`, `exoCombine`, `exoFollowKey`, `exoRows`,
4099
+ `exoDerivedList`, `exoPositionalList`; `TOIMExoBinding`, `TOIMExoReadable`,
4100
+ `TOIMExoDisposable`, `TOIMExoRenderedRow`, `TOIMExoList`.
4101
+
4102
+ ---
4103
+
4104
+ ## Performance
4105
+
4106
+ **No figure may be quoted for this engine, and none is quoted anywhere in this
4107
+ file.**
4108
+
4109
+ Every artifact under `packages/bench/measurements/` predates the surface
4110
+ documented in this file, so none of its
4111
+ figures prices what is described here. Such figures are **struck, not updated** — a
4112
+ stale number with a real-looking artifact path attached is worse than none.
4113
+
4114
+ `packages/bench/results/` holds a timing series
4115
+ that is still in flight: fifty-five timing artifacts beside one memory artifact,
4116
+ of which eleven scenarios — `readByKey`, `scrollWindow`, `filterTwoAxes`,
4117
+ `consumerFloor`, `patchColumn`, `patchOutsideWindow`, `churnChain`,
4118
+ `churnShared`, `churnSharedDeep`, `churnWide`, `mountFloor` — carry all three
4119
+ points. Two things bound
4120
+ what it may be quoted for, and both are recorded in the artifacts themselves:
4121
+ the series is **incomplete**, and every run in it was taken with the load gate
4122
+ raised — to **3.5** foreign runnable processes on the 22 `small`/`wide` pieces,
4123
+ and to **25** on the 34 `default`-point ones — against the harness's own
4124
+ default of **0.5** (`packages/bench/src/harness/load.ts`,
4125
+ `DEFAULT_MAX_FOREIGN_LOAD`). Read `packages/bench/measurements/README.md` for what
4126
+ a raised gate does to a comparison before quoting anything out of that directory.
4127
+
4128
+ Everything outside that series has **no number at all**. No strategy of any shipped
4129
+ fork has a measured price, so no consumer can pick with the numbers in front of
4130
+ them.
4131
+
4132
+ The one quantity that needs no measurement, because it is arithmetic rather than
4133
+ a reading: emptying a bucket one slot at a time on an array-driven bucket costs
4134
+ n(n-1)/2 element moves — **499 500** for a thousand — which is why a bucket content service's `writeBucketMemberSlots`
4135
+ and `clearBucketMemberSlots` go through one `drainSlots`. Counted, not timed, in
4136
+ `packages/core/src/engine/bucket/array/tests/everyone-out-at-once-and-not-one-at-a-time.test.ts`, which
4137
+ also pins `drainSlots` at zero element writes.
4138
+
4139
+ Fast properties are checked, not timed. Every slot kind is one object literal with
4140
+ every field present at birth; per-index state lives in the `indexKeys` array;
4141
+ nothing adds a field to a slot later or deletes one; neither entity writer
4142
+ `delete`s a column — it writes `undefined`, in place or into a spread copy. So every slot of a kind keeps one V8
4143
+ hidden class through holds, effects, moves between buckets, patches and release
4144
+ deadlines, and an entity keeps its own when a column leaves or arrives —
4145
+ `packages/core/tests/what-v8-keeps-on-fast-properties.test.ts` asks V8 directly
4146
+ through its own natives for fast properties and a shared map, and
4147
+ `packages/core/tests/how-many-shapes-reach-the-write-door.test.ts` pins three slot
4148
+ shapes at the write door, one below V8's polymorphic ceiling, and four at `holdSlot`,
4149
+ at it, a computed column slot among them as a computed slot. What that saves per read is not measured. An
4150
+ entity handed in with a few hundred fields is in dictionary mode before it
4151
+ arrives.
4152
+
4153
+ The goal the measurement work serves: prove that **for every typical task there
4154
+ is a combination of shipped strategies whose cost is at or below the hand-written
4155
+ floor**, on the same tasks every framework in `packages/bench` is measured on. A
4156
+ ratio above 1.00 is a loss only when no strategy combination reaches the floor.