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