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