@oimdb/core 4.0.1 → 4.2.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 CHANGED
@@ -6,35 +6,40 @@
6
6
  > written against the current source.
7
7
  >
8
8
  > **Services hold no data; state lives on slots.** An `OIMDB` owns the clock, the collector slot and
9
- > its `defaultChannelSlot`. A collection service (`OIMSlotCollectionService`) or a single slot service
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
10
12
  > (`OIMSingleSlotService`) is built once per collection type, and `service.createStoreSlot(db)` makes an
11
13
  > instance, a `TOIMStoreSlot`, which is NOT a slot (no owner, holds or effects) and carries its
12
14
  > `channelSlot`.
13
15
  > READS and minting are on the collection service and take the store slot first
14
16
  > (`cardService.getSlotByKey(cards, key)`, `holdSlotByKey`, `findSlotByKey`, `getSlots`, `getSlotCount`, the
15
- > column doors). WRITES are on `service.slotContentService` and take a slot
16
- > (`cardService.slotContentService.updateEntity(slot, patch)`) or, for a batch, the store slot
17
- > (`cardService.slotContentService.upsertEntities(cards, entities)`). There are NO by-key write doors:
18
- > get or find the slot, then write through it.
19
- > A single — one slot under no key — is an `OIMSingleSlotService`: `getSingleSlot(storeSlot)`,
20
- > `holdSingleSlot(storeSlot)`, `findSingleSlot(storeSlot)`.
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>`.
21
27
  >
22
- > For entities the base layer is `createOIMSlotCollectionService({ slotContentService })` /
23
- > `createOIMSingleSlotService({ slotContentService })` over `createOIMSlotContentEntityService(db, …)`;
24
- > `createOIMEntityService` and `createOIMSingleEntityService` are convenience wrappers over it. A bucket
25
- > collection is `createOIMBucketService(db, { slotContentService })` (single:
26
- > `createOIMSingleBucketService`), where the content service is `new OIMSlotContentBucketServiceSetDriven()`,
27
- > `new OIMSlotContentBucketServiceArrayDriven()` (the default) or the sorted one; an index is
28
- > `createOIMIndexService(db, { entityService, keyOf | columns, slotContentService? })`, and
29
- > `createOIMIndexServiceByCompositeKey` is a convenience wrapper over it.
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.
30
35
  > Position reads and writes (`getBucketMemberSlotAt`, `getBucketMemberSlotPosition`, `insertBucketMemberSlotAt`,
31
36
  > `removeBucketMemberSlotAt`, `removeBucketMemberSlotRange`, `moveBucketMemberSlotToPosition`,
32
- > `moveBucketMemberSlotRangeToPosition`) are on `OIMSlotContentBucketServiceArrayDriven`, the default bucket
37
+ > `moveBucketMemberSlotRangeToPosition`) are on `OIMSlotContentPositionalArrayBucketService`, the default bucket
33
38
  > content service; the set-driven one has none. A bucket sorts only on the sorted-at-write content service
34
- > (`createOIMIndexServiceSortedArrayDriven`); on every other bucket its order is the caller's. A sorted list
35
- > over any index is `createOIMComputedServiceSorted`. The render script is `OIMBucketCommands`.
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`.
36
41
  > The channel has no class: its operations are on `OIMChannelService.INSTANCE` and take a channel slot.
37
- > Effects are `OIMEffectService.INSTANCE.createEffect(db, sourceSlots, sourceComputeds, body)`.
42
+ > Effects are `OIMEffectService.INSTANCE.createEffect(db, body, sourceSlots?, sourceComputeds?, sourceTrackingComputeds?)`.
38
43
  > Every stateless singleton is a static `INSTANCE` on its class. Module-level defaults are on `OIMDefaults`.
39
44
  >
40
45
  > Check every symbol you write against [the full export list](#full-export-list).
@@ -45,7 +50,7 @@
45
50
 
46
51
  ## Contents
47
52
 
48
- - [The three layers](#the-three-layers)
53
+ - [The domains](#the-domains)
49
54
  - [The doctrine](#the-doctrine) — the four doors, the injected seams, the idle windows, the collectors, forks
50
55
  - [The runtime: the channel slot](#the-runtime-the-channel-slot)
51
56
  - [`OIMDB` and the three doors](#oimdb-and-the-three-doors)
@@ -76,47 +81,16 @@ block and the one under [One derivation per key](#one-derivation-per-key). Block
76
81
  marked `ts signature` are listings of an API and are not compiled.
77
82
 
78
83
  ```ts setup
79
- import {
80
- OIMChannelService,
81
- OIMContentChangeListenerService,
82
- OIMDB,
83
- OIMDefaults,
84
- OIMEffectService,
85
- OIMSlotContentBucketServiceArrayDriven,
86
- OIMSlotContentBucketServiceSetDriven,
87
- OIMSlotStoreServiceMapDriven,
88
- OIMSlotStoreServiceSingleSlotDriven,
89
- OIMSlotStoreServiceTreeDriven,
90
- OIMUnusedSlotCollectorService,
91
- createOIMBucketService,
92
- createOIMComputedServiceBucketMemo,
93
- createOIMComputedService,
94
- createOIMComputedServiceSorted,
95
- createOIMEntityService,
96
- createOIMEntityWriterInPlace,
97
- createOIMEntityWriterReplacing,
98
- createOIMIndexService,
99
- createOIMIndexServiceSortedArrayDriven,
100
- createOIMSingleBucketService,
101
- createOIMSingleEntityService,
102
- createOIMSlotCollectionService,
103
- createOIMSlotContentEntityService,
104
- createOIMUnusedSlotCollectorSlot,
105
- oimIsSlotEmpty,
106
- oimIsSlotReleased,
107
- oimPatchEntityColumn,
108
- oimPatchEntityColumns,
109
- oimPutEntityColumns,
110
- oimSameValueZeroGuard,
111
- } from '@oimdb/core';
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';
112
85
  import type {
113
86
  IOIMEntityWriter,
114
- TOIMSlotEditable,
87
+ TOIMEditable,
115
88
  TOIMBucketSlotsArrayStore,
116
89
  TOIMBucketSlot,
117
90
  TOIMBucketSlotsSetStore,
118
91
  TOIMComputedSlot,
119
92
  TOIMDisposer,
93
+ TOIMEntityContentSlot,
120
94
  TOIMEntitySlot,
121
95
  TOIMOrderedListCommand,
122
96
  TOIMSingleSlotStore,
@@ -137,17 +111,15 @@ declare const payload: readonly Card[];
137
111
  const db = new OIMDB();
138
112
  const channelSlot = db.defaultChannelSlot;
139
113
  const effectService = OIMEffectService.INSTANCE;
140
- const cardService = createOIMEntityService<Card>(db, { keyOf: (card) => card.id });
114
+ const cardService = createOIMSlotCollectionService({ slotContentService: createOIMSlotContentEntityService<Card>() });
141
115
  const cards = cardService.createStoreSlot(db);
142
- const slot = cardService.slotContentService.upsertEntity(cards, { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' });
116
+ const slot = cardService.writeSlotContentByKey(cards, 'c1', { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' });
143
117
 
144
- const orderedService = createOIMBucketService<TOIMEntitySlot<Card>>(db, {
145
- slotContentService: new OIMSlotContentBucketServiceArrayDriven<TOIMEntitySlot<Card>>(),
146
- });
118
+ const orderedService = createOIMSlotCollectionService({ slotContentService: new OIMSlotContentPositionalArrayBucketService<TOIMEntitySlot<Card>>() });
147
119
  const ordered = orderedService.createStoreSlot(db);
148
120
  const orderedBucketSlot = orderedService.holdSlotByKey(ordered, 'top');
149
121
 
150
- const pageService = createOIMBucketService<TOIMEntitySlot<Card>>(db);
122
+ const pageService = createOIMSlotCollectionService({ slotContentService: new OIMSlotContentPositionalArrayBucketService<TOIMEntitySlot<Card>>({ bucketCommands: new OIMBucketCommands({ maxCommandsPerSlot: undefined }) }) });
151
123
  const page = pageService.createStoreSlot(db);
152
124
  const todo = pageService.holdSlotByKey(page, 'todo');
153
125
  const done = pageService.holdSlotByKey(page, 'done');
@@ -155,30 +127,40 @@ const done = pageService.holdSlotByKey(page, 'done');
155
127
 
156
128
  ---
157
129
 
158
- ## The three layers
130
+ ## The domains
159
131
 
160
- | Layer | Contents |
161
- |---|---|
162
- | `src/engine/<domain>/` | the channel (`OIMChannelService`, `TOIMChannelSlot`), the flush scheduler (`OIMFlushSchedulerService`), the slot owner (`OIMSlotOwner`, and `OIMSlotOwnerService`, a type-only export), `OIMContentChangeListenerService`, the entity content service `OIMSlotContentEntityService`, `OIMComputedValueService`, `OIMEffectService`, the column owner (abstract `OIMColumnOwner` with the strategies `OIMColumnOwnerPushDriven` and `OIMColumnOwnerPullDriven`), the key stores and key tree walks, the index keys services, `TOIMStoreSlot`, `TOIMComputedSlot`, `TOIMEffect`, the collector services and slots, and the release policies |
163
- | `src/facade/<domain>/` | `facade/slot/`: `OIMSlotCollectionService`, `OIMSingleSlotService`; `facade/bucket/`: `OIMSlotContentBucketService` (abstract), `OIMSlotContentBucketServiceSetDriven`, `OIMSlotContentBucketServiceArrayBased` (abstract), `OIMSlotContentBucketServiceArrayDriven`, `OIMSlotContentBucketServiceSortedArrayDriven`, `OIMBucketCommands`; `facade/index/`: `OIMIndexService`, `OIMIndexServiceSortedArrayDriven`; `facade/computed/`: `OIMComputedService`, `OIMComputedServiceSorted`, `OIMComputedServiceBucketMemo` |
164
- | `src/factories/` | `OIMDB`, `OIMDefaults`, `OIMDefaultValue`; the base factories `createOIMSlotCollectionService`, `createOIMSingleSlotService`, `createOIMSlotContentEntityService`; the convenience wrappers `createOIMEntityService`, `createOIMSingleEntityService`, `createOIMBucketService`, `createOIMSingleBucketService`, `createOIMIndexServiceByCompositeKey`; `createOIMIndexService`, `createOIMIndexServiceSortedArrayDriven`, `createOIMComputedService`, `createOIMComputedServiceSorted`, `createOIMComputedServiceBucketMemo`, `createOIMEntityWriterInPlace`, `createOIMEntityWriterReplacing` |
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`.
165
134
 
166
- **No class sits directly in `engine/` or `facade/`.** The directory is the
167
- domain: `engine/bucket/`, `engine/channel/`, `engine/collector/`, `engine/column/`, `engine/computed/`,
168
- `engine/entity/`, `engine/error/`, `engine/index/`, `engine/key/`, `engine/schedule/`, `engine/slot/`,
169
- `engine/store/`, each with its own `constants/`, `interfaces/`, `types/` and `utils/` beside its classes; and
170
- `facade/bucket/`, `facade/computed/`, `facade/index/`, `facade/slot/`.
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 |
171
151
 
172
152
  **A service holds no data; state lives on slots.** A collection service is built once per collection type
173
153
  and holds only configuration and other services: its slot owner, its store service and its content
174
154
  service (`service.slotContentService`). `service.createStoreSlot(db)` makes an instance, a `TOIMStoreSlot`,
175
155
  which is NOT a slot (no owner, holds or effects): its `store` is what the store service built, its
176
- `channelSlot` is the db's channel, and it carries the per-store state (content-change listeners, index key
177
- positions, bucket write queues, column readers). Every slot filed in it points back to it through
178
- `slot.storeSlot`, which is why a slot door needs no store slot. Reads are called on the service
179
- (`cardService.getSlotByKey(cards, pk)`, `cardService.getSlots(cards)`); writes on its content service
180
- (`cardService.slotContentService.updateEntity(slot, patch)`). A store slot's `store` is the store service's
181
- to read, never the caller's.
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.
182
164
 
183
165
  ---
184
166
 
@@ -192,20 +174,20 @@ effect, a count, or a tracked mint — so nothing can unfile the identity under
192
174
  the caller who just got it.
193
175
 
194
176
  ```ts
195
- cardService.slotContentService.upsertEntity(cards, { id: 'c2', title: 'Two', rank: 2, deck: 'spades' }); // write
196
- effectService.createEffect(db, [slot], [], () => { paint(slot.entity); }); // subscribe
177
+ cardService.writeSlotContentByKey(cards, 'c2', { id: 'c2', title: 'Two', rank: 2, deck: 'spades' }); // write
178
+ effectService.createEffect(db, () => { paint(slot.entity); }, [slot]); // subscribe
197
179
  cardService.holdSlotByKey(cards, 'c1'); // hold, by key -> the slot, holdCount up by one
198
- const rankColumn = cardService.holdColumn(slot, 'rank'); // hold, on a column
199
- cardService.unholdColumn(rankColumn); // give the column back
180
+ const rankColumn = cardService.columnSlotService.holdColumn(slot, 'rank'); // hold, on a column
181
+ cardService.columnSlotService.unholdColumn(rankColumn); // give the column back
200
182
  cardService.getSlotByKey(cards, 'c1'); // read, by key -> the slot, tracked by the collector
201
183
  ```
202
184
 
203
185
  **The non-minting lookup leaves no reference.** `findSlotByKey(storeSlot, key)` (and
204
186
  `findSingleSlot(storeSlot)` on a single) answers `undefined` on a miss and mints nothing, and the
205
- slot it does hand back is protected by nothing the caller took. `slotContentService.findEntityByKey`
206
- runs the same lookup. There are no by-key write doors: to change or remove an entity by key, find
207
- the slot first and write through it. Do not write a helper over any of the four doors — it would be a
208
- second spelling of a mint, not a fifth door.
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.
209
191
 
210
192
  **Subscribing leaves BOTH kinds of reference.** `OIMEffectService.INSTANCE.createEffect(db, …)` files
211
193
  the effect in `slot.effects` *and* holds each root it subscribed to
@@ -237,13 +219,13 @@ owned by the object asked, and out of grace. Terms, and who gives each back:
237
219
 
238
220
  | Term | Given back by |
239
221
  |---|---|
240
- | `holdCount` | `slot.owner.unholdSlot(slot)`, or `service.unholdColumn(columnSlot)` for a column; the unused-slot collector's flush; a slot leaving a bucket; `effectService.disposeEffect(effect)`, for the hold the effect took |
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 |
241
223
  | `effects` | `effectService.disposeEffect(effect)` |
242
224
  | a held column | its last column effect being disposed, through the service's column owner (`unsubscribeFromSlot`) |
243
- | the value | `entityService.slotContentService.deleteEntity(slot)`, or `bucketService.slotContentService.clearBucketMemberSlots(bucketSlot)` |
244
- | a composite-key edge | `OIMKeyTreeWalkComposite.deleteKey` unlinking that composite key part |
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 |
245
227
 
246
- `deleteEntity(slot)` takes the **value**. Subscribers stay, holds
228
+ `clearSlotContent(slot)` takes the **value**. Subscribers stay, holds
247
229
  stay, and the identity stays with them. Slots you wrote into a bucket are held by
248
230
  that bucket until you empty it.
249
231
 
@@ -255,27 +237,36 @@ built from three stateless parts, wired once at construction, plus the injected
255
237
  | Part | Where | What it does |
256
238
  |---|---|---|
257
239
  | the slot owner, `OIMSlotOwnerService` (type-only export) | `slot.owner`, typed `IOIMSlotOwner` | holds, subscriptions, release, emptiness, `assertWritable`, the column owner; one per collection type |
258
- | the store service, `IOIMSlotStoreService` | given to the factory (`entitySlotStoreService`, `bucketSlotStoreService`, …) or the Map default | files and finds the slots of each store slot's `store` |
259
- | the content service | `service.slotContentService` | the writes and reads of one kind of content (`OIMSlotContentEntityService`, `OIMSlotContentBucketService*`) and the answers the owner asks of that kind |
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 |
260
242
 
261
- The owner asks the content kind through one strategy object handed to it at construction, never
243
+ The factory reads what it needs off the content service it is handed, through four small interfaces, never
262
244
  through a subclass of the service:
263
245
 
264
246
  ```ts signature
265
- interface IOIMSlotContentStrategy<TSlot, TKey, TSlotOwnerService> {
266
- createSlot(key: TKey, storeSlot: TOIMStoreSlot | undefined, slotOwnerService: TSlotOwnerService): TSlot;
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> {
267
259
  isSlotEmpty(slot: TSlot): boolean;
268
- releaseSlotContent(slot: TSlot, slotOwnerService: TSlotOwnerService): void;
260
+ releaseSlotContent(slot: TSlot): void;
269
261
  hasReference(slot: TSlot): boolean;
270
262
  }
271
- interface IOIMSlotContentFlushStrategy<TSlot, TSlotOwnerService> {
272
- onSubframeClosed(storeSlot: TOIMStoreSlot<unknown, TSlot>, slotOwnerService: TSlotOwnerService): void;
273
- }
274
263
  ```
275
264
 
276
- `hasReference` answers whether something outside `holdCount` and `effects` keeps the slot (a
277
- bucket's members); the flush strategy is what a bucket content service delivers at the close of a
278
- subframe. Every write door asserts the identity is still filed and opens the channel's subframe.
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.
279
270
 
280
271
  ### The injected seams
281
272
 
@@ -284,26 +275,26 @@ question that the engine asks but does not decide. There is no flag and no optio
284
275
  object.
285
276
 
286
277
  ```ts signature
287
- interface IOIMSlotEmptiness<TSlot> { isEmpty(slot: TSlot): boolean; }
278
+ interface IOIMSlotEmptinessPolicy<TSlot> { isEmpty(slot: TSlot): boolean; }
288
279
 
289
- interface IOIMSlotStamp<TSlot> { stamp(slotEditable: TOIMSlotEditable<TSlot>): void; }
280
+ interface IOIMSlotStamp<TSlot> { stamp(editableSlot: TOIMEditable<TSlot>): void; }
290
281
  ```
291
282
 
292
283
  **What a kind of content answers is not a seam.** Mint, write, clear and release
293
- belong to the content service, which the factory wires into the owner as its content strategy.
284
+ belong to the content service, which the collection service hands its owner as `slotContentService`.
294
285
  "Does this still hold something" no part declares — the owner asks `oimIsSlotEmpty(slot)`,
295
286
  `hasReference` and the release policy's `isSlotReleasable` separately.
296
287
 
297
- **`IOIMSlotEmptiness`** decides what counts as empty, and the answer is written once
288
+ **`IOIMSlotEmptinessPolicy`** decides what counts as empty, and the answer is written once
298
289
  per write into the EMPTY flag of `slot.slotFlags` — never recomputed at a read. The entity content
299
- service defaults to "the entity is `undefined`" (the `slotEmptiness` option of
290
+ service defaults to "the entity is `undefined`" (the `slotEmptinessPolicy` option of
300
291
  `createOIMSlotContentEntityService` replaces it); a bucket content service answers "no members"; a
301
292
  computed answers `false`, because a derivation always has a value to produce.
302
293
 
303
- **Emptiness decides lifetime, never the write path.** An `upsertEntity` onto an entity slot that
304
- still carries an entity merges into it even when emptiness calls the slot empty (a
305
- soft delete, a seed planted by `IOIMSlotStamp`): a column the new entity drops is
306
- woken, an unchanged one is not, and an upsert that repeats the entity exactly wakes
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
307
298
  nothing. Only an entity slot with no entity at all is filled whole.
308
299
 
309
300
  **`IOIMSlotStamp`** writes onto the slot at every write, just before emptiness is
@@ -324,53 +315,67 @@ interface IOIMSlotReleasePolicy {
324
315
  cancelSlotRelease(slot: TOIMSlot): void;
325
316
  }
326
317
 
327
- new OIMSlotReleasePolicyAfterWrite({ keepAfterWriteMs: number })
328
- new OIMSlotReleasePolicyAfterIdle({ keepAfterIdleMs: number })
329
- new OIMSlotReleasePolicyAfterUnhold({ keepAfterUnholdMs: number })
330
- OIMSlotReleasePolicyAtUnhold.INSTANCE
331
- OIMSlotReleasePolicyAtEmpty.INSTANCE
332
- OIMSlotReleasePolicyNever.INSTANCE
333
- new OIMSlotReleasePolicyEarliest(slotReleasePolicies: readonly IOIMSlotReleasePolicy[])
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[])
334
327
  ```
335
328
 
336
329
  `releasesWrittenSlots` is whether the policy can ever release a slot that holds
337
330
  data; a service reads it once at construction and skips the after-write
338
- release check when it is false (`OIMSlotReleasePolicyAtEmpty`, `OIMSlotReleasePolicyNever`: false;
339
- `OIMSlotReleasePolicyAtUnhold` and the timed policies: true; `OIMSlotReleasePolicyEarliest`: true
331
+ release check when it is false (`OIMSlotAtEmptyReleasePolicy`, `OIMSlotNeverReleasePolicy`: false;
332
+ `OIMSlotAtUnholdReleasePolicy` and the timed policies: true; `OIMSlotEarliestReleasePolicy`: true
340
333
  when any policy it combines is).
341
334
 
342
335
  `schedulesReleaseOnWrite` is whether a write can make the policy schedule a release; a service
343
336
  reads it once at construction and, when it is false and no `slotStamp` is set, skips `onSlotWritten`
344
- and the first-fill `onSlotIdle` on every write (`OIMSlotReleasePolicyAfterWrite`, `OIMSlotReleasePolicyAfterIdle`:
345
- true; `OIMSlotReleasePolicyAtEmpty`, `OIMSlotReleasePolicyNever`, `OIMSlotReleasePolicyAtUnhold`,
346
- `OIMSlotReleasePolicyAfterUnhold`: false; `OIMSlotReleasePolicyEarliest`: true when any policy it combines is).
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).
347
340
  `onSlotIdle` is still called when the last subscriber leaves.
348
341
 
349
342
  A policy decides whether and when a slot goes; it does no work itself. A
350
- factory, and `OIMDB` (whose `slotReleasePolicy` option every factory reads as its default), takes ONE
343
+ factory, and an `OIMDefaults` (whose `slotReleasePolicy` a factory given it as `defaults` reads as its default), takes ONE
351
344
  through the option `slotReleasePolicy`. With none, the slot owner is built with
352
- `OIMSlotReleasePolicyAtEmpty`, so a slot with a value stays for good and an empty
345
+ `OIMSlotAtEmptyReleasePolicy`, so a slot with a value stays for good and an empty
353
346
  one with nothing holding it goes; there is no "no policy" branch on any path.
354
- Computed services default to `OIMSlotReleasePolicyAtUnhold`.
347
+ Computed services default to `OIMSlotAtUnholdReleasePolicy`.
355
348
 
356
349
  | Policy | A slot goes |
357
350
  |---|---|
358
- | `OIMSlotReleasePolicyAfterWrite` | once its last write is older than `keepAfterWriteMs` |
359
- | `OIMSlotReleasePolicyAfterIdle` | once nothing has watched it for `keepAfterIdleMs` |
360
- | `OIMSlotReleasePolicyAfterUnhold` | once `keepAfterUnholdMs` has passed since its LAST unhold; a hold taken again inside the window keeps it |
361
- | `OIMSlotReleasePolicyAtUnhold` | at the last unhold, even with a value |
362
- | `OIMSlotReleasePolicyAtEmpty` | once it has no value and nothing holds it; a computed, never empty, stays while its owner lives |
363
- | `OIMSlotReleasePolicyNever` | never, empty or not; a deleted key's slot stays filed |
364
- | `OIMSlotReleasePolicyEarliest` | when the earliest deadline of its members falls due — a `min` over deadlines |
365
-
366
- The collectors' state lives on the db's collector slot, one per `OIMDB`:
367
- `db.collectorSlot.afterWriteReleaseSlotCollectorSlot`, `afterIdleReleaseSlotCollectorSlot` and
368
- `afterUnholdReleaseSlotCollectorSlot`, swept by the stateless
369
- `OIMReleaseSlotCollectorServiceAfterWrite.INSTANCE` / `OIMReleaseSlotCollectorServiceAfterIdle.INSTANCE` /
370
- `OIMReleaseSlotCollectorServiceAfterUnhold.INSTANCE`. The
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
371
376
  clock and the timer are the db's: pass `new OIMDB({ getNowMs, releaseTimer })` to control time (tests),
372
- and sweep with `OIMReleaseSlotCollectorServiceAfterIdle.INSTANCE.flush(db.collectorSlot.afterIdleReleaseSlotCollectorSlot)`.
373
- `OIMSlotReleasePolicyAtUnhold` is the zero window and has no collector.
377
+ and sweep with `oimMintAfterIdleSlotReleaseWheel(db.collectorSlot).flush()`.
378
+ `OIMSlotAtUnholdReleasePolicy` is the zero window and has no collector.
374
379
 
375
380
  A zero window never touches a release queue or a timer: when a slot goes idle with
376
381
  `onSlotIdle` answering `true`, it is released at once if nothing holds it, and
@@ -378,17 +383,21 @@ a freshly minted slot that is already due goes to the unused-slot collector and
378
383
  leaves at the end of the frame. A window above zero is filed in that policy's own
379
384
  queue.
380
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
+
381
395
  ```ts
382
- import {
383
- OIMSlotReleasePolicyAfterIdle,
384
- OIMSlotReleasePolicyAfterWrite,
385
- OIMSlotReleasePolicyEarliest,
386
- } from '@oimdb/core';
387
396
 
388
- const releasingDb = new OIMDB({
389
- slotReleasePolicy: new OIMSlotReleasePolicyEarliest([
390
- new OIMSlotReleasePolicyAfterWrite({ keepAfterWriteMs: 60_000 }),
391
- new OIMSlotReleasePolicyAfterIdle({ keepAfterIdleMs: 30_000 }),
397
+ const releasingDefaults = new OIMDefaults({
398
+ slotReleasePolicy: new OIMSlotEarliestReleasePolicy([
399
+ new OIMSlotAfterWriteReleasePolicy({ keepAfterWriteMs: 60_000 }),
400
+ new OIMSlotAfterIdleReleasePolicy({ keepAfterIdleMs: 30_000 }),
392
401
  ]),
393
402
  });
394
403
  ```
@@ -400,9 +409,9 @@ no other:
400
409
 
401
410
  | Marker | Moved by | Not moved by |
402
411
  |---|---|---|
403
- | `releaseDueAfterWriteMs` | every write, under `OIMSlotReleasePolicyAfterWrite` | a subscriber arriving or leaving |
404
- | `releaseDueAfterIdleMs` | the first content of a slot with no subscriber, and the departure of the last subscriber, under `OIMSlotReleasePolicyAfterIdle` | a later write, and the give-back of a hold |
405
- | `releaseDueAfterUnholdMs` | every last unhold, under `OIMSlotReleasePolicyAfterUnhold` | a write |
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 |
406
415
 
407
416
  `-1` means unset. A slot goes on the SOONEST of those that are set — each
408
417
  marker is an eviction deadline, so whichever says "out" first decides — and a
@@ -414,7 +423,7 @@ is allowed to move it: a write arriving after the subscriber left would push the
414
423
  deadline out and destroy the idle policy's verdict, and the last subscriber
415
424
  leaving after a write would destroy the write policy's. Both directions are
416
425
  pinned by
417
- `engine/collector/tests/which-release-marker-each-collector-may-move.test.ts`;
426
+ `collector/tests/which-release-marker-each-collector-may-move.test.ts`;
418
427
  collapse the two markers into one and six of its nine cases go red.
419
428
 
420
429
  **The release policy's `isSlotReleasable(slot)`** is what the release guard asks
@@ -423,8 +432,8 @@ now; `false` keeps it, and a policy with a finite deadline has itself filed the
423
432
  slot into its release queue (a marker of `-1` is kept with nothing scheduled).
424
433
  An **empty** slot is released without asking, and a service given no release
425
434
  policy keeps a non-empty slot. `OIMComputedService` defaults its policy to
426
- `OIMSlotReleasePolicyAtUnhold`, which always answers `true`, so a derivation
427
- nothing refers to goes at once. `OIMSlotReleasePolicyEarliest` asks every member
435
+ `OIMSlotAtUnholdReleasePolicy`, which always answers `true`, so a derivation
436
+ nothing refers to goes at once. `OIMSlotEarliestReleasePolicy` asks every member
428
437
  and answers `true` if any does.
429
438
 
430
439
  **AN IDLE WINDOW OPENS IN EXACTLY TWO PLACES, AND NOWHERE ELSE:**
@@ -435,7 +444,7 @@ and answers `true` if any does.
435
444
  2. **when the LAST SUBSCRIBER leaves** — `OIMSlotOwner.unsubscribeFromSlot` empties
436
445
  `slot.effects` and runs the idle step, which opens one.
437
446
 
438
- Both ask the policy's `onSlotIdle(slot)`; under `OIMSlotReleasePolicyAfterIdle`
447
+ Both ask the policy's `onSlotIdle(slot)`; under `OIMSlotAfterIdleReleasePolicy`
439
448
  it stamps `releaseDueAfterIdleMs` and files the slot into the policy's queue. An
440
449
  **empty** slot gets no window, and the release guard takes it without asking a
441
450
  clock at all.
@@ -449,60 +458,65 @@ therefore taken at the give-back and not one window later.
449
458
  nothing — and the queue has taken the slot off its slice, so nothing re-files it.
450
459
  The give-back is where the guard is asked again.
451
460
 
452
- ### The two release collectors
461
+ ### The release wheels
453
462
 
454
- Each of `OIMSlotReleasePolicyAfterWrite`, `OIMSlotReleasePolicyAfterIdle` and
455
- `OIMSlotReleasePolicyAfterUnhold` is swept by its own stateless collector service, and the difference
456
- between them is WHICH MARKER it reads — there is no flag, and no branch on the sweep path. Their state is
457
- one release collector slot each, on the db's collector slot.
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.
458
467
 
459
468
  ```ts signature
460
- abstract class OIMReleaseSlotCollectorService {
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 }
461
478
  readonly getReleaseDueMs: (slot: TOIMSlot) => number;
462
- getReleaseSlotCollectorSlot(collectorSlot: TOIMCollectorSlot): TOIMReleaseSlotCollectorSlot;
463
- getReleaseSlotCollectorSlotOf(slot: TOIMSlot): TOIMReleaseSlotCollectorSlot;
464
- markSlotReleaseDue(releaseSlotCollectorSlot, slot, keepMs): number; // stamps now + keepMs on this marker, returns now
465
- isSlotReleasable(releaseSlotCollectorSlot, slot): boolean; // due now; if not yet, files it on the queue
466
- cancelSlotRelease(releaseSlotCollectorSlot, slot): void; // no-op for a slot whose marker is -1
467
- flush(releaseSlotCollectorSlot): void;
468
- cancelAllSlotReleases(releaseSlotCollectorSlot): void;
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;
469
485
  }
470
- OIMReleaseSlotCollectorServiceAfterIdle.INSTANCE
471
- OIMReleaseSlotCollectorServiceAfterWrite.INSTANCE
472
- OIMReleaseSlotCollectorServiceAfterUnhold.INSTANCE
473
486
 
474
487
  type TOIMCollectorSlot = {
475
488
  readonly unusedSlotCollectorSlot: TOIMUnusedSlotCollectorSlot;
476
- readonly afterIdleReleaseSlotCollectorSlot: TOIMReleaseSlotCollectorSlot;
477
- readonly afterWriteReleaseSlotCollectorSlot: TOIMReleaseSlotCollectorSlot;
478
- readonly afterUnholdReleaseSlotCollectorSlot: TOIMReleaseSlotCollectorSlot;
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;
479
493
  };
494
+ oimMintAfterWriteSlotReleaseWheel(collectorSlot): OIMSlotReleaseWheel // and …AfterIdle…, …AfterUnhold…
480
495
  ```
481
496
 
482
497
  The clock (`getNowMs`, default `Date.now`), the timer (`releaseTimer`, default an unref'd `setTimeout`)
483
- and the `errorPolicy` (default `OIMErrorPolicyLogAndContinue.INSTANCE`) are `OIMDB` options, shared by the
498
+ and the `errorPolicy` (default `OIMLogAndContinueErrorPolicy.INSTANCE`) are `OIMDB` options, shared by the
484
499
  channel and every release collector of that db. A release that throws inside a hand-called `flush` throws to
485
500
  that caller; one that throws on the collector's own timer tick has no caller and goes to the db's
486
501
  `errorPolicy`. So one error policy on the db routes every error through one place:
487
502
 
488
503
  ```ts
489
- import { OIMErrorPolicyThrowAtOnce, OIMSlotReleasePolicyAfterIdle } from '@oimdb/core';
490
504
 
491
- const oneSinkDb = new OIMDB({
492
- errorPolicy: new OIMErrorPolicyThrowAtOnce(),
493
- slotReleasePolicy: new OIMSlotReleasePolicyAfterIdle({ keepAfterIdleMs: 30_000 }),
505
+ const oneSinkDb = new OIMDB({ errorPolicy: new OIMThrowAtOnceErrorPolicy() });
506
+ const idleReleasingDefaults = new OIMDefaults({
507
+ slotReleasePolicy: new OIMSlotAfterIdleReleasePolicy({ keepAfterIdleMs: 30_000 }),
494
508
  });
495
509
  ```
496
510
 
497
- | Collector service | Reads | What renews it | What does not |
511
+ | Wheel | Reads | What renews it | What does not |
498
512
  |---|---|---|---|
499
- | `OIMReleaseSlotCollectorServiceAfterIdle` | `releaseDueAfterIdleMs` | the first content of a slot, and the give-back of the last hold | a later write |
500
- | `OIMReleaseSlotCollectorServiceAfterWrite` | `releaseDueAfterWriteMs` | every write, under its policy | a subscriber arriving or leaving |
501
- | `OIMReleaseSlotCollectorServiceAfterUnhold` | `releaseDueAfterUnholdMs` | every last unhold | a hold taken again inside the window |
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 |
502
516
 
503
- **A CONSUMER MAY RUN BOTH, AND THEY SHARE NOTHING.** Each has its own release collector slot with its own
504
- queue and its own scheduled run. Whichever deadline falls due first takes the slot, and
505
- nothing has to be reconciled: `OIMSlotReleasePolicyEarliest` answers a `min`
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`
506
520
  over its members' deadlines, so a sweep of either queue reaches the same verdict.
507
521
 
508
522
  **NEITHER MEANS UNREAD.** No read is seen and no door reports one: an identity a
@@ -517,19 +531,16 @@ The slot owner's `releaseSlotIfUnheld` checks `holdCount > 0`, then
517
531
  `oimIsSlotReleased(slot)`, then `slot.owner !== this`, then `hasReference(slot)`, and
518
532
  only then — on a slot that is not empty — asks the policy's `isSlotReleasable`; an empty slot
519
533
  asks `isEmptySlotReleasable` instead, which every policy but
520
- `OIMSlotReleasePolicyNever` answers `true`. So only `OIMSlotReleasePolicyNever`
534
+ `OIMSlotNeverReleasePolicy` answers `true`. So only `OIMSlotNeverReleasePolicy`
521
535
  keeps an identity that holds nothing.
522
536
 
523
- **A release that falls inside a flush waits for it to end.** Releasing a slot
524
- with a value clears that value, which is a write, and every service
525
- refuses writes for the whole of a flush of its channel (`channelSlot.isFlushing`; refusal is per channel). So when the
526
- last hold goes during a frame — an effect body disposing another effect, say — the
527
- slot is handed to the unused-slot collector instead, which holds it and gives it
528
- back once the flush is over; the release then runs. An empty slot needs no write
529
- and goes at once.
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.
530
541
 
531
542
  *The `owner !== this` line costs no lookup. A KEYLESS slot — a single's, filed in an
532
- `OIMSlotStoreServiceSingleSlotDriven` store — goes through the same guard under its service's policy: an
543
+ `OIMSingleSlotStoreService` store — goes through the same guard under its service's policy: an
533
544
  emptied single bucket nothing holds is released like a keyed one, and the single service's
534
545
  `getSingleSlot(storeSlot)` mints a fresh slot on the next read.*
535
546
 
@@ -543,7 +554,7 @@ one whole window later. **A give-back is NOT that moment**: `unholdSlot` stamps
543
554
  nothing, so a slot held through an expired sweep goes at the give-back.
544
555
 
545
556
  ```ts
546
- const effect = effectService.createEffect(db, [slot], [], () => { paint(slot.entity); });
557
+ const effect = effectService.createEffect(db, () => { paint(slot.entity); }, [slot]);
547
558
  // …the window passes, sweeps run, nothing happens to this slot…
548
559
  effectService.disposeEffect(effect);
549
560
  oimIsSlotReleased(slot); // false — a whole new window starts here
@@ -554,34 +565,41 @@ releases the stale index, which gives back one reference per entity slot it name
554
565
  each entity slot whose own window already ran out is taken by the ordinary guard the
555
566
  instant that reference departs.
556
567
 
557
- Pinned by `engine/collector/tests/what-a-stale-index-takes-with-it.test.ts`,
568
+ Pinned by `collector/tests/what-a-stale-index-takes-with-it.test.ts`,
558
569
  `…/what-an-identity-nobody-subscribed-to-costs-after-a-while.test.ts` and
559
570
  `…/what-a-subscribed-row-costs-the-release-clock.test.ts`.
560
571
 
561
572
  ### The release guard, and who fires it
562
573
 
563
- **THE QUEUE DECIDES AT A DEADLINE, NOT THE OWNER.**
564
- `OIMReleaseSlotQueueService.INSTANCE.releaseDueUpToSliceNumber`, per slot in a slice at or below the one due:
574
+ **THE WHEEL DECIDES AT A DEADLINE, NOT THE OWNER.**
575
+ `OIMSlotReleaseWheel.sweepUpToSliceNumber`, per slot in a slice at or below the one due:
565
576
 
566
- 1. `oimIsSlotReleased(slot)` → skip it;
567
- 2. its own marker, read through `getReleaseDueMs(slot)` — the ONE field this queue
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
568
579
  answers for;
569
- 3. `-1` → the slot is dropped from the queue: this policy does not claim it;
570
- 4. a moment still in the future → the queue **re-files the slot itself**, into
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
571
582
  `oimSliceOfMs(getReleaseDueMs)` through its own slice arithmetic;
572
583
  5. a moment already passed → `slot.owner.releaseSlotIfUnheld(slot)`, which asks the
573
- policy's `isSlotReleasable` and so declines when the other policy still keeps
574
- the slot.
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.
575
592
 
576
593
  The owner files nothing from inside a sweep. It **opens** a window on its own
577
594
  events, through the policy's `onSlotIdle` — when a slot first gets content, and
578
595
  when the last subscriber leaves — and `onSlotUnheld` on the give-back of the last hold.
579
596
 
580
- **A SLOT ALREADY ON A QUEUE STAYS WHERE IT IS.** `scheduleSlotRelease` of a slot filed
581
- at a slice no later than its new deadline moves nothing: the sweep re-reads the
582
- deadline when that slice comes due and re-files the slot then (the timing-wheel
583
- lazy re-insertion). So a write to a queued entity slot costs the queue one `Map.get`, not
584
- an unfile and a refile.
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.
585
603
 
586
604
  `releaseSlotIfUnheld(slot)` is the guard, and it releases only when all of
587
605
  `holdCount === 0`, `oimIsSlotReleased(slot)` is false, `slot.owner === this`,
@@ -591,7 +609,7 @@ happens to it. On a slot that is not empty with time still owed, `isSlotReleasab
591
609
  answers `false` and the policy files the slot into its queue at its own marker, so a
592
610
  filing can neither shorten nor extend a deadline. That is the path from `unholdSlot` and from a write, never from a
593
611
  sweep, which had already re-filed at step 4. Releasing hands the slot to the
594
- content strategy's `releaseSlotContent` (which gives back what the content held),
612
+ content service's `releaseSlotContent` (which gives back what the content held),
595
613
  marks the slot empty, calls the policy's `cancelSlotRelease(slot)`, unfiles the key
596
614
  through the store service's `remove`, and sets the RELEASED flag in `slot.slotFlags`. The `owner` is NOT cleared — the slot keeps
597
615
  pointing at the object that let it go, which is what makes `slot.owner !== this`
@@ -604,48 +622,48 @@ opposite:
604
622
  `oimIsSlotReleased(slot)` and returns; it does not throw. A sweep meeting a slot
605
623
  something else already let go must not raise.
606
624
  - **Trying to WORK with a released slot throws by name.** `assertNotReleased`
607
- guards the content services' write doors (`writeEntity`, `deleteEntity`, `updateEntity`, `markEntityChanged`,
608
- `writeBucketMemberSlots`, `clearBucketMemberSlots`, `markContentChanged`) and the reference doors (`holdSlot`, `unholdSlot`, `subscribeToSlot`,
625
+ guards the content services' write doors (`writeSlotContent`, `clearSlotContent`, `updateEntity`, `markEntityChanged`,
626
+ `addBucketMemberSlot`, `removeBucketMemberSlot`, `markContentChanged`) and the reference doors (`holdSlot`, `unholdSlot`, `subscribeToSlot`,
609
627
  `unsubscribeFromSlot`). The refusal message starts `This identity was released:`.
610
628
 
611
- ### The collectors
629
+ ### The wheels and the unused-slot collector
612
630
 
613
631
  ```ts signature
614
- OIMReleaseSlotCollectorServiceAfterWrite.INSTANCE // and …AfterIdle, …AfterUnhold
615
- OIMReleaseSlotQueueService.INSTANCE
632
+ oimMintAfterWriteSlotReleaseWheel(db.collectorSlot) // and …AfterIdle…, …AfterUnhold…
633
+ new OIMSlotRefetchWheel(options: TOIMSlotRefetchWheelOptions) // { slotRefetchListener, getNowMs?, timer?, errorPolicy? }
616
634
  OIMUnusedSlotCollectorService.INSTANCE
617
635
  createOIMUnusedSlotCollectorSlot(): TOIMUnusedSlotCollectorSlot
618
636
  ```
619
637
 
620
- All are stateless; each works on a slot handed to it. The release collector services extend
621
- `OIMFlushSchedulerService`.
622
-
623
- **A release collector service** is the clock. It sweeps the queue of the release collector slot it is
624
- given on `flush(releaseSlotCollectorSlot)` and re-schedules itself to the next deadline.
625
- `cancelAllSlotReleases(releaseSlotCollectorSlot)` cancels and empties the queue; the next scheduled release
626
- schedules it again. The clock (`getNowMs`) and the timer come from the db, so a test drives them through
627
- `new OIMDB({ getNowMs, releaseTimer })`. The default timer is a `setTimeout` that calls `unref`
628
- where the platform has it, so an idle Node process is not held open.
629
-
630
- **`OIMReleaseSlotQueueService`** files the slots of one release collector slot into **time slices**, not
631
- into one entry each; the collector service's `getReleaseDueMs` is the ONE marker it answers for, which is
632
- what makes two queues over two markers independent. `oimSliceOfMs(ms)` is `Math.floor(ms / OIM_SLICE_LENGTH_MS)` and
633
- `OIM_SLICE_LENGTH_MS` is `1024`, so every slot due inside the same ~1s slice
634
- shares one `Set` and one timer. Its doors, each taking the release collector slot first:
635
-
636
- | Door | What it does |
637
- |---|---|
638
- | `scheduleSlotRelease(releaseSlotCollectorSlot, slot, …)` | read this queue's own marker off the slot and file it into that slice (a marker already past now files at now's slice), then schedule the collector. A marker of `-1` files nothing |
639
- | `cancelSlotRelease(releaseSlotCollectorSlot, slot)` | take it off |
640
- | `isSlotReleaseScheduled(releaseSlotCollectorSlot, slot)` | is it filed |
641
- | `getNextReleaseSliceNumber(releaseSlotCollectorSlot)` | the nearest filed slice number, or `undefined` |
642
- | `releaseDueUpToSliceNumber(releaseSlotCollectorSlot, …)` | the sweep: for every slice at or below the one due, read the marker and then drop it (`-1`), re-file it itself (still in the future), or call `slot.owner.releaseSlotIfUnheld(slot)` |
643
- | `cancelAllSlotReleases(releaseSlotCollectorSlot)` | drop every filing |
644
-
645
- A slot re-filed during a sweep cannot land in a slice the same sweep is still
646
- walking: the sweep raises a floor for its duration, so a re-file — the queue's own
647
- at step 4, or one `releaseSlotIfUnheld` makes — goes to the next slice and is looked
648
- at next time, not in a loop.
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`.
649
667
 
650
668
  **The unused-slot collector** has nothing to do with time. It is the one that makes
651
669
  `getSlotByKey(storeSlot, key)` safe: `OIMUnusedSlotCollectorService.INSTANCE.holdSlotUntilFlush(unusedSlotCollectorSlot, slot)`
@@ -674,7 +692,7 @@ OIMUnusedSlotCollectorService.INSTANCE.flush(unusedSlotCollectorSlot); // now
674
692
  get that.** A computed takes a hold on each of its own direct sources at the mint
675
693
  and gives them back when it is released, so an arena holds the top and the chain holds itself.
676
694
 
677
- Pinned by `engine/collector/tests/what-a-named-arena-keeps-cached.test.ts`: the
695
+ Pinned by `collector/tests/what-a-named-arena-keeps-cached.test.ts`: the
678
696
  derivation survives a settle, is the SAME instance on the next read (built once),
679
697
  is rebuilt only after the arena is flushed, and `open`/`close` nest so an inner
680
698
  scope ending drops nothing an outer one stands on.
@@ -708,13 +726,13 @@ path. There is no `if (inPlace)` anywhere in the engine.
708
726
 
709
727
  | Fork | Strategies |
710
728
  |---|---|
711
- | how a write lands | `createOIMEntityWriterInPlace()` / `createOIMEntityWriterReplacing()` |
712
- | how a bucket holds slots | `OIMSlotContentBucketServiceSetDriven` / `OIMSlotContentBucketServiceArrayDriven` (the positions are the caller's) |
713
- | what naming a slot records | `OIMBucketArrayMembershipNonUnique` / `OIMBucketArrayMembershipUnique`, the `bucketArrayMembership` option of `OIMSlotContentBucketServiceArrayDriven` |
714
- | which writes re-sort a sorted list | `sortColumns` (only a write changing one of them on a member) / `comparator` alone (any member write), the options of `createOIMComputedServiceSorted` |
715
- | how a key is addressed | `OIMDefaults.KEY_TREE_WALK_SCALAR` / `OIMDefaults.KEY_TREE_WALK_COMPOSITE` |
716
- | when a slot may go | `OIMSlotReleasePolicyAfterIdle` / `OIMSlotReleasePolicyAfterWrite`, either or both |
717
- | who decides a bucket's slots | a bucket collection (`createOIMBucketService`, the caller) / an index (`createOIMIndexService`, filed from the entity's own key) |
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) |
718
736
 
719
737
  **One strategy per kind per app.** A V8 call site is shared by every service
720
738
  that runs the same engine code, so mixing strategies of one kind in one process
@@ -723,8 +741,8 @@ V8 still caches each one and can still inline
723
741
  (`--max-valid-polymorphic-map-count`, default 4). Five or more is megamorphic: no
724
742
  per-site cache and no inlining through it. The kinds that count: release policies
725
743
  (the `IOIMSlotReleasePolicy` strategies), set-driven vs array-driven buckets and indexes, entity
726
- writers (`createOIMEntityWriterInPlace` / `createOIMEntityWriterReplacing`) and
727
- `TOIMEntityInsertStrategy`, `TOIMSameValueGuard`, `IOIMSlotEmptiness`, and
744
+ writers (`createOIMInPlaceEntityWriter` / `createOIMReplacingEntityWriter`) and
745
+ `TOIMEntityInserter`, `TOIMSameValueGuard`, `IOIMSlotEmptinessPolicy`, and
728
746
  `TOIMChannelScheduler`. Two or three of one kind is fine; five release policies in
729
747
  one process is not. The pair
730
748
  `alt.slot-shape.three-shapes-against-one-at-the-write` measures three slot shapes
@@ -735,27 +753,23 @@ Generate code with one strategy per kind unless the task names a second.
735
753
 
736
754
  `createStoreSlot(db)` stamps the db's `defaultChannelSlot` on the store slot, and every write to its slots
737
755
  is reported to that channel. No service holds a channel; there is no `channel` option and no
738
- `unusedSlotCollector` option. The base layer and the convenience wrapper build the same thing:
756
+ `unusedSlotCollector` option:
739
757
 
740
758
  ```ts
741
- const cardContentService = createOIMSlotContentEntityService<Card>(db, { keyOf: (c) => c.id });
759
+ const cardContentService = createOIMSlotContentEntityService<Card>();
742
760
  const handBuiltCardService = createOIMSlotCollectionService({ slotContentService: cardContentService });
743
761
  handBuiltCardService.createStoreSlot(db);
744
-
745
- // THE CONVENIENCE WRAPPER — the same two calls in one:
746
- createOIMEntityService<Card>(db, { keyOf: (c) => c.id }).createStoreSlot(db);
747
762
  ```
748
763
 
749
- A single entity is `createOIMSingleEntityService<T>(db)` (or `createOIMSingleSlotService` over
750
- `createOIMSlotContentEntityService(db, { slotStoreService: OIMSlotStoreServiceSingleSlotDriven.INSTANCE })`),
764
+ A single entity is `createOIMSingleSlotService({ slotContentService: createOIMSlotContentEntityService<T>() })`,
751
765
  and its store slot takes the db's channel too and always reports.
752
766
 
753
767
  ### A bucket's slots are the caller's
754
768
 
755
769
  A bucket's slots are a fact about the **response** being composed — a search result, a
756
770
  page, a window — and **no column can express absence**. So a bucket collection
757
- is manual, and `entityService.slotContentService.deleteEntity(slot)` does not take a slot out of any
758
- bucket. Use an index (`createOIMIndexService`) when the slots are determined by the entity's key value,
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,
759
773
  and an index should maintain buckets only for keys being held.
760
774
 
761
775
  ---
@@ -772,7 +786,7 @@ new OIMDB(options?: TOIMDBOptions)
772
786
 
773
787
  type TOIMDBOptions = {
774
788
  channelScheduler?: TOIMChannelScheduler; // absent = you flush by hand
775
- errorPolicy?: IOIMErrorPolicy; // absent = OIMErrorPolicyLogAndContinue.INSTANCE
789
+ errorPolicy?: IOIMErrorPolicy; // absent = OIMLogAndContinueErrorPolicy.INSTANCE
776
790
  onBeforeFlush?: () => void; // start of the flush, before the frame; READ ONLY, a write is refused
777
791
  getNowMs?: () => number; // the release collectors' clock; absent = Date.now
778
792
  releaseTimer?: TOIMTimer; // the release collectors' timer; absent = an unref'd setTimeout
@@ -788,9 +802,9 @@ db.defaultChannelSlot // TOIMChannelSlot
788
802
  db.flush() // OIMChannelService.INSTANCE.flush(db.defaultChannelSlot)
789
803
 
790
804
  interface IOIMErrorPolicy { onErrorCaught(error: unknown): void }
791
- OIMErrorPolicyLogAndContinue.INSTANCE // console.error; the flush goes on
792
- new OIMErrorPolicyLogAndContinue({ onError?: (error: unknown) => void }) // console.error, then onError
793
- new OIMErrorPolicyThrowAtOnce() // throws the first error out of the flush
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
794
808
  ```
795
809
 
796
810
  **What the error policy covers** — every error with no synchronous caller to
@@ -801,7 +815,7 @@ hand-called collector `flush` — throws to it. `channelSlot.errorPolicy` is the
801
815
  instance the channel reports through; it is the db's `errorPolicy`, which the release collectors share,
802
816
  so one policy answers for the whole database. **The default digests and logs**: each throw goes to `console.error`
803
817
  and then to `onError` (where Sentry or the devtools registry plugs in), and the
804
- flush finishes every other effect and handler. `OIMErrorPolicyThrowAtOnce` throws
818
+ flush finishes every other effect and handler. `OIMThrowAtOnceErrorPolicy` throws
805
819
  the first error out of `flush()` at once; the effects the frame had not reached yet
806
820
  stay queued and run at the next flush. **An effect whose body or cleanup threw
807
821
  has not taken the change it woke for**: its `ranAtRevision`/`ranAtEpoch` are
@@ -852,17 +866,13 @@ Every door below is on `OIMChannelService.INSTANCE` and takes the channel slot f
852
866
  | `assertNotFlushing(channelSlot)` | `void` — throws if the channel is flushing or its frame is running, i.e. for the whole of a flush |
853
867
  | `write(channelSlot, slot)` | `void` — asks `assertNotFlushing`, then marks and queues |
854
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 |
855
- | `openSubframe(channelSlot)` | `void` — a subframe opens, and the frame cannot close until it closes — `flush` throws meanwhile |
856
- | `closeSubframe(channelSlot)` | `void` — it closes, delivering what was listed for its close and then asking for the frame it owes |
857
- | `isSubframeOpen(channelSlot)` | `boolean` — whether a subframe is open right now, so a caller can defer work to its close instead of per write |
858
- | `listChangedStoreSlot(channelSlot, storeSlot, subframeCloseListener)` | `void` — list a store slot whose changes are delivered when the OUTERMOST subframe closes, by calling `subframeCloseListener.onStoreSlotSubframeClosed(storeSlot)`; see below |
859
869
  | `dropChannelSlot(channelSlot)` | `void` — kill the channel whole; see below |
860
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 |
861
871
 
862
872
  The channel slot's own fields that a caller reads: `frame` (`TOIMChannelFrame`, `{ isRunning, openedAtRevision,
863
873
  openedAtEpoch, … }`; a frame carries no number, the clock reading it opened at is its boundary), `clock`
864
874
  (`TOIMChannelClock`, the `{revision, epoch}` pair, shared by every channel of one db; an epoch holds
865
- `OIM_REVISIONS_PER_EPOCH` (2^30) revisions, so the counter stays a V8 small integer under pointer compression),
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`),
866
876
  `collectorSlot`, `errorPolicy`, `isFlushing`. Everything else on it is engine state.
867
877
 
868
878
  ### A subscription hears only what happens after it
@@ -874,14 +884,14 @@ bucket, a computed — and a batch does not soften it: writes accumulate until
874
884
  after it.
875
885
 
876
886
  ```ts
877
- cardService.slotContentService.upsertEntity(cards, { id: 'a', title: 'A', rank: 1, deck: 'spades' });
887
+ cardService.writeSlotContentByKey(cards, 'a', { id: 'a', title: 'A', rank: 1, deck: 'spades' });
878
888
  const entitySlot = cardService.getSlotByKey(cards, 'a');
879
889
 
880
- effectService.createEffect(db, [entitySlot], [], () => { /* NEVER runs for the upsert above */ });
890
+ effectService.createEffect(db, () => { /* NEVER runs for the upsert above */ }, [entitySlot]);
881
891
  db.flush();
882
892
 
883
- // A first value is `createEffectImmediate`'s job, not `createEffect`'s:
884
- effectService.createEffectImmediate(db, [entitySlot], [], () => { paint(entitySlot.entity); }); // runs now
893
+ // A first value is `createAndRunEffect`'s job, not `createEffect`'s:
894
+ effectService.createAndRunEffect(db, () => { paint(entitySlot.entity); }, [entitySlot]); // runs now
885
895
  ```
886
896
 
887
897
  A later write in that same batch IS delivered — "after I subscribed" is decided
@@ -894,11 +904,11 @@ Told after **one** frame — the first that ends after the call — and then gon
894
904
  The place that asks decides which frame that is.
895
905
 
896
906
  ```ts
897
- effectService.createEffect(db, [slot], [], () => {
907
+ effectService.createEffect(db, () => {
898
908
  paint(slot.entity);
899
909
  // the tail of THIS frame, after every other effect of it
900
910
  OIMChannelService.INSTANCE.subscribeOnceAfterFrame(channelSlot, () => { measureAndScroll(); });
901
- });
911
+ }, [slot]);
902
912
  ```
903
913
 
904
914
  - **Registered from inside an effect body it runs in the tail of that very
@@ -920,87 +930,27 @@ effectService.createEffect(db, [slot], [], () => {
920
930
  - One list, not two: it goes into the same set at the same phase, so one-shot and
921
931
  standing handlers are called in registration order among each other.
922
932
 
923
- ### `openSubframe(channelSlot)` / `closeSubframe(channelSlot)`
924
-
925
- A **frame** is everything delivered together: the writes collected since the
926
- last delivery, handed to effects and after-each-frame handlers in one go. A
927
- **subframe** is one logical change inside it — a write that has begun and not yet
928
- ended. **The frame cannot close while a subframe is open**, however many writes
929
- ask for one: the debt is recorded and paid by the `closeSubframe` that brings the
930
- count to zero. A subframe delivers nothing of its own; it only keeps the frame
931
- open.
932
-
933
- - **`flush()` throws while a subframe is open**, with a message containing
934
- "a subframe is open": code a write runs (a `keyOf`, a content
935
- listener) called `flush`, or the caller opened a subframe and has not closed it. Nothing is delivered and the write in progress stays whole; flush
936
- after the write returns.
937
- - Counted, not a flag: a subframe is routinely inside another one, and the inner
938
- close delivers nothing.
939
- - Not `assertNotFlushing` from the other side. That refuses a **write** made
940
- while a frame closes; this refuses a **frame** asked for while a subframe is open.
941
- - A subframe is bigger than any single write, which is why the span is the
942
- service's and not the writer's: a write moves the entity's columns and *then*
943
- settles emptiness, which is what puts the entity slot into the index's
944
- bucket. Under a synchronous scheduler that gap used to flush — an auto-index
945
- settled over a bucket the entity slot had not joined yet, stamped itself past the
946
- entity slot's revision, and the entity was invisible to that index forever with nothing
947
- thrown.
948
- - `closeSubframe` is called from a `finally` by every opener, so a subframe that
949
- threw part-way still closes and still delivers what it managed to write.
950
- - **The span belongs to the DOOR, and exactly one door opens it.** The entity content service's
951
- `writeEntity`, `deleteEntity`, `deleteEntities`, `fillEntities`, `updateEntity` and `markEntityChanged` each
952
- open one — except a write that is exactly one channel write, which opens none — one write cannot be seen
953
- half-done, and there is nobody to batch for: `updateEntity`/`updateEntityColumn` and `deleteEntity` when
954
- the store slot has no content listener, no column of the entity is referenced and emptiness is not
955
- recomputed on a column write; the entity writer's `fillEntity`, `putEntity`, `patchEntity`, `deleteColumns`
956
- and `clearEntity` open none. One `upsertEntity` therefore opens one span, and one `insertEntities` opens one
957
- however many entities it carries. The one exception is the content strategy's `releaseSlotContent`, which
958
- keeps its own: `releaseSlotIfUnheld` reaches it when a release deadline has passed, and that call arrives
959
- from the release queue through no door at all — so a removal opens two and an eviction opens one.
960
- - Every write door of a content service is wrapped in the channel's `openSubframe` / `closeSubframe` on
961
- `storeSlot.channelSlot`.
962
- - **Every bucket door wakes the bucket the same way.** Each door of a bucket content service that changed
963
- the bucket — `addBucketMemberSlot(s)`, `removeBucketMemberSlot(s)`,
964
- `insertBucketMemberSlotAt`, `removeBucketMemberSlotAt` / `…Range`, both moves,
965
- `writeBucketMemberSlots`, `clearBucketMemberSlots` — lists the bucket's store slot on the channel
966
- (`listChangedStoreSlot`) and sets the bucket's EMPTY flag there and then; the outermost `closeSubframe`
967
- stamps each changed bucket once, tells its release policy `onSlotWritten` once, and only then asks
968
- whether it may go. So `hasSlotContentByKey` answers right inside an open batch, a
969
- batch that mixes doors stamps the bucket once, a move renews a write deadline,
970
- and a door that changed nothing stamps nothing and tells the policy nothing.
971
- Commands are still recorded at the door, because a position is true only when it
972
- is written. A release asked for while the bucket has a pending change waits for
973
- the close.
974
- - **A content listener that throws on one bucket does not strand the others.**
975
- The close stamps every filed bucket and settles it before its listeners run,
976
- goes on to the next bucket when one listener throws, and rethrows the first error
977
- once every bucket is through — costing nothing when nothing throws.
978
- - **A released identity is refused before the bucket changes.** Every door that
979
- takes a member — the add doors, the doors by key, `insertBucketMemberSlotAt`,
980
- a whole-list `writeBucketMemberSlots`, and the fill of a minted bucket — checks what it was
981
- handed with `oimIsSlotReleased` first and throws, so a refused door leaves the members,
982
- their holds and the EMPTY flag exactly as they were. It costs one field read per
983
- member handed in.
984
-
985
- ### `isSubframeOpen(channelSlot)` / `listChangedStoreSlot(channelSlot, storeSlot, subframeCloseListener)`
986
-
987
- **What a batch does once instead of per entity.** A subframe is the unit a
988
- service can defer work to the end of: `listChangedStoreSlot` lists a store slot together with the
989
- `IOIMSubframeCloseListener` (`onStoreSlotSubframeClosed(storeSlot)`) that delivers its changes, and the
990
- outermost `closeSubframe` calls each listed listener with its store slot. `isSubframeOpen` says whether there
991
- is a subframe to defer into at all — with none, the caller does the work immediately.
992
-
993
- - **The drain runs before the count drops**, so a listener that writes is still inside the subframe and
994
- still cannot close the frame. A store slot listed by a listener during the drain is picked up by the same
995
- drain.
996
- - **Every listed entry runs.** A listener that throws loses its own work for that batch and nothing retries
997
- it; the entries after it still run, the list is emptied, the count still drops, the frame the subframe
998
- owed is still asked for, and then the FIRST throw leaves `closeSubframe`.
999
- - **What uses it**: the bucket content services. A join or a leave records the bucket slot on its store
1000
- slot's write queues and lists that store slot; the drain then writes the channel, settles emptiness and
1001
- asks `releaseSlotIfUnheld` **once per bucket per batch** rather than once per member. Filing 10 000 entity
1002
- slots into 100 buckets pays 100 of each tail instead of 10 000. Nothing is delivered before the flush either
1003
- way, so the two are behaviourally the same.
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.
1004
954
 
1005
955
  ### `dropChannelSlot(channelSlot)`
1006
956
 
@@ -1050,19 +1000,11 @@ OIMChannelService.INSTANCE.dropChannelSlot(db.defaultChannelSlot);
1050
1000
  `finally`; `assertNotFlushing` throws on `isFlushing` or `frame.isRunning`, so
1051
1001
  all three phases are refused. Refusal is per channel: a flush of one db's channel does not refuse a
1052
1002
  write to another db's.
1053
- - **A refused write keeps its change pending — it is never dropped.** A bucket
1054
- carries a change `addBucketMemberSlots`/`removeBucketMemberSlots` recorded inside an outer open batch
1055
- until that batch closes. If something delivers it mid-flush — a member's
1056
- subscriber giving the list back, so `unholdSlot` reaches `releaseSlotIfUnheld` —
1057
- the write is refused and the bucket's store slot stays listed, so the next
1058
- subframe close delivers it.
1059
1003
  - **Each door asks once, then writes through `writeWithoutFlushCheck`.** A content service
1060
1004
  door opens with `assertWritable`, which asks the channel, and every write it
1061
- makes after that asks nothing: the guard was three field reads and a branch
1062
- per write, and `upsertEntities` of 100 entities into one ordered bucket asked it 302
1063
- times where 201 of those were the door's own re-asking. The per-entity ask is NOT
1064
- removable and stays — user code runs inside a door (`keyOf`, `index.keyOf`,
1065
- `slotEmptiness.isEmpty`, an `IOIMEntityWriter`) and can call
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
1066
1008
  `flush()`, so each entity is a new question. What went is the SECOND and THIRD ask
1067
1009
  of the same entity.
1068
1010
  - **`flush` during a frame throws:** the message says a frame is already running, and a second one
@@ -1077,7 +1019,7 @@ OIMChannelService.INSTANCE.dropChannelSlot(db.defaultChannelSlot);
1077
1019
  hands the work to a microtask or a later callback rather than writing itself.
1078
1020
  - A refusal raised from a tail handler does NOT reach the caller at the call: it
1079
1021
  goes to the error policy like any handler throw — logged by default, or thrown
1080
- out of `flush()` under `OIMErrorPolicyThrowAtOnce`.
1022
+ out of `flush()` under `OIMThrowAtOnceErrorPolicy`.
1081
1023
  - Effects are isolated under the default policy: a throw is logged and handed to
1082
1024
  `onError`, and the frame finishes every other effect.
1083
1025
  - **A derivation is asked, never woken.** No frame walks to a computed slot.
@@ -1085,7 +1027,7 @@ OIMChannelService.INSTANCE.dropChannelSlot(db.defaultChannelSlot);
1085
1027
  ### Subscribing
1086
1028
 
1087
1029
  ```ts
1088
- const effect = effectService.createEffect(db, [slot], [], () => { paint(slot.entity); });
1030
+ const effect = effectService.createEffect(db, () => { paint(slot.entity); }, [slot]);
1089
1031
  effectService.disposeEffect(effect);
1090
1032
  ```
1091
1033
 
@@ -1120,10 +1062,10 @@ function is the undo**: it is called before the next run of that body and by
1120
1062
  `disposeEffect`.
1121
1063
 
1122
1064
  ```ts
1123
- effectService.createEffect(db, [slot], [], () => {
1065
+ effectService.createEffect(db, () => {
1124
1066
  const timer = setInterval(poll, 1000);
1125
1067
  return () => clearInterval(timer);
1126
- });
1068
+ }, [slot]);
1127
1069
  ```
1128
1070
 
1129
1071
  The return is checked with `typeof`, not for truthiness — `() => set.add(key)`
@@ -1131,24 +1073,38 @@ hands back the `Set` — so anything that is not a function is discarded. The un
1131
1073
  is cleared before it is called, so one that throws is not called again by the
1132
1074
  `disposeEffect` behind it.
1133
1075
 
1134
- **Sources found at read: `createEffectWithSourceTracking(db, body)`.** The second way to build
1135
- an effect takes no source lists. The body receives an `OIMEffectSourceTracker`, and what it
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
1136
1092
  reads through the tracker is what that run depends on — signal-style tracking.
1137
1093
 
1138
1094
  ```ts signature
1139
- createEffectWithSourceTracking(channelMap: IOIMChannelMap, body: TOIMEffectBodyWithSourceTracking): TOIMEffect;
1140
- type TOIMEffectBodyWithSourceTracking = (effectSourceTracker: OIMEffectSourceTracker) => (() => void) | void;
1141
- class OIMEffectSourceTracker { // exported as a type; the service builds it
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
1142
1098
  readSourceSlot<TSlot extends TOIMSlot>(slot: TSlot): TSlot; // files the slot, returns it
1143
1099
  readSourceComputedValue<TValue>(computedSlot: TOIMComputedSlot<TValue, unknown>): TValue; // files it, returns its value
1144
1100
  }
1145
1101
  ```
1146
1102
 
1147
1103
  ```ts
1148
- const fallback = cardService.slotContentService.upsertEntity(cards, { id: 'c2', title: 'King', rank: 13, deck: 'hearts' });
1149
- effectService.createEffectWithSourceTracking(db, effectSourceTracker => {
1150
- const isFirst = effectSourceTracker.readSourceSlot(slot).entity?.rank === 1;
1151
- paint(effectSourceTracker.readSourceSlot(isFirst ? slot : fallback).entity);
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);
1152
1108
  });
1153
1109
  ```
1154
1110
 
@@ -1161,7 +1117,7 @@ effectService.createEffectWithSourceTracking(db, effectSourceTracker => {
1161
1117
  creation. A returned function is the undo, exactly as for `TOIMEffectBody`.
1162
1118
 
1163
1119
  ```ts
1164
- const onRank = effectService.createEffect(db, [cardService.holdColumn(slot, 'rank')], [], () => { paint(slot.entity); });
1120
+ const onRank = effectService.createEffect(db, () => { paint(slot.entity); }, [cardService.columnSlotService.holdColumn(slot, 'rank')]);
1165
1121
  ```
1166
1122
 
1167
1123
  **A column effect takes the `TOIMColumnSlot` as a root, not a slot and a name.** Minting the
@@ -1177,7 +1133,7 @@ its carrier. An index store slot's buckets have the same doors on the index serv
1177
1133
  `createEffect` returns the **effect**, not the column: a column holds no value; read `slot.entity[name]`.
1178
1134
 
1179
1135
  **The name is compile-time checked only as far as the service's column-name type goes** —
1180
- `holdColumn` takes the service's `TColumnName`, which `createOIMEntityService` sets to
1136
+ `holdColumn` takes the service's `TColumnName`, which `createOIMSlotContentEntityService` sets to
1181
1137
  `TOIMEntityColumnName<TEntity>`. `TOIMEntityColumnName<TEntity>` is the type that narrows a name to the
1182
1138
  entity's own columns, and `slotContentService.deleteEntityColumns` is typed on it.
1183
1139
 
@@ -1187,13 +1143,13 @@ per call; get it once per view, not per entity. `service.holdColumnGroup(slot, c
1187
1143
  the group's column slot on `slot`, and `service.unholdColumn(columnSlot)` gives it back. A write wakes it once
1188
1144
  if it changes any of the group's columns and not at all if it changes only others.
1189
1145
 
1190
- **`columnNames` picks the column slot store.** `createOIMEntityService<TCard, 'rank' | 'label'>(db, { keyOf, columnNames: ['rank', 'label'] })`
1146
+ **`columnNames` picks the column slot store.** `createOIMSlotCollectionService({ slotContentService, columnNames: ['rank', 'label'] })`
1191
1147
  — and `columnNames` on any factory's options — files the service's column slots by ordinal: every named column
1192
1148
  gets a number, a group gets one at its first slot, and an entity slot's column slots sit in an array by
1193
1149
  number instead of a `Map` per entity slot, which is cheaper to create and give back. `holdColumn` and
1194
1150
  `getColumnGroup` are typed on those names, and a slot for any other column throws. Without `columnNames` the
1195
1151
  store is the `columnSlotStoreService` option, defaulting to the shared map-driven
1196
- `OIMDefaults.COLUMN_SLOT_STORE_SERVICE.getValue()`, and any name is accepted.
1152
+ a shared map-driven store, and any name is accepted.
1197
1153
 
1198
1154
  ---
1199
1155
 
@@ -1220,26 +1176,21 @@ frame, with no further code. Every mutation goes through a domain neuron — a w
1220
1176
  from outside the orchestrator is the mistake, not a case to cover with a fallback.
1221
1177
  Full page: `website/docs/guides/with-cnstra.md`.
1222
1178
 
1223
- The doors are free functions, one module each, so a consumer ships only the one they import. The entity,
1224
- bucket and index factories take `db` first and ONE options object second (optional on the bucket doors); the
1225
- base collection factories and the computed factories take only the options object. Each builds a SERVICE,
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,
1226
1182
  once per collection type; the store slot is made separately:
1227
1183
 
1228
1184
  | Door | Builds | Store slot |
1229
1185
  |---|---|---|
1230
- | `createOIMSlotContentEntityService(db, { keyOf, entityWriter?, slotEmptiness?, slotStoreService?, slotReleasePolicy?, columnSlotStoreService? \| columnNames? })` | `OIMSlotContentEntityService<TEntity, TKey, TStore, TColumnName>`, the entity content service with its slot owner; `slotStoreService` defaults to a Map store for a key and is REQUIRED for any other store (a tree store, or `OIMSlotStoreServiceSingleSlotDriven` for a single, which takes no `keyOf`) | — |
1231
- | `createOIMSlotCollectionService({ slotContentService })` | `OIMSlotCollectionService` over an entity content service: the read doors and the column doors | `createStoreSlot(db)` |
1232
- | `createOIMSingleSlotService({ slotContentService })` | `OIMSingleSlotService` over a single entity content service | `createStoreSlot(db)` |
1233
- | `createOIMEntityService(db, { keyOf, entityWriter?, slotEmptiness?, slotReleasePolicy?, columnSlotStoreService? \| columnNames? })` | convenience wrapper: the two calls above for a Map store; `TColumnName` is `TOIMEntityColumnName<TEntity>` | `createStoreSlot(db)` |
1234
- | `createOIMSingleEntityService(db, { … }?)` | convenience wrapper for a single entity | `createStoreSlot(db)` |
1235
- | `createOIMBucketService<TBucketMemberSlot, TKey?, TSlots?, TSlotContentService?, TStore?>(db, { slotContentService?, bucketSlotStoreService?, maxCommandsPerSlot?, slotReleasePolicy?, columnSlotStoreService? \| columnNames? }?)` | `OIMSlotCollectionService` of bucket slots; `slotContentService` defaults to an `OIMSlotContentBucketServiceArrayDriven` that records `OIMBucketCommands` (`maxCommandsPerSlot` caps it). An array `TKey` makes `bucketSlotStoreService` REQUIRED by type — pass `OIMSlotStoreServiceTreeDriven`, since a Map store compares arrays by reference | `createStoreSlot(db)` |
1236
- | `createOIMSingleBucketService(db, { … }?)` | `OIMSingleSlotService` of one bucket slot | `createStoreSlot(db)` |
1237
- | `createOIMIndexService(db, { entityService, columns? \| keyOf?, slotContentService?, slotReleasePolicy?, columnSlotStoreService? \| columnNames? })` | `OIMIndexService`, over the entity collection service whose store slots it will index (it takes a `Pick` of `getSlots`); `slotContentService` defaults to array-driven, pass `new OIMSlotContentBucketServiceSetDriven()` for an unordered one. Generics `<TEntity, TSourceKey, const TColumns, TKey extends TOIMStoreKey>`: a scalar key, inferred from ONE inline column or from `keyOf`'s return | `createIndexStoreSlot(entityStoreSlot)` |
1238
- | `createOIMIndexServiceSortedArrayDriven(db, { entityService, columns? \| keyOf?, sortColumns, … })` | `OIMIndexServiceSortedArrayDriven`: the index whose buckets are kept in the order of `sortColumns` (`readonly TOIMSortColumn<TEntity>[]`, required) through an `OIMSlotContentBucketServiceSortedArrayDriven`. See [Sorted at write](#sorted-at-write) | `createIndexStoreSlot(entityStoreSlot)` |
1239
- | `createOIMIndexServiceByCompositeKey(db, { entityService, columns? \| keyOf?, … })` | convenience wrapper: the index keyed by an array, `TKey extends readonly TOIMStoreKey[]`, inferred as a tuple from two or more inline columns or from an array-returning `keyOf`, over a tree store | `createIndexStoreSlot(entityStoreSlot)` |
1240
- | `createOIMComputedService({ createSources?, derive?, sameValueGuard?, initialValue?, slotReleasePolicy?, computedSlotStoreService?, columnSlotStoreService?, columnNames? })` | `OIMComputedService<TKey, TValue, TComputedStore>`; its reads are on `computedService.computedCollectionService`; `derive` and `createSources` may instead be given per store slot; `computedSlotStoreService` is REQUIRED for a non-Map store (a single: `OIMSlotStoreServiceSingleSlotDriven`); see [One derivation per key](#one-derivation-per-key) | `createComputedStoreSlot(db, derive?, createSources?)` |
1241
- | `createOIMComputedServiceSorted({ indexService, sortColumns?, comparator?, slotReleasePolicy?, columnSlotStoreService?, computedSlotStoreService? })` | `OIMComputedServiceSorted` — per key, the entity slots of the index bucket under that key, sorted; see [Sorted lists](#sorted-lists) | `createSortedStoreSlot(indexStoreSlot)` |
1242
- | `createOIMComputedServiceBucketMemo({ bucketCollectionService, slotReleasePolicy?, columnSlotStoreService?, computedSlotStoreService? })` | `OIMComputedServiceBucketMemo` — per key, the bucket's slots as one array; see [The order of a bucket](#the-order-of-a-bucket-as-one-derivation-per-key) | `createBucketMemoStoreSlot(bucketStoreSlot)` |
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)` |
1243
1194
 
1244
1195
  `entityWriter` and `slotReleasePolicy` on these doors default to the
1245
1196
  db's, so naming one overrides it for that service alone.
@@ -1247,18 +1198,17 @@ db's, so naming one overrides it for that service alone.
1247
1198
  **A DOOR ALWAYS PASSES; IT NEVER LETS A CLASS REACH FOR A GLOBAL.** Every option
1248
1199
  a service would otherwise answer with `?? OIMDefaults.x` is in the object its
1249
1200
  door hands it, so there is one answer to each question and not two. Pinned in
1250
- `src/factories/tests/which-answer-a-collection-the-root-mints-gets.test.ts`,
1201
+ `src/db/tests/which-answer-a-collection-the-root-mints-gets.test.ts`,
1251
1202
  which also fails when an option arrives on a service and is not forwarded,
1252
1203
  when the root mints anything but the channel and the collector, or when a public
1253
1204
  member appears on the root that is not one of the seven it owns.
1254
1205
 
1255
- **`createOIMBucketService` with no content service is the door for a list whose order is yours.** It builds
1256
- an `OIMSlotContentBucketServiceArrayDriven` — insertion order, never re-decided — that records an
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
1257
1207
  `OIMBucketCommands` script per bucket. The recorder holds no subscription: it lists store slots on the channel
1258
1208
  slot, and the script lives for one frame.
1259
1209
 
1260
1210
  ```ts
1261
- const freshPageService = createOIMBucketService<TOIMEntitySlot<Card>>(db);
1211
+ const freshPageService = createOIMSlotCollectionService({ slotContentService: new OIMSlotContentPositionalArrayBucketService<TOIMEntitySlot<Card>>({ bucketCommands: new OIMBucketCommands({ maxCommandsPerSlot: undefined }) }) });
1262
1212
  const freshPage = freshPageService.createStoreSlot(db);
1263
1213
  const freshBucketSlot = freshPageService.holdSlotByKey(freshPage, 'page-1');
1264
1214
  // freshBucketSlot.commands and freshBucketSlot.isWholeListRecorded carry the script; read them in an effect on the bucket slot
@@ -1268,56 +1218,41 @@ const freshBucketSlot = freshPageService.holdSlotByKey(freshPage, 'page-1');
1268
1218
 
1269
1219
  ## `OIMDefaults` — where an optional dependency comes from
1270
1220
 
1271
- Every optional property of the factories falls back to an entry
1272
- on `OIMDefaults`. It is a class of static `OIMDefaultValue` fields and nothing else — never
1273
- construct one, never subclass it.
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.
1274
1225
 
1275
- | Accessor | Type | What it hands back |
1276
- |---|---|---|
1277
- | `OIMDefaults.COLUMN_SLOT_STORE_SERVICE` | `IOIMSlotStoreService<string, TOIMColumnSlot>` | a map-driven store service for column slots; the factories pass it to every service they build without `columnNames` |
1278
- | `OIMDefaults.COMPUTED_COLUMN_SLOT_STORE_SERVICE` | `IOIMSlotStoreService<string, TOIMComputedColumnSlot>` | a map-driven store service for the column slots of computed services, keyed or single; the computed factories pass it to every one they build without `columnNames` |
1279
- | `OIMDefaults.INDEX_KEYS_SERVICE` | `IOIMIndexKeysService` | `OIMIndexKeysServiceAppending`; the factories pass it when `db.indexKeysService` is not set |
1280
- | `OIMDefaults.KEY_TREE_WALK_SCALAR` | `OIMKeyTreeWalkScalar` | the chain-of-values walk |
1281
- | `OIMDefaults.KEY_TREE_WALK_COMPOSITE` | `OIMKeyTreeWalkComposite` | the chain-of-identities walk |
1282
- | `OIMDefaults.STANDALONE_SLOT_OWNER` | `OIMSlotOwner` | the owner of a slot nobody filed |
1283
- | `OIMDefaults.ENTITY_WRITER` | `IOIMEntityWriter` | `createOIMEntityWriterInPlace()` |
1284
- | `OIMDefaults.ENTITY_SLOT_EMPTINESS` | `IOIMSlotEmptiness<TOIMEntitySlot>` | "empty means `slot.entity === undefined`" |
1285
- | `OIMDefaults.STORE_SLOT_LISTENER` | `IOIMStoreSlotListener` | `OIMStoreSlotListenerNone`: hears nothing. Not a constructor option: every service reads it when it is built and calls `onStoreSlotCreated(storeSlot)` for each store slot it creates. A tool that wants to see every store slot sets its own with `setValue` before the first service is built; once one has been built, `setValue` throws |
1286
-
1287
- **NOTHING IS CONSTRUCTED AT MODULE LOAD.** Every entry is an `OIMDefaultValue`
1288
- whose `getValue()` mints on its first call (`??=`) and hands the same instance back
1289
- for the rest of the process. A consumer that never reaches for a key tree walk never
1290
- builds one.
1291
-
1292
- Each has a matching `setValue(value)`, which is how a process replaces a default
1293
- everywhere at once:
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
+ ```
1294
1238
 
1295
1239
  ```ts
1296
- OIMDefaults.ENTITY_WRITER.setValue(createOIMEntityWriterReplacing());
1240
+ const replacingDefaults = new OIMDefaults({ entityWriter: createOIMReplacingEntityWriter() });
1241
+ const replacingCardService = createOIMSlotCollectionService({
1242
+ defaults: replacingDefaults,
1243
+ slotContentService: createOIMSlotContentEntityService<Card>({ defaults: replacingDefaults }),
1244
+ });
1297
1245
  ```
1298
1246
 
1299
- **`setValue` THROWS ONCE `getValue` HAS HANDED THE FIRST ONE OUT.** `getValue`
1300
- marks the entry as handed out; `setValue` refuses after that, by name —
1301
- `OIMDefaults.ENTITY_WRITER has already been handed out, and whoever took it keeps
1302
- it …`, the name in the message being the field's upper-case name. Whoever took it keeps
1303
- it, so replacing it then would leave two implementations live in one process,
1304
- the older one inside every object built before the call, and the second half of
1305
- the program would disagree with the first about what a write does.
1306
-
1307
- Setting it before anything reads it works, and so does setting it twice before
1308
- the first read. Each property is tracked on its own, so calling
1309
- `OIMDefaults.COLUMN_SLOT_STORE_SERVICE.getValue()` does not close
1310
- `OIMDefaults.KEY_TREE_WALK_SCALAR`. **Set every default you intend to replace at
1311
- startup**, before you construct an `OIMDB` or a service. Pinned by
1312
- `packages/core/src/factories/tests/what-a-default-replaced-too-late-does.test.ts`.
1313
-
1314
- **Each default is its own module, so reaching one retains only its arm.** Every
1315
- default lives in its own file under `packages/core/src/factories/defaults/` with its
1316
- own `OIMDefaultValue`; a service reads the modules it needs and
1317
- neither key tree walk enters its bundle. `OIMDefaults` itself delegates to all of
1318
- them, so importing `OIMDefaults` retains every arm.
1319
- `packages/core/tests/what-an-import-retains.test.ts` pins it — *"a default is one
1320
- door to its own arm, and the defaults class to all of them"*.
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"*.
1321
1256
 
1322
1257
  ---
1323
1258
 
@@ -1329,66 +1264,65 @@ with its slot owner; the collection service (`OIMSlotCollectionService`) over it
1329
1264
  Neither holds an entity: each `createStoreSlot(db)` is one instance of that type's store.
1330
1265
 
1331
1266
  ```ts signature
1332
- createOIMSlotContentEntityService<TEntity, TColumnName?, TKey?, TStore?>(db, {
1333
- keyOf: (entity: TEntity) => TKey; // REQUIRED for a key; absent for a single
1334
- entityWriter?: IOIMEntityWriter; // db.entityWriter, then OIMDefaults.ENTITY_WRITER
1335
- slotEmptiness?: IOIMSlotEmptiness<TOIMEntitySlot<TEntity, TKey>>; // OIMDefaults.ENTITY_SLOT_EMPTINESS
1336
- slotStoreService?: IOIMSlotStoreService<TKey, TOIMEntitySlot<TEntity, TKey>, TStore>; // Map; REQUIRED for any other store
1337
- // and for a single (OIMSlotStoreServiceSingleSlotDriven)
1338
- slotReleasePolicy?: IOIMSlotReleasePolicy; // db.slotReleasePolicy, then OIMSlotReleasePolicyAtEmpty
1339
- columnSlotStoreService?: IOIMSlotStoreService<string, TOIMColumnSlot>; // OIMDefaults.COLUMN_SLOT_STORE_SERVICE
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
1340
1278
  columnNames?: readonly TColumnName[]; // the ordinal column store over those names
1341
- }): OIMSlotContentEntityService<TEntity, TKey, TStore, TColumnName>
1342
-
1343
- createOIMSlotCollectionService({ slotContentService }): OIMSlotCollectionService<TKey, TOIMEntitySlot<TEntity, TKey>, TStore, …>
1344
- collectionService.createStoreSlot(db): TOIMStoreSlot<TStore, TOIMEntitySlot<TEntity, TKey>, Partial<TEntity>>
1345
-
1346
- createOIMEntityService<TEntity, TColumnName?, TKey?>(db, { keyOf, … }) // convenience: both calls, Map store
1279
+ }): OIMWritableSlotCollectionService<TKey, TOIMEntitySlot<TEntity, TKey>, TStore, TEntity, …>
1280
+ collectionService.createStoreSlot(db): TOIMStoreSlot<TStore, TOIMEntitySlot<TEntity, TKey>, Partial<TEntity>, typeof collectionService>
1347
1281
  ```
1348
1282
 
1349
1283
  **Each collection type has its own column owner.** It is stateless configuration built with the slot owner
1350
1284
  over `columnSlotStoreService` (or the ordinal store `columnNames` names); the column state — the column slots,
1351
1285
  the column groups, and which columns are referenced — lives on the store slot. The doors are on the
1352
- collection service (`getColumn`, `holdColumn`, `unholdColumn`, `getColumnGroup`, `holdColumnGroup`,
1286
+ collection's `columnSlotService` (`getColumn`, `holdColumn`, `unholdColumn`, `getColumnGroup`, `holdColumnGroup`,
1353
1287
  `getAnyColumn`, `holdAnyColumn`). The column owner has two strategies, chosen by the factory: write-driven
1354
- column slots, woken by the marks a write makes (`OIMColumnOwnerPushDriven`, entities and buckets), and
1355
- derive-driven column slots, each a computed slot over its carrier (`OIMColumnOwnerPullDriven`, computeds).
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).
1356
1290
 
1357
1291
  Example, over the `db`, `cardService`, `cards` and `slot` of the setup block:
1358
1292
 
1359
1293
  ```ts
1360
- const effect = effectService.createEffect(db, [slot], [], () => { console.log(slot.entity?.title); });
1294
+ const effect = effectService.createEffect(db, () => { console.log(slot.entity?.title); }, [slot]);
1361
1295
  cardService.slotContentService.updateEntity(slot, { title: 'Ace of Spades' });
1362
1296
  db.flush();
1363
1297
  effectService.disposeEffect(effect);
1364
1298
  ```
1365
1299
 
1366
- Assembled from the base layer, which is what `createOIMEntityService` does:
1300
+ Assembled with a store service of your own:
1367
1301
 
1368
1302
  ```ts
1369
1303
  const baseCardService = createOIMSlotCollectionService({
1370
- slotContentService: createOIMSlotContentEntityService<Card>(db, { keyOf: (card) => card.id }),
1304
+ slotContentService: createOIMSlotContentEntityService<Card, string>(),
1305
+ slotStoreService: new OIMSlotMapStoreService<TOIMEntitySlot<Card, string>>(),
1371
1306
  });
1372
1307
  const baseCards = baseCardService.createStoreSlot(db);
1373
1308
  ```
1374
1309
 
1375
1310
  The writer is omitted because the db and `OIMDefaults` supply it, and so is the column store. The slot stamp
1376
- is not an option of the content service: it is `new OIMDB({ slotStamp })`, for every collection of that db.
1311
+ is not an option of any factory: it is `new OIMDefaults({ slotStamp })`, passed as `defaults`.
1377
1312
 
1378
1313
  ### Public properties
1379
1314
 
1380
1315
  | Property | Of | Type |
1381
1316
  |---|---|---|
1382
- | `slotContentService` | the collection service | the content service: the write doors |
1383
- | `keyOf` | the entity content service | `(entity: TEntity) => TKey` |
1384
- | `slotOwner` | the entity content service | `IOIMSlotOwner` — the owner of every slot the collection type files |
1385
- | `slotStoreService` | the entity content service | the store service |
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 |
1386
1320
 
1387
1321
  A store slot is not a slot, so nothing holds, subscribes or wakes it: there is no
1388
1322
  `effectService.createEffect(db, [cards], …)` that hears the population. "Something in this population changed" is an
1389
1323
  index over the store slot and a subscription to its bucket — the bucket's door
1390
1324
  is written when an entity slot joins or leaves it — or a content-change listener on the store slot
1391
- (`OIMContentChangeListenerService.INSTANCE.addContentChangeListener(storeSlot, listener, readColumnNames)`,
1325
+ (`OIMSlotContentChangeListenerService.INSTANCE.addContentChangeListener(storeSlot, listener, readColumnNames)`,
1392
1326
  removed by the same listener object). The slot owner is what `slot.owner` points at for every slot the type
1393
1327
  filed, in every one of its store slots; `slot.storeSlot` names which store slot.
1394
1328
 
@@ -1406,7 +1340,7 @@ for (const filedSlot of cardService.getSlots(cards)) {
1406
1340
  ```
1407
1341
 
1408
1342
  To have that answer MAINTAINED and subscribable instead of walked, declare an
1409
- index keyed on the entity's own id (`keyOf: card => card.id`) and hold the key
1343
+ index keyed on the entity's own id (`keyOf: (card: Card) => card.id`) and hold the key
1410
1344
  you want. An index's bucket is an injected lifetime — a
1411
1345
  `slotReleasePolicy` — so it is the consumer who says how long the
1412
1346
  answer outlives its last reader.
@@ -1415,13 +1349,12 @@ An index maintains buckets only while they are held, filing entity slots from th
1415
1349
  store slot on every write by the entity's key value. The bucket is created on first
1416
1350
  `holdSlotByKey` by walking the source store slot's slots once, and released on the last give-back.
1417
1351
 
1418
- **`holdColumn(slot, columnName)`** is on the collection service and takes a hold on a column slot;
1419
- `service.unholdColumn(columnSlot)` gives that hold back. **`service.getColumn(slot, columnName)`**
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)`**
1420
1354
  returns the same column slot without a hold: a column slot it mints is held by the
1421
1355
  unused-slot collector until the end of the next flush, so `getColumn` is
1422
- the door that does not hold but tracks. `slotContentService.findEntityByKey(storeSlot, key)` reads the entity
1423
- itself through the store lookup `findSlotByKey` runs, so it mints nothing: a miss answers `undefined`
1424
- and leaves no slot behind.
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.
1425
1358
 
1426
1359
  ### The doors every service has
1427
1360
 
@@ -1442,8 +1375,15 @@ class OIMSlotCollectionService<TKey, TSlot, TStore, TSlotContentService, TColumn
1442
1375
  getSlots(storeSlot): IterableIterator<TSlot>;
1443
1376
  getSlotCount(storeSlot): number;
1444
1377
  isSameKey(key, otherKey): boolean;
1445
- getColumn / holdColumn / getAnyColumn / holdAnyColumn / getColumnGroup / holdColumnGroup / unholdColumn;
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
1446
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
1447
1387
  ```
1448
1388
 
1449
1389
  | Door | Of | Signature | Mints? |
@@ -1455,13 +1395,10 @@ class OIMSlotCollectionService<TKey, TSlot, TStore, TSlotContentService, TColumn
1455
1395
  | `holdSlotByKey(storeSlot, key)` | collection | `TSlot` — minted if missing, `holdCount` up by one | **yes** |
1456
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 |
1457
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 |
1458
- | `insertEntity(storeSlot, key, entity, changed?)` | entity content | `TOIMEntitySlot` — **total; always the identity it resolved or minted** | **yes** |
1459
- | `upsertEntity(storeSlot, entity, changed?)` / `upsertEntities(storeSlot, entities, changed?)` | entity content | the slot(s) — `insertEntity` under `keyOf(entity)` | **yes** |
1460
- | `writeEntity(slot, entity)` / `writeBucketMemberSlots(bucketSlot, members)` | content | `boolean` — on a slot you already hold; the boolean is about the CONTENT | no |
1461
- | `deleteEntity(slot)` / `deleteEntities(storeSlot, slots)` / `clearBucketMemberSlots(bucketSlot)` | content | `void` — it takes the value, not the identity | no |
1462
- | `clearAllEntities(storeSlot)` | entity content | `void` — clears every slot filed in that store slot | 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 |
1463
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 |
1464
- | `fillEntities(storeSlot, keys, entities)` / `insertEntities(storeSlot, entities)` | entity content | `void` — a bulk load of new keys; hands out no reference. On a key that is already filed or named twice it throws and rolls back only the entities this call filed; `fillEntities` also requires an empty store slot, and throws on lists of different lengths | yes |
1465
1402
  | `slot.owner.holdSlot(slot)` / `slot.owner.unholdSlot(slot)` | `void` — the `IOIMSlotOwner` pair; `unholdSlot` on an unheld slot throws by name | no |
1466
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 |
1467
1404
 
@@ -1470,10 +1407,10 @@ class OIMSlotCollectionService<TKey, TSlot, TStore, TSlotContentService, TColumn
1470
1407
  owned or in-grace slot it returns having done nothing, and on an
1471
1408
  already-released one it returns silently rather than throwing.
1472
1409
 
1473
- **`insertEntity` does not file a key whose content is refused.** On a MISS it
1410
+ **`writeSlotContentByKey` does not file a key whose content is refused.** On a MISS it
1474
1411
  mints, writes inside a `try`, and on a throw runs the release guard on what it
1475
1412
  just minted before re-raising — so the caller's error is the only thing that
1476
- leaves. Every path still runs `closeSubframe` from a `finally`. The HIT path looks
1413
+ leaves. The HIT path looks
1477
1414
  the key up first and never mints, so a refused write on an existing key leaves
1478
1415
  that key exactly as it was.
1479
1416
 
@@ -1481,21 +1418,18 @@ that key exactly as it was.
1481
1418
 
1482
1419
  | | What happened | What the caller gets |
1483
1420
  |---|---|---|
1484
- | **Refused** | The write threw; **no content ever arrived** | The throw. A key minted for it is unwound — `insertEntity` files nothing |
1421
+ | **Refused** | The write threw; **no content ever arrived** | The throw. A key minted for it is unwound — `writeSlotContentByKey` files nothing |
1485
1422
  | **Unchanged** | The write **did** happen and the content was already there | The identity, like any other write. Nothing is unwound |
1486
1423
 
1487
- `insertEntity` is **total**: whatever it decides about the content, it hands back
1424
+ `writeSlotContentByKey` is **total**: whatever it decides about the content, it hands back
1488
1425
  the identity it resolved or minted. A door that mints must leave a reference
1489
1426
  behind, which is the one test that admits a door at all.
1490
1427
 
1491
- `fillEntities` is the companion and does not carry the rule: it returns `void` and hands
1492
- out no reference at all.
1493
-
1494
1428
  **`changed?` — the verdict, into an array you own.**
1495
1429
 
1496
1430
  ```ts
1497
1431
  const changed: TOIMEntitySlot<Card, TOIMStoreKey>[] = [];
1498
- cardService.slotContentService.insertEntity(cards, 'c1', { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' }, changed); // pushed exactly when the content differed
1432
+ cardService.writeSlotContentByKey(cards, 'c1', { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' }, changed); // pushed exactly when the content differed
1499
1433
  ```
1500
1434
 
1501
1435
  - **Optional, yours, and appended to** — never read by the engine, never cleared.
@@ -1513,49 +1447,45 @@ cardService.slotContentService.insertEntity(cards, 'c1', { id: 'c1', title: 'Ace
1513
1447
 
1514
1448
  ## Writing
1515
1449
 
1516
- Every write door is on the entity content service, `service.slotContentService`. A store door takes the store
1517
- slot first; a slot door takes the slot alone. There are no by-key write doors: resolve the slot with
1518
- `getSlotByKey` / `findSlotByKey` and write through it.
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, () => { … })`.
1519
1453
 
1520
1454
  | Call | Signature |
1521
1455
  |---|---|
1522
- | `upsertEntity(storeSlot, entity, changedEntitySlots?)` | `(storeSlot, TEntity, TOIMEntitySlot<TEntity, TKey>[]?) => TOIMEntitySlot<TEntity, TKey>` — **total** |
1523
- | `upsertEntities(storeSlot, entities, changedEntitySlots?)` | `(storeSlot, readonly TEntity[], TOIMEntitySlot<TEntity, TKey>[]?) => TOIMEntitySlot<TEntity, TKey>[]` — the slot of EVERY entity, in input order, whether or not its write changed anything |
1524
- | `insertEntity(storeSlot, key, entity, changedEntitySlots?)` | `(storeSlot, TKey, TEntity, TOIMEntitySlot<TEntity, TKey>[]?) => TOIMEntitySlot<TEntity, TKey>` — **total**, under a key you name |
1525
- | `insertEntities(storeSlot, entities)` | `(storeSlot, readonly TEntity[]) => void` — a bulk load of NEW keys. A key already filed, or named twice in the list, throws and rolls back only the entities this call filed; what was in the store slot before stays. No `changed` — a bulk load changes every entity |
1526
- | `fillEntities(storeSlot, keys, entities)` | `(storeSlot, readonly TKey[], readonly TEntity[]) => void` — the same under keys you name, with the same rollback; it also **throws unless the store slot is empty** |
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 |
1527
1458
  | `updateEntity(slot, patch)` | `(slot, Partial<TEntity>) => slot` |
1528
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 |
1529
- | `deleteEntity(slot)` | `(slot) => void` |
1530
- | `deleteEntities(storeSlot, slots)` | the batch form — one subframe for the whole batch, so the store slot is written to the channel ONCE for the batch, not once per entity (`upsertEntities` and `insertEntities` are the same) |
1531
- | `clearAllEntities(storeSlot)` | `(storeSlot) => void` — clears every filed slot inside one subframe, so the whole clear is one frame, not one per slot |
1532
- | `markEntityChanged(slot, changedColumns?)` | `(TOIMEntitySlot<TEntity, TKey>, readonly (keyof TEntity & string)[]?) => void` |
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` |
1533
1462
  | `deleteEntityColumns(slot, columnNames)` | `(slot, readonly TOIMEntityColumnName<TEntity>[]) => slot` — the named columns go |
1534
- | `writeEntity(slot, entity)` | `(slot, TEntity) => boolean` — the whole value onto a slot you hold; columns it had and `entity` does not, go |
1535
- | `findEntityByKey(storeSlot, key)` | `(storeSlot, TKey) => TEntity \| undefined` — the entity alone; mints nothing |
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 |
1536
1465
 
1537
1466
  - **The store keeps your object by default.** Under the default `oimInsertEntityWithoutCopy` the
1538
1467
  slot holds the object you hand in, so with the in-place writer a later update edits that
1539
- same object; pass `entityInsertStrategy: oimInsertEntityWithCopy` to store `{ ...entity }`
1468
+ same object; pass `entityInserter: oimInsertEntityWithCopy` to store `{ ...entity }`
1540
1469
  and leave your object untouched. After an insert, change the object only through the
1541
1470
  content service (`updateEntity`, or `markEntityChanged` after a hand edit): editing it and calling
1542
- `upsertEntity` with the same object again wakes nobody, because no column reads as changed. `oimInsertEntityWithSanitizedCopy` also removes an own
1471
+ `writeSlotContent` with the same object again wakes nobody, because no column reads as changed. `oimInsertEntityWithSanitizedCopy` also removes an own
1543
1472
  `__proto__` key: pick it for untrusted JSON.
1544
1473
  - `updateEntity` takes a **patch** — named columns move, unnamed ones stay.
1545
- `insertEntity`, `upsertEntity` and `writeEntity` take a **whole value**, and a column the new value does not name
1474
+ `writeSlotContentByKey` and `writeSlotContent` take a **whole value**, and a column the new value does not name
1546
1475
  is removed from the entity.
1547
1476
  - A write that moves no column wakes nobody.
1548
- - **`upsertEntity` always hands back the slot**, whatever the write changed — see the
1549
- rule on `insertEntity`; this door is that one plus `keyOf`.
1550
- - **`changedEntitySlots?` is the same array on both**: optional, yours, appended with the
1477
+ - **`writeSlotContentByKey` always hands back the slot**, whatever the write changed.
1478
+ - **`changedEntitySlots?`**: optional, yours, appended with the
1551
1479
  entity slots whose content was different from what they already held. One array reused
1552
- across a whole `upsertEntities` batch is the intended use, so a
1480
+ across a whole batch is the intended use, so a
1553
1481
  pass over its slots costs the entities that changed rather than the entities that
1554
1482
  arrived. It is never cleared — you empty it.
1555
1483
 
1556
1484
  ```ts
1557
1485
  const changed: TOIMEntitySlot<Card, TOIMStoreKey>[] = [];
1558
- cardService.slotContentService.upsertEntities(cards, payload, changed);
1486
+ cardService.writeInBatch(cards, () => {
1487
+ for (const card of payload) cardService.writeSlotContentByKey(cards, card.id, card, changed);
1488
+ });
1559
1489
  for (const slot of changed) reindex(slot); // the re-sent entities are not in here
1560
1490
  changed.length = 0;
1561
1491
  ```
@@ -1583,7 +1513,7 @@ cardService.slotContentService.markEntityChanged(slot, ['title']);
1583
1513
  - **The hand mutation already compiles.** `TOIMEntitySlot` declares
1584
1514
  `readonly entity: TEntity | undefined`, and `readonly` forbids REPLACING the
1585
1515
  object, not writing its columns — `entity.title = 'two'` needs no cast, only
1586
- the usual narrowing of `| undefined`. `TOIMSlotEditable<TSlot>` is what strips
1516
+ the usual narrowing of `| undefined`. `TOIMEditable<TSlot>` is what strips
1587
1517
  `readonly` from the field itself, which is what assigning `slot.entity` needs.
1588
1518
  - **With a list, only the named columns wake. Without it, every watched column
1589
1519
  does** — the column owner walks the slot's whole column store, and a
@@ -1592,13 +1522,13 @@ cardService.slotContentService.markEntityChanged(slot, ['title']);
1592
1522
  what the store holds. **Pass the list.** The no-argument form is the convenient
1593
1523
  one and it gives back the per-column walk this door exists to skip.
1594
1524
  - **An empty list wakes nothing at all** — not the columns, not the entity slot, not the
1595
- store slot's listeners. `[]` is the caller saying nothing moved, so it returns before the
1596
- subframe opens: a write that changes nothing wakes nobody here too.
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.
1597
1527
  - **With either of the other two it wakes the entity slot and the store slot's content-change listeners**, so an
1598
1528
  effect on the slot itself and an index over the store slot both see a hand mutation.
1599
1529
  - **It re-stamps emptiness** whenever emptiness can depend on the mutation — with no column list,
1600
- on a slot whose `entity` is `undefined`, and on a content service with a `slotEmptiness` other than
1601
- `OIMSlotEmptinessByEntityPresence.INSTANCE`, a release policy that releases written slots, or a db with a
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
1602
1532
  `slotStamp` — so a hand mutation that emptied the entity leaves `oimIsSlotEmpty(slot)`
1603
1533
  true rather than lying, and the release guard can take the identity when it should.
1604
1534
  - **It refuses inside a frame**, through the same `assertWritable` →
@@ -1614,33 +1544,33 @@ cardService.slotContentService.markEntityChanged(slot, ['title']);
1614
1544
  A **writer** is the operations that move an entity's columns, and nothing
1615
1545
  else: it reaches no channel, takes no holds and knows nothing about lifetime.
1616
1546
  Both strategies ship as factories; the fork is chosen once, as the content service's `entityWriter`
1617
- (or for every collection of a db, `new OIMDB({ entityWriter })`).
1547
+ (or for every factory given it, `new OIMDefaults({ entityWriter })` as `defaults`).
1618
1548
 
1619
1549
  ```ts signature
1620
1550
  interface IOIMEntityWriter {
1621
- readonly fillEntity: (slot: TOIMEntitySlot, entity: object) => void;
1622
- readonly putEntity: (slot: TOIMEntitySlot, entity: object, changedColumnNames: string[] | undefined) => boolean;
1623
- readonly patchEntity: (slot: TOIMEntitySlot, patch: object, changedColumnNames: string[] | undefined) => boolean;
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;
1624
1554
  readonly patchColumn: (
1625
- slot: TOIMEntitySlot, columnName: string, value: unknown, changedColumnNames: string[] | undefined,
1555
+ slot: TOIMEntityContentSlot, columnName: string, value: unknown, changedColumnNames: string[] | undefined,
1626
1556
  ) => boolean;
1627
- readonly deleteColumns: (slot: TOIMEntitySlot, names: readonly string[]) => readonly string[];
1628
- readonly clearEntity: (slot: TOIMEntitySlot) => void;
1557
+ readonly deleteColumns: (slot: TOIMEntityContentSlot, names: readonly string[]) => readonly string[];
1558
+ readonly clearEntity: (slot: TOIMEntityContentSlot) => void;
1629
1559
  }
1630
1560
 
1631
1561
  type TOIMEntityWriterOptions = {
1632
1562
  sameValueGuard?: TOIMSameValueGuard;
1633
- entityInsertStrategy?: TOIMEntityInsertStrategy;
1634
- putEntityColumns?: typeof oimPutEntityColumns; // oimPutEntityColumns | oimPutEntityColumnsGuarded
1635
- patchEntityColumns?: typeof oimPatchEntityColumns; // oimPatchEntityColumns | oimPatchEntityColumnsGuarded
1636
- patchEntityColumn?: typeof oimPatchEntityColumn; // oimPatchEntityColumn | oimPatchEntityColumnGuarded
1563
+ entityInserter?: TOIMEntityInserter;
1564
+ putEntityColumns?: typeof oimPutEntityColumns; // oimPutEntityColumns | oimGuardedPutEntityColumns
1565
+ patchEntityColumns?: typeof oimPatchEntityColumns; // oimPatchEntityColumns | oimGuardedPatchEntityColumns
1566
+ patchEntityColumn?: typeof oimPatchEntityColumn; // oimPatchEntityColumn | oimGuardedPatchEntityColumn
1637
1567
  };
1638
1568
 
1639
- createOIMEntityWriterInPlace(options?: TOIMEntityWriterOptions): IOIMEntityWriter // stored object keeps its identity
1640
- createOIMEntityWriterReplacing(options?: TOIMEntityWriterOptions): IOIMEntityWriter // a new object per write that changed something
1569
+ createOIMInPlaceEntityWriter(options?: TOIMEntityWriterOptions): IOIMEntityWriter // stored object keeps its identity
1570
+ createOIMReplacingEntityWriter(options?: TOIMEntityWriterOptions): IOIMEntityWriter // a new object per write that changed something
1641
1571
  ```
1642
1572
 
1643
- `entityInsertStrategy` is the first-write strategy: `oimInsertEntityWithoutCopy` (the default) stores
1573
+ `entityInserter` is the first-write strategy: `oimInsertEntityWithoutCopy` (the default) stores
1644
1574
  your object itself, `oimInsertEntityWithCopy` stores `{ ...entity }`, `oimInsertEntityWithSanitizedCopy`
1645
1575
  stores a copy with an own `__proto__` key removed (for untrusted JSON).
1646
1576
 
@@ -1660,14 +1590,14 @@ per-column comparison that decides "moved".
1660
1590
 
1661
1591
  `putEntity` is PUT, `patchEntity` is PATCH (HTTP semantics). PUT makes the stored entity
1662
1592
  the value handed in — changed columns written, columns the value lacks removed — in ONE
1663
- call, returning both sets of names; PATCH writes only the columns named. `upsertEntity`
1664
- of a key that already has a value and `slotContentService.writeEntity(slot, entity)` are PUT: a whole-entity
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
1665
1595
  write that drops a column is ONE channel write of the slot, and the replacing writer
1666
1596
  builds ONE new object.
1667
1597
 
1668
- Pick `createOIMEntityWriterReplacing` when something downstream compares by
1598
+ Pick `createOIMReplacingEntityWriter` when something downstream compares by
1669
1599
  reference — a React `useSyncExternalStore` snapshot, a memo, a shallow-equal
1670
- selector. Under `createOIMEntityWriterInPlace` the entity reference never moves,
1600
+ selector. Under `createOIMInPlaceEntityWriter` the entity reference never moves,
1671
1601
  so a value comparison never fires and a binding needs a counter bumped inside its
1672
1602
  handler. Replacing allocates one object per changed write; in-place allocates
1673
1603
  none.
@@ -1679,9 +1609,9 @@ replacing — so the entity keeps its hidden class, and the key stays in
1679
1609
  leaves it out does not report it, and taking the entity away does not wake its
1680
1610
  readers.
1681
1611
 
1682
- **Neither strategy reports anything.** Reporting is `OIMSlotContentEntityService`'s,
1683
- and it wraps each writer call in a content subframe on the store slot's channel slot so the whole write is
1684
- one logical change.
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.
1685
1615
 
1686
1616
  **A writer of your own** reuses the column walks both shipped writers run:
1687
1617
 
@@ -1692,10 +1622,10 @@ oimPatchEntityColumn(slot, columnName: string, value: unknown, sameValueGuard, c
1692
1622
  targetOf: (entity: Record<string, unknown>) => Record<string, unknown>): Record<string, unknown> | undefined
1693
1623
  oimPutEntityColumns(slot, entity: object, sameValueGuard, changedColumnNames: string[] | undefined,
1694
1624
  targetOf: (entity: Record<string, unknown>) => Record<string, unknown>): Record<string, unknown> | undefined
1695
- oimPutEntityColumnsGuarded(slot, entity: object, sameValueGuard, changedColumnNames: string[] | undefined,
1625
+ oimGuardedPutEntityColumns(slot, entity: object, sameValueGuard, changedColumnNames: string[] | undefined,
1696
1626
  targetOf: (entity: Record<string, unknown>) => Record<string, unknown>): Record<string, unknown> | undefined
1697
- oimPatchEntityColumnsGuarded(slot, patch: object, sameValueGuard, changedColumnNames, targetOf) // same signature as the unguarded walk
1698
- oimPatchEntityColumnGuarded(slot, columnName: string, value: unknown, sameValueGuard, changedColumnNames, targetOf)
1627
+ oimGuardedPatchEntityColumns(slot, patch: object, sameValueGuard, changedColumnNames, targetOf) // same signature as the unguarded walk
1628
+ oimGuardedPatchEntityColumn(slot, columnName: string, value: unknown, sameValueGuard, changedColumnNames, targetOf)
1699
1629
  ```
1700
1630
 
1701
1631
  Each compares every column through `sameValueGuard`, writes the moved ones into
@@ -1708,26 +1638,26 @@ ships two arms:
1708
1638
 
1709
1639
  | Option | Default (unguarded) | Guarded arm |
1710
1640
  |---|---|---|
1711
- | `putEntityColumns` | `oimPutEntityColumns` — skips neither `__proto__` keys nor inherited stored columns | `oimPutEntityColumnsGuarded` — skips both |
1712
- | `patchEntityColumns` | `oimPatchEntityColumns` — every enumerable key of the patch, `__proto__` included | `oimPatchEntityColumnsGuarded` — skips a `__proto__` key and a key the patch does not own |
1713
- | `patchEntityColumn` | `oimPatchEntityColumn` | `oimPatchEntityColumnGuarded` — refuses the column name `__proto__` |
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__` |
1714
1644
 
1715
1645
  Take the guarded arms, at a cost on every write, for values that can carry a `__proto__` key or are class
1716
- instances — `createOIMEntityWriterInPlace({ putEntityColumns: oimPutEntityColumnsGuarded,
1717
- patchEntityColumns: oimPatchEntityColumnsGuarded, patchEntityColumn: oimPatchEntityColumnGuarded })`, the
1718
- same options on `createOIMEntityWriterReplacing`. The writer decides only where the result goes — this one
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
1719
1649
  stores every value frozen:
1720
1650
 
1721
1651
  ```ts
1722
1652
  const copyColumns = (entity: Record<string, unknown>): Record<string, unknown> => ({ ...entity });
1723
1653
 
1724
- function storeFrozen(slot: TOIMEntitySlot, next: Record<string, unknown> | undefined): boolean {
1654
+ function storeFrozen(slot: TOIMEntityContentSlot, next: Record<string, unknown> | undefined): boolean {
1725
1655
  if (next === undefined) return false;
1726
- (slot as TOIMSlotEditable<TOIMEntitySlot>).entity = Object.freeze(next);
1656
+ (slot as TOIMEditable<TOIMEntityContentSlot>).entity = Object.freeze(next);
1727
1657
  return true;
1728
1658
  }
1729
1659
 
1730
- const replacingWriter = createOIMEntityWriterReplacing();
1660
+ const replacingWriter = createOIMReplacingEntityWriter();
1731
1661
 
1732
1662
  const freezingWriter: IOIMEntityWriter = {
1733
1663
  fillEntity: (slot, entity) => { storeFrozen(slot, copyColumns(entity as Record<string, unknown>)); },
@@ -1751,30 +1681,25 @@ const freezingWriter: IOIMEntityWriter = {
1751
1681
  ## Single entities
1752
1682
 
1753
1683
  A single entity — one value under no key, such as settings or a session — is a store slot of an
1754
- `OIMSingleSlotService` over an entity content service whose key type is `void` and whose store service is
1755
- `OIMSlotStoreServiceSingleSlotDriven.INSTANCE`, which files at most one slot per store slot. It has no
1756
- by-key doors: its read doors name the one slot.
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.
1757
1686
 
1758
1687
  ```ts signature
1759
- createOIMSingleEntityService<TEntity, TColumnName?>(db, {
1760
- entityWriter?, slotEmptiness?, slotReleasePolicy?, columnSlotStoreService?, columnNames?,
1761
- }?): OIMSingleSlotService<TOIMEntitySlot<TEntity, void>, OIMSlotContentEntityService<TEntity, void, TOIMSingleSlotStore<…>, TColumnName>, …>
1762
-
1763
- // the base layer it assembles
1764
1688
  createOIMSingleSlotService({
1765
- slotContentService: createOIMSlotContentEntityService<TEntity>(db, {
1766
- slotStoreService: OIMSlotStoreServiceSingleSlotDriven.INSTANCE, // no keyOf
1767
- }),
1768
- })
1689
+ slotContentService: createOIMSlotContentEntityService<TEntity>(),
1690
+ slotReleasePolicy?, slotSubscriptionListener?, columnSlotStoreService?, columnNames?,
1691
+ }): OIMWritableSingleSlotService<TOIMEntityContentSlot<TEntity>, TEntity, …>
1769
1692
 
1770
- class OIMSingleSlotService<TSlot, TSlotContentService, TColumnName, …> {
1693
+ class OIMWritableSingleSlotService<TSlot, TContent, TSlotContentService, …> {
1771
1694
  readonly slotContentService: TSlotContentService;
1772
- createStoreSlot(channelMap: IOIMChannelMap): TOIMStoreSlot<TOIMSingleSlotStore<TSlot>, TSlot, TPatch>;
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;
1773
1699
  findSingleSlot(storeSlot): TSlot | undefined; // mints nothing
1774
1700
  hasSingleSlotContent(storeSlot): boolean; // mints nothing
1775
1701
  getSingleSlot(storeSlot): TSlot; // minted if missing, tracked until the next flush
1776
1702
  holdSingleSlot(storeSlot): TSlot; // minted if missing, holdCount up by one
1777
- getColumn / holdColumn / getAnyColumn / holdAnyColumn / getColumnGroup / holdColumnGroup / unholdColumn;
1778
1703
  }
1779
1704
  ```
1780
1705
 
@@ -1785,33 +1710,32 @@ with no value until something writes it. Its store slot is a `TOIMSingleSlotStor
1785
1710
  ```ts
1786
1711
  type Settings = { theme: 'light' | 'dark'; zoom: number };
1787
1712
 
1788
- const settingsService = createOIMSingleEntityService<Settings>(db);
1713
+ const settingsService = createOIMSingleSlotService({ slotContentService: createOIMSlotContentEntityService<Settings>() });
1789
1714
  const settings = settingsService.createStoreSlot(db);
1790
- settingsService.slotContentService.upsertEntity(settings, { theme: 'light', zoom: 1 });
1715
+ settingsService.writeSingleSlotContent(settings, { theme: 'light', zoom: 1 });
1791
1716
  const settingsSlot = settingsService.holdSingleSlot(settings);
1792
- const themeColumn = settingsService.holdColumn(settingsSlot, 'theme');
1793
- const themeEffect = effectService.createEffect(db, [themeColumn], [], () => { console.log(settingsSlot.entity?.theme); });
1717
+ const themeColumn = settingsService.columnSlotService.holdColumn(settingsSlot, 'theme');
1718
+ const themeEffect = effectService.createEffect(db, () => { console.log(settingsSlot.entity?.theme); }, [themeColumn]);
1794
1719
  settingsService.slotContentService.updateEntity(settingsSlot, { theme: 'dark' });
1795
1720
  db.flush();
1796
1721
  effectService.disposeEffect(themeEffect);
1797
- settingsService.unholdColumn(themeColumn);
1722
+ settingsService.columnSlotService.unholdColumn(themeColumn);
1798
1723
  settingsSlot.owner.unholdSlot(settingsSlot);
1799
1724
  ```
1800
1725
 
1801
1726
  **The slot is reached only through the service.** `getSingleSlot(storeSlot)` hands back the live slot, and
1802
1727
  mints a fresh one — tracked by the unused-slot collector until the next flush — when there is none;
1803
- `holdSingleSlot(storeSlot)` does the same and takes a hold. Every store write door of the content service
1804
- works on it as on a keyed store slot (`upsertEntity`, `clearAllEntities`), and every slot door
1805
- (`updateEntity`, `updateEntityColumn`, `deleteEntityColumns`, `writeEntity`, `markEntityChanged`,
1806
- `deleteEntity`) takes that slot.
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.
1807
1731
 
1808
- The slot is of the keyed entity slot's literal shape, owned by the content service's slot owner —
1809
- `settingsSlot.owner === settingsService.slotContentService.slotOwner`, and its `storeSlot` is `settings` — so
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
1810
1734
  `effectService.createEffect(db, [settingsSlot], [], …)` works and a column of it is a root like any other.
1811
1735
  Its key is `undefined`, and its store is `{ slot }` (`TOIMSingleSlotStore`). Its lifetime is the
1812
- `slotReleasePolicy` (the db's, then `OIMSlotReleasePolicyAtEmpty`), so a single entity that holds a value
1736
+ `slotReleasePolicy` (`defaults.slotReleasePolicy`, then `OIMSlotAtEmptyReleasePolicy`), so a single entity that holds a value
1813
1737
  stays; one emptied and unheld is released, and the slot `getSingleSlot` mints next holds no entity. The value
1814
- is stored by the writer's `entityInsertStrategy`, which by default keeps the object handed in rather than a copy.
1738
+ is stored by the writer's `entityInserter`, which by default keeps the object handed in rather than a copy.
1815
1739
 
1816
1740
  ---
1817
1741
 
@@ -1821,18 +1745,17 @@ A bucket collection is, like an entity collection, a content service and the col
1821
1745
  The content service is the strategy — how a bucket keeps its member slots — and it is chosen at the factory:
1822
1746
 
1823
1747
  ```ts signature
1824
- createOIMBucketService<TBucketMemberSlot = TOIMEntitySlot, TKey = TOIMStoreKey, TSlots?, TSlotContentService?, TStore?>(db, {
1825
- slotContentService?: OIMSlotContentBucketService<TBucketMemberSlot, TSlots>; // default: array-driven, with commands
1826
- maxCommandsPerSlot?: number; // for the default content service's OIMBucketCommands
1827
- bucketSlotStoreService?: IOIMSlotStoreService<TKey, TOIMBucketSlot<TSlots, TKey>, TStore>; // Map; REQUIRED for an array TKey
1828
- slotReleasePolicy?: IOIMSlotReleasePolicy;
1829
- columnSlotStoreService?: IOIMSlotStoreService<string, TOIMColumnSlot>;
1830
- columnNames?: readonly string[];
1831
- }?): OIMSlotCollectionService<TKey, TOIMBucketSlot<TSlots, TKey>, TStore, TSlotContentService, …>
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[], …>
1832
1753
 
1833
- new OIMSlotContentBucketServiceSetDriven<TBucketMemberSlot>({ bucketCommands? })
1834
- new OIMSlotContentBucketServiceArrayDriven<TBucketMemberSlot, TSlots?>({ bucketCommands?, bucketArrayMembership? })
1835
- new OIMSlotContentBucketServiceSortedArrayDriven<TBucketMemberSlot>({ compareBucketMemberSlots })
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)
1836
1759
  ```
1837
1760
 
1838
1761
  A bucket holds bucket member slots of any slot type — entity slots, bucket slots, computeds — and its
@@ -1842,16 +1765,13 @@ of the collection service take that store slot first, and the bucket doors of th
1842
1765
  bucket slot.
1843
1766
 
1844
1767
  ```ts
1845
- const colourService = createOIMBucketService<TOIMEntitySlot<Card>, TOIMStoreKey, TOIMBucketSlotsSetStore<TOIMEntitySlot<Card>>,
1846
- OIMSlotContentBucketServiceSetDriven<TOIMEntitySlot<Card>>>(db, {
1847
- slotContentService: new OIMSlotContentBucketServiceSetDriven<TOIMEntitySlot<Card>>(),
1848
- });
1768
+ const colourService = createOIMSlotCollectionService({ slotContentService: new OIMSlotContentSetBucketService<TOIMEntitySlot<Card>>() });
1849
1769
  const colours = colourService.createStoreSlot(db);
1850
1770
  const red = colourService.holdSlotByKey(colours, 'red');
1851
- colourService.slotContentService.writeBucketMemberSlots(red, [slot, cardService.getSlotByKey(cards, 'c2')]);
1771
+ colourService.slotContentService.writeSlotContent(red, [slot, cardService.getSlotByKey(cards, 'c2')]);
1852
1772
  db.flush();
1853
1773
  for (const deckSlot of colourService.slotContentService.getBucketMemberSlots(red)) { paint(deckSlot.entity); }
1854
- colourService.slotContentService.clearBucketMemberSlots(red);
1774
+ colourService.slotContentService.clearSlotContent(red);
1855
1775
  red.owner.unholdSlot(red);
1856
1776
  ```
1857
1777
 
@@ -1859,23 +1779,24 @@ Every door below is the bucket content service's and takes the bucket slot:
1859
1779
 
1860
1780
  | Door | Signature |
1861
1781
  |---|---|
1862
- | `writeBucketMemberSlots(bucketSlot, bucketMemberSlots)` | `boolean` — states the whole bucket; idempotent; true when anything arrived or departed |
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 |
1863
1784
  | `addBucketMemberSlot(bucketSlot, bucketMemberSlot)` / `addBucketMemberSlots(bucketSlot, bucketMemberSlots)` | joins the named bucket member slots and touches nothing else. REMOVES NOTHING |
1864
1785
  | `removeBucketMemberSlot(bucketSlot, bucketMemberSlot)` / `removeBucketMemberSlots(bucketSlot, bucketMemberSlots)` | takes the named bucket member slots out and touches nothing else |
1865
- | `clearBucketMemberSlots(bucketSlot)` | empties it |
1786
+ | `clearSlotContent(bucketSlot)` | empties it |
1866
1787
  | `getBucketMemberSlots(bucketSlot)` | `Iterable<TBucketMemberSlot>` |
1867
1788
  | `getBucketMemberSlotCount(bucketSlot)` | `number` |
1868
- | `hasBucketMemberSlot(bucketSlot, bucketMemberSlot)` | `boolean` — the content service's own membership test: one `Set.has` on a set-driven bucket, the membership's `hasSlot` on an array-driven one |
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) |
1869
1790
  | `markBucketColumnsChanged(bucketSlot, patch, changedColumns)` | `void` — tell the bucket's column slots which member columns moved |
1870
1791
  | `markContentChanged(bucketSlot)` | `void` — re-settle emptiness and re-ask the release guard after a write made another way |
1871
- | `fillBucketMemberSlots(bucketSlot, bucketMemberSlots)` | `void` — fills a bucket slot nobody was told about yet; it throws on one that was already announced, which `addBucketMemberSlots` / `writeBucketMemberSlots` are for |
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 |
1872
1793
 
1873
1794
  The entities of a bucket by key are the index's door, `indexService.getEntitiesByKey(indexStoreSlot, key)`
1874
1795
  ([The index that fills itself](#the-index-that-fills-itself--oimindexservice)).
1875
1796
 
1876
1797
  ### Which door: you hold the answer, or you hold the delta
1877
1798
 
1878
- `writeBucketMemberSlots` is for a caller who has THE LIST THAT SHOULD BE THERE and does not
1799
+ `writeSlotContent` is for a caller who has THE LIST THAT SHOULD BE THERE and does not
1879
1800
  know what changed — a search result, a page from a server, a filter re-run. One
1880
1801
  call, and the engine works out the arrivals and the departures. Its cost is
1881
1802
  proportional to WHAT THE BUCKET HOLDS, not to the change: the difference builds a
@@ -1894,31 +1815,31 @@ the order you name them.
1894
1815
 
1895
1816
  **`addBucketMemberSlots` removes nothing.** If the authoritative list SHRANK and you call
1896
1817
  `addBucketMemberSlots`, the entity slots that left stay in the bucket, held. Not knowing what left is
1897
- exactly when `writeBucketMemberSlots` is the right door, and its cost is the price of not
1818
+ exactly when `writeSlotContent` is the right door, and its cost is the price of not
1898
1819
  knowing.
1899
1820
 
1900
1821
  The keyed doors are the collection service's and take the store slot first —
1901
1822
  `decksService.getSlotByKey(decks, key)`, `decksService.holdSlotByKey(decks, key)` — and a hold is given back
1902
- with `bucketSlot.owner.unholdSlot(bucketSlot)`. There is no by-key write: resolve the bucket slot, then
1903
- `decksService.slotContentService.writeBucketMemberSlots(bucketSlot, members)`.
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)`.
1904
1825
 
1905
1826
  A bucket's emptiness is the content service's `isSlotEmpty(bucketSlot)`, a member count of zero, so
1906
1827
  an emptied bucket that nothing holds and nothing watches is released by the
1907
1828
  guard on the same call that emptied it.
1908
1829
 
1909
1830
  **A bucket never sorts.** Its order is what the caller put in — `addBucketMemberSlots`
1910
- appends, `insertBucketMemberSlotAt` inserts, a move moves, `writeBucketMemberSlots` takes the
1831
+ appends, `insertBucketMemberSlotAt` inserts, a move moves, `writeSlotContent` takes the
1911
1832
  sequence given. A sorted list is a computed over an index:
1912
- [Sorted lists](#sorted-lists); or the bucket of a `createOIMIndexServiceSortedArrayDriven` index, which
1833
+ [Sorted lists](#sorted-lists); or the bucket of a `createOIMSlotCollectionSortedArrayIndexService` index, which
1913
1834
  orders itself: [Sorted at write](#sorted-at-write).
1914
1835
 
1915
- `writeBucketMemberSlots(bucketSlot, members)` runs `assertWritable` — the frame guard
1836
+ `writeSlotContent(bucketSlot, members)` runs `assertWritable` — the frame guard
1916
1837
  (`assertNotFlushing`) and the released-identity check — then the reconcile (an empty `arrived` and
1917
1838
  `departed` is an unchanged write, which wakes nobody), the holds taken on arrivals, the holds given back by
1918
1839
  departures and the single channel write of the bucket slot. It is handed a slot, so it hands back a
1919
1840
  `boolean`: true when anything arrived or departed.
1920
1841
 
1921
- `writeBucketMemberSlots` **joins before it evicts**, so a slot named by both the old and the
1842
+ `writeSlotContent` **joins before it evicts**, so a slot named by both the old and the
1922
1843
  new list never reaches zero references. It empties with one `drainSlots` rather
1923
1844
  than one removal at a time — per-slot eviction on an array-driven bucket is
1924
1845
  n(n-1)/2 element moves.
@@ -1939,7 +1860,7 @@ empty means nothing changed. Each content service reconciles its own way:
1939
1860
  `OIMBucketCommands` records onto the bucket slot (`bucket.commands`) as the writes land and
1940
1861
  empties it once the flush has fully ended, so a consumer reads it INSIDE the
1941
1862
  frame and what it reads can only describe the writes it was woken for.
1942
- `clearBucketMemberSlots` asks whether the bucket is empty **before** it
1863
+ `clearSlotContent` asks whether the bucket is empty **before** it
1943
1864
  touches the store, so an already-empty bucket records nothing for a call that
1944
1865
  reports nothing.
1945
1866
 
@@ -1947,33 +1868,20 @@ reports nothing.
1947
1868
 
1948
1869
  | Class | Storage type | One add records |
1949
1870
  |---|---|---|
1950
- | `OIMSlotContentBucketServiceSetDriven<TBucketMemberSlot>({ bucketCommands? })` | `TOIMBucketSlotsSetStore<TBucketMemberSlot>` = `Set<TBucketMemberSlot>` | one per slot |
1951
- | `OIMSlotContentBucketServiceArrayDriven<TBucketMemberSlot, TSlots>({ bucketCommands?, bucketArrayMembership? })` | whatever `bucketArrayMembership` builds | whatever `bucketArrayMembership` records |
1952
- | `OIMSlotContentBucketServiceSortedArrayDriven<TBucketMemberSlot>({ compareBucketMemberSlots })` | `TOIMBucketSlotsSortedArrayStore<TBucketMemberSlot>` | one per slot, at its sorted place |
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 |
1953
1874
 
1954
- The first two are the ones a caller picks between, and neither sorts: the array-driven service keeps the
1955
- caller's order. The third is the one the sorted-at-write index builds for itself
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
1956
1877
  ([Sorted at write](#sorted-at-write)); its `repositionBucketMemberSlot(bucketSlot, bucketMemberSlot)` moves one
1957
1878
  member to its place after its sort columns changed.
1958
1879
 
1959
- `bucketArrayMembership` defaults to `OIMBucketArrayMembershipNonUnique.INSTANCE` and is the fork between one
1960
- entry per mention and one per slot. There is no "ordered unique" class; you build one like this:
1961
-
1962
- new OIMSlotContentBucketServiceArrayDriven<
1963
- TOIMEntitySlot<Card>, TOIMBucketSlotsArrayUniqueStore<TOIMEntitySlot<Card>>
1964
- >({
1965
- bucketArrayMembership: new OIMBucketArrayMembershipUnique<TOIMEntitySlot<Card>>(),
1966
- })
1967
-
1968
- | Membership | Storage it builds | One add records |
1969
- |---|---|---|
1970
- | `OIMBucketArrayMembershipNonUnique.INSTANCE` (default) | `TOIMBucketSlotsArrayStore<TBucketMemberSlot>` | one per **mention** |
1971
- | `new OIMBucketArrayMembershipUnique<TBucketMemberSlot>()` | `TOIMBucketSlotsArrayUniqueStore<TBucketMemberSlot>` | one per slot |
1972
-
1973
- `IOIMBucketArrayMembership<TBucketMemberSlot, TSlots>`: `createStore`, `addSlot`, `removeSlot`,
1974
- `hasSlot`, `clearSlots`, `containsTheSameSequence`. `hasBucketMemberSlot` asks the membership's `hasSlot`: the
1975
- unique membership answers from its `Set` (no array walk), the non-unique one walks the
1976
- array. Only the array-driven service takes one — a set-driven service keeps its slots IN a `Set`.
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.
1977
1885
 
1978
1886
  Nothing positional is on the abstract `OIMSlotContentBucketService`: the reads `getBucketMemberSlotAt` and
1979
1887
  `getBucketMemberSlotPosition` are on the array-based services (array-driven and sorted), and the five
@@ -1993,8 +1901,8 @@ named constants:
1993
1901
  A removal answering the third is recorded as `recordWholeList(bucketSlot)`: a set has no index to patch,
1994
1902
  so the consumer is told to re-read the bucket rather than handed a fabricated one.
1995
1903
 
1996
- `OIM_NOTHING_WAS_INSERTED` (`-1`) is the insert's answer when nothing was inserted, which is a repeat on an
1997
- `OIMBucketArrayMembershipUnique`. The recorded insert names the index the slot LANDED on — `at` clamped to the
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
1998
1906
  ends of the list — and not `getBucketMemberSlotPosition`, because the default ordered list keeps one place per
1999
1907
  **mention**: inserting a slot the bucket already holds gives it a second position, and the position read
2000
1908
  names the FIRST. A command script built from it would tell the consumer to patch at an index the slot is not
@@ -2006,9 +1914,8 @@ error. **One content service instance may serve two bucket collections** — it
2006
1914
 
2007
1915
  ### Positions the caller decides
2008
1916
 
2009
- A bucket whose positions are the caller's is one over `OIMSlotContentBucketServiceArrayDriven`, which is
2010
- what `createOIMBucketService(db)` builds when it is given no content service — the `pageService` of the setup
2011
- block. The two position reads and the five writes are that content service's, they take the bucket
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
2012
1919
  slot and no store slot, and an index is valid only until the next write:
2013
1920
 
2014
1921
  | Door | What it does |
@@ -2029,7 +1936,7 @@ like any other out-of-range position.
2029
1936
  Each wraps a store change in the frame guard, the holds, the report and the
2030
1937
  release question. The default ordered list records one place per MENTION, so
2031
1938
  `insertBucketMemberSlotAt` of a slot the bucket already holds gives it a second position —
2032
- build the content service with an `OIMBucketArrayMembershipUnique` when a position must name
1939
+ take `bucketMemberSlotsArrayService: new OIMBucketUniqueMemberSlotsArrayService()` when a position must name
2033
1940
  exactly one entity slot.
2034
1941
 
2035
1942
  Per strategy, generating code:
@@ -2052,7 +1959,7 @@ slotContentBucketService.moveBucketMemberSlotToPosition(
2052
1959
  );
2053
1960
 
2054
1961
  // a whole list at once, in the order the server sent
2055
- slotContentBucketService.writeBucketMemberSlots(todo, cardService.slotContentService.upsertEntities(cards, payload));
1962
+ slotContentBucketService.writeSlotContent(todo, cardService.writeInBatch(cards, () => (payload).map(entity => cardService.writeSlotContentByKey(cards, entity.id, entity))));
2056
1963
  ```
2057
1964
 
2058
1965
  ### The render script: `OIMBucketCommands`
@@ -2061,14 +1968,13 @@ slotContentBucketService.writeBucketMemberSlots(todo, cardService.slotContentSer
2061
1968
  new OIMBucketCommands(options: { maxCommandsPerSlot?: number }) // default OIM_MAX_COMMANDS_PER_SLOT
2062
1969
 
2063
1970
  // a bucket content service takes it at construction, once:
2064
- new OIMSlotContentBucketServiceArrayDriven({ bucketCommands: new OIMBucketCommands({ maxCommandsPerSlot: Infinity }) });
2065
- // createOIMBucketService(db, { maxCommandsPerSlot }) builds the default content service with one
1971
+ new OIMSlotContentPositionalArrayBucketService({ bucketCommands: new OIMBucketCommands({ maxCommandsPerSlot: Infinity }) });
2066
1972
  ```
2067
1973
 
2068
1974
  **Recording is a strategy chosen when the bucket content service is built.** `bucketCommands` is read once
2069
- into a readonly field; left out of a content service built with `new`, it is `OIMBucketCommandsNone`, whose
1975
+ into a readonly field; left out of a content service built with `new`, it is `OIMNoBucketCommands`, whose
2070
1976
  every method is empty, so a bucket nobody renders positionally pays one empty call per write and allocates no
2071
- command. The default content service of `createOIMBucketService` records. There is no setter: a content
1977
+ command. There is no setter: a content
2072
1978
  service records into the one recorder it was built with. One recorder serves every bucket of every store slot
2073
1979
  and of every db — it is stateless.
2074
1980
 
@@ -2077,10 +1983,10 @@ content service already writes the bucket slot on every mutation, so a consumer
2077
1983
  reads the script inside the effect:
2078
1984
 
2079
1985
  ```ts
2080
- effectService.createEffect(db, [todo], [], () => {
1986
+ effectService.createEffect(db, () => {
2081
1987
  if (todo.isWholeListRecorded) render(Array.from(pageService.slotContentService.getBucketMemberSlots(todo)));
2082
1988
  else applyToTheRenderedList(todo.commands ?? []);
2083
- });
1989
+ }, [todo]);
2084
1990
  ```
2085
1991
 
2086
1992
  `TOIMBucketSlot` carries two fields for it, and a consumer reads both:
@@ -2103,8 +2009,8 @@ that lands after a flush but before that reset starts a new script instead of ex
2103
2009
  read. The reset is subscribed with `subscribeAfterEachFlush` on the first script a channel slot ever sees, and
2104
2010
  walks only the buckets that recorded.
2105
2011
 
2106
- A positional write is always recorded as a position. `isWholeListRecorded` is set by a write of the whole bucket — `writeBucketMemberSlots`,
2107
- `clearBucketMemberSlots`, `moveBucketMemberSlotRangeToPosition` — by a removal from a bucket whose
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
2108
2014
  content service keeps no positions, which has no index to patch, and by the script
2109
2015
  outgrowing the list
2110
2016
  it describes. `maxCommandsPerSlot` is that ratio; it defaults to
@@ -2123,43 +2029,43 @@ content service's own write doors do.
2123
2029
  ### A single bucket
2124
2030
 
2125
2031
  One bucket under no key is a store slot of an `OIMSingleSlotService` over a bucket content service, with
2126
- `OIMSlotStoreServiceSingleSlotDriven.INSTANCE` as the store service:
2032
+ `OIMSingleSlotStoreService.INSTANCE` as the store service:
2127
2033
 
2128
2034
  ```ts signature
2129
- createOIMSingleBucketService<TBucketMemberSlot = TOIMEntitySlot, TSlots?, TSlotContentService?>(db, {
2130
- slotContentService?, maxCommandsPerSlot?, slotReleasePolicy?, columnSlotStoreService?, columnNames?,
2131
- }?): OIMSingleSlotService<TOIMBucketSlot<TSlots, void>, TSlotContentService, …>
2035
+ createOIMSingleSlotService({
2036
+ slotContentService: new OIMSlotContentPositionalArrayBucketService<TBucketMemberSlot>(), // or the set one
2037
+ slotReleasePolicy?, slotSubscriptionListener?, columnSlotStoreService?, columnNames?,
2038
+ }): OIMWritableSingleSlotService<TOIMBucketContentSlot<TSlots>, readonly TBucketMemberSlot[], …>
2132
2039
  ```
2133
2040
 
2134
2041
  ```ts
2135
- const handService = createOIMSingleBucketService<TOIMEntitySlot<Card>>(db);
2042
+ const handService = createOIMSingleSlotService({ slotContentService: new OIMSlotContentPositionalArrayBucketService<TOIMEntitySlot<Card>>({ bucketCommands: new OIMBucketCommands({ maxCommandsPerSlot: undefined }) }) });
2136
2043
  const hand = handService.createStoreSlot(db);
2137
2044
  const handSlot = handService.holdSingleSlot(hand);
2138
- handService.slotContentService.writeBucketMemberSlots(handSlot, [slot]);
2045
+ handService.slotContentService.writeSlotContent(handSlot, [slot]);
2139
2046
  handService.slotContentService.insertBucketMemberSlotAt(handSlot, 0, cardService.getSlotByKey(cards, 'c2'));
2140
- effectService.disposeEffect(effectService.createEffect(db, [handSlot], [], () => {
2047
+ effectService.disposeEffect(effectService.createEffect(db, () => {
2141
2048
  render(Array.from(handService.slotContentService.getBucketMemberSlots(handSlot)));
2142
- }));
2049
+ }, [handSlot]));
2143
2050
  handSlot.owner.unholdSlot(handSlot);
2144
2051
  ```
2145
2052
 
2146
- One bucket, no key: the single-slot store files its one bucket slot under `undefined`. The bucket slot is a
2147
- plain `TOIMBucketSlot<TSlots, void>`, what an effect subscribes to and what a reader is handed,
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,
2148
2055
  so the channel's write door still sees three slot shapes. Every door of the bucket content service takes it as
2149
2056
  it takes a keyed bucket slot, and `markBucketColumnsChanged(bucketSlot, patch, changedColumns)` is how the
2150
2057
  caller that changed members says which of their columns moved, as the index does for a keyed bucket. Each
2151
- door opens its own subframe on the channel slot and coalesces the bucket's change to one channel write per
2152
- subframe.
2058
+ door writes the bucket slot to the channel once.
2153
2059
 
2154
2060
  **Its lifetime is the `slotReleasePolicy`.** With none named (here or on the db) it is
2155
- `OIMSlotReleasePolicyAtEmpty.INSTANCE`, so an emptied single bucket that nothing holds or watches is
2061
+ `OIMSlotAtEmptyReleasePolicy.INSTANCE`, so an emptied single bucket that nothing holds or watches is
2156
2062
  released, and `getSingleSlot(storeSlot)` mints a fresh, empty one on the next read. Hold the
2157
- slot (`holdSingleSlot(storeSlot)`) for as long as you write into it, or name `OIMSlotReleasePolicyNever.INSTANCE`.
2063
+ slot (`holdSingleSlot(storeSlot)`) for as long as you write into it, or name `OIMSlotNeverReleasePolicy.INSTANCE`.
2158
2064
 
2159
- ### The index that fills itself — `OIMIndexService`
2065
+ ### The index that fills itself — `OIMSlotCollectionIndexService`
2160
2066
 
2161
2067
  ```ts signature
2162
- createOIMIndexService<TEntity, TSourceKey, TColumns?, TKey?, TSourceStore?, TSlots?, TSlotContentService?>(db, {
2068
+ createOIMSlotCollectionIndexService<TEntity, TSourceKey, TColumns?, TKey?, TSourceStore?, TSlots?, TSlotContentService?>({
2163
2069
  entityService: Pick<OIMSlotCollectionService<TSourceKey, TOIMEntitySlot<TEntity, TSourceKey>, TSourceStore, unknown>, 'getSlots'>;
2164
2070
  ...TOIMIndexKeyOptions<TKey, TEntity, TColumns>; // { columns } | { keyOf } | { keyOf, columns }
2165
2071
  slotContentService?: OIMSlotContentBucketService<TOIMEntitySlot<TEntity>, TSlots>; // default: array-driven, no commands
@@ -2167,9 +2073,9 @@ createOIMIndexService<TEntity, TSourceKey, TColumns?, TKey?, TSourceStore?, TSlo
2167
2073
  slotReleasePolicy?: IOIMSlotReleasePolicy;
2168
2074
  columnSlotStoreService?: IOIMSlotStoreService<string, TOIMColumnSlot>;
2169
2075
  columnNames?: readonly string[];
2170
- }): OIMIndexService<TKey, TEntity, TSlots, TSourceKey, TSourceStore, TSlotContentService, …>
2076
+ }): OIMSlotCollectionIndexService<TKey, TEntity, TSlots, TSourceKey, TSourceStore, TSlotContentService, …>
2171
2077
 
2172
- class OIMIndexService<…> extends OIMSlotCollectionService<…> {
2078
+ class OIMSlotCollectionIndexService<…> extends OIMSlotCollectionService<…> {
2173
2079
  createIndexStoreSlot(sourceStoreSlot: TOIMStoreSlot<TSourceStore, TOIMEntitySlot<TEntity, TSourceKey>, Partial<TEntity>>): TOIMIndexStoreSlot<…>;
2174
2080
  disposeIndexStoreSlot(indexStoreSlot: TOIMIndexStoreSlot<…>): void;
2175
2081
  getEntitiesByKey(indexStoreSlot, key): (TEntity | undefined)[];
@@ -2180,12 +2086,12 @@ class OIMIndexService<…> extends OIMSlotCollectionService<…> {
2180
2086
  }
2181
2087
  ```
2182
2088
 
2183
- `OIMIndexService` is a collection service of buckets, so it has every read door of one, and its
2089
+ `OIMSlotCollectionIndexService` is a collection service of buckets, so it has every read door of one, and its
2184
2090
  `slotContentService` is the bucket content service narrowed by type to its reads (`getBucketMemberSlots`,
2185
2091
  `getBucketMemberSlotCount`, `hasBucketMemberSlot`, and the positional reads where the strategy has them) — the
2186
2092
  index is the only writer of its buckets. The strategy is chosen at the factory:
2187
- `OIMSlotContentBucketServiceArrayDriven` by default, `OIMSlotContentBucketServiceSetDriven` passed as
2188
- `slotContentService`, and the sorted one built by `createOIMIndexServiceSortedArrayDriven`. Only the array-based
2093
+ `OIMSlotContentPositionalArrayBucketService` by default, `OIMSlotContentSetBucketService` passed as
2094
+ `slotContentService`, and the sorted one built by `createOIMSlotCollectionSortedArrayIndexService`. Only the array-based
2189
2095
  content services answer by position: `getBucketMemberSlotAt(bucketSlot, index)` (O(1), `undefined` past the
2190
2096
  end) and `getBucketMemberSlotPosition(bucketSlot, entitySlot)` (walks the array, O(n), `-1` for a non-member).
2191
2097
  The index's default content service records no commands; pass one built with `bucketCommands` for a render
@@ -2201,34 +2107,34 @@ read through the index service, passing that store slot; the index is what write
2201
2107
  written before it, without a flush, so minting inside an effect body does not throw.
2202
2108
  `getSlotsByKeys` / `holdSlotsByKeys` fill every new bucket in ONE pass over the source. How long it lives after that is **the
2203
2109
  `slotReleasePolicy`'s ordinary business**, exactly as for a hand-held bucket; the index injects no lifetime of its
2204
- own. With none named it is `OIMSlotReleasePolicyAtEmpty.INSTANCE`: the bucket stands while it has members and is
2110
+ own. With none named it is `OIMSlotAtEmptyReleasePolicy.INSTANCE`: the bucket stands while it has members and is
2205
2111
  maintained on every write.
2206
2112
 
2207
2113
  ```ts
2208
- const byDeckService = createOIMIndexService(db, {
2114
+ const byDeckService = createOIMSlotCollectionIndexService({
2209
2115
  entityService: cardService,
2210
2116
  keyOf: (card: Card) => card.deck,
2211
2117
  });
2212
2118
  const byDeck = byDeckService.createIndexStoreSlot(cards);
2213
2119
 
2214
2120
  const spades = byDeckService.holdSlotByKey(byDeck, 'spades');
2215
- effectService.createEffect(db, [spades], [], () => render(Array.from(byDeckService.slotContentService.getBucketMemberSlots(spades))));
2121
+ effectService.createEffect(db, () => render(Array.from(byDeckService.slotContentService.getBucketMemberSlots(spades))), [spades]);
2216
2122
  ```
2217
2123
 
2218
- A set-driven index names its content service, whose bucket member slot type is spelled out —
2219
- `new OIMSlotContentBucketServiceSetDriven()` infers `<TOIMSlot>` and fails to type-check:
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:
2220
2126
 
2221
2127
  ```ts
2222
- const bySuitService = createOIMIndexService(db, {
2128
+ const bySuitService = createOIMSlotCollectionIndexService({
2223
2129
  entityService: cardService,
2224
2130
  columns: ['deck'],
2225
- slotContentService: new OIMSlotContentBucketServiceSetDriven<TOIMEntitySlot<Card>>(),
2131
+ slotContentService: new OIMSlotContentSetBucketService<TOIMEntitySlot<Card>, string>(),
2226
2132
  });
2227
2133
  const bySuit = bySuitService.createIndexStoreSlot(cards);
2228
2134
  ```
2229
2135
 
2230
2136
  - **Passing the entity store slot to `createIndexStoreSlot` is the whole wiring.** It registers
2231
- a content-change listener on that store slot (`OIMContentChangeListenerService.INSTANCE.addContentChangeListener`);
2137
+ a content-change listener on that store slot (`OIMSlotContentChangeListenerService.INSTANCE.addContentChangeListener`);
2232
2138
  the entity services know nothing about indexes. Call `createIndexStoreSlot` twice over one entity
2233
2139
  store slot and it files into both. `entityService` is used only for its `getSlots`, to fill a new bucket
2234
2140
  from the source.
@@ -2240,17 +2146,15 @@ const bySuit = bySuitService.createIndexStoreSlot(cards);
2240
2146
  ones `keyOf` reads. With `columns`, a patch or put whose changed columns include none of
2241
2147
  them keeps the entity's key and calls nothing; a write of the whole value (a fill, a
2242
2148
  clear, `markEntityChanged(slot)` with no names) always reads the key again.
2243
- - **A key of several columns** takes its own door: `createOIMIndexServiceByCompositeKey(db,
2244
- { entityService, columns: ['deck', 'suit'] })` makes the key the array of those values in order, typed as a
2245
- tuple. It builds the bucket store on `OIMSlotStoreServiceTreeDriven` over
2246
- `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()`, and only that store knows the key is an array (its
2247
- `isSameKey`), so a fresh array with the same parts moves nothing. `createOIMIndexService` takes a scalar key
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
2248
2153
  unless it is handed a `bucketSlotStoreService`: two columns or an array `keyOf` without one do not compile.
2249
2154
  `getSlotsByKeys` / `holdSlotsByKeys` fill array-keyed buckets one pass over the
2250
2155
  source per key (the cost of `getSlotByKey` each); only a scalar key gets one pass for all.
2251
- - **Entity slots are filed on write**, using the index's key. Batch doors (`upsertEntities`,
2252
- `insertEntities`/`fillEntities`, `deleteEntities`, `clearAllEntities`) notify once per
2253
- batch; the index groups the entity slots by key and moves each group with one bucket write.
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.
2254
2158
  - **Reading**, each taking the index store slot first: `getSlotByKey`, `holdSlotByKey`, `findSlotByKey`,
2255
2159
  `hasSlotContentByKey`, `getSlotsByKeys`, `holdSlotsByKeys`, `getSlots`, `getSlotCount`, `getEntitiesByKey`, and the bucket column
2256
2160
  slot doors `getAnyBucketColumnByKey`, `holdAnyBucketColumnByKey`,
@@ -2274,9 +2178,9 @@ const bySuit = bySuitService.createIndexStoreSlot(cards);
2274
2178
  is maintained whether anyone holds it or not.
2275
2179
  - **Order is not the bucket's business, unless you pick the strategy that makes it so.**
2276
2180
  A bucket keeps its arrival order; a sorted list is
2277
- `createOIMComputedServiceSorted(db, { indexService, sortColumns?, comparator? })`
2181
+ `createOIMSortedComputedService(db, { indexService, sortColumns?, comparator? })`
2278
2182
  — see [Sorted lists](#sorted-lists) — or the index is built with
2279
- `createOIMIndexServiceSortedArrayDriven`, whose buckets are already in order — see
2183
+ `createOIMSlotCollectionSortedArrayIndexService`, whose buckets are already in order — see
2280
2184
  [Sorted at write](#sorted-at-write). A second computed over the bucket handles
2281
2185
  projection or filtering.
2282
2186
  - **Members are read through the bucket content service**: `Array.from(indexService.slotContentService.getBucketMemberSlots(bucketSlot))`.
@@ -2286,9 +2190,9 @@ const bySuit = bySuitService.createIndexStoreSlot(cards);
2286
2190
  membership only**: an entity slot joining or leaving. A write to an entity that stays in the
2287
2191
  bucket does not wake it — subscribe to that entity's own slot for that.
2288
2192
  - **Bucket lifetime is the `slotReleasePolicy` option, not the index's.** Name nothing (here or on the db)
2289
- and it is `OIMSlotReleasePolicyAtEmpty.INSTANCE`: an unheld bucket stays while it has members and goes
2290
- once empty; `OIMSlotReleasePolicyNever.INSTANCE` keeps it for good; pass an `OIMSlotReleasePolicyAfterIdle`
2291
- and it cools on its queue; pass `OIMSlotReleasePolicyAtUnhold.INSTANCE` and it dies at the last give-back,
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,
2292
2196
  and the next hold builds a fresh one by walking the source again.
2293
2197
 
2294
2198
  **The bucket's any-column slot exists, but it is not the recommended path.**
@@ -2299,15 +2203,15 @@ the bucket slot (`getSlotByKey` / `holdSlotByKey`). A reader that wants both sub
2299
2203
  to both, and the subscriber picks by which slots it puts in `createEffect`.
2300
2204
 
2301
2205
  ```ts
2302
- const deckIndexService = createOIMIndexService(db, { entityService: cardService, keyOf: (card: Card) => card.deck });
2206
+ const deckIndexService = createOIMSlotCollectionIndexService({ entityService: cardService, keyOf: (card: Card) => card.deck });
2303
2207
  const deckIndex = deckIndexService.createIndexStoreSlot(cards);
2304
2208
  const spadesBucket = deckIndexService.holdSlotByKey(deckIndex, 'spades');
2305
2209
  const spadesAnyColumn = deckIndexService.holdAnyBucketColumnByKey(deckIndex, 'spades');
2306
- const entitiesEffect = effectService.createEffect(db, [spadesBucket, spadesAnyColumn], [], () => {
2210
+ const entitiesEffect = effectService.createEffect(db, () => {
2307
2211
  console.log(deckIndexService.getEntitiesByKey(deckIndex, 'spades'));
2308
- });
2212
+ }, [spadesBucket, spadesAnyColumn]);
2309
2213
  effectService.disposeEffect(entitiesEffect);
2310
- deckIndexService.unholdColumn(spadesAnyColumn);
2214
+ deckIndexService.columnSlotService.unholdColumn(spadesAnyColumn);
2311
2215
  spadesBucket.owner.unholdSlot(spadesBucket);
2312
2216
  ```
2313
2217
 
@@ -2324,7 +2228,7 @@ needs the whole list each time: an export, an aggregate over the entities, a log
2324
2228
  It costs nothing until someone asks for one: a write is checked against it only after
2325
2229
  the index has been asked for the first any-column slot.
2326
2230
 
2327
- **The content-change hook.** `OIMContentChangeListenerService.INSTANCE.addContentChangeListener(storeSlot, listener, readColumnNames): void`
2231
+ **The content-change hook.** `OIMSlotContentChangeListenerService.INSTANCE.addContentChangeListener(storeSlot, listener, readColumnNames): void`
2328
2232
  and `removeContentChangeListener(storeSlot, listener): boolean` file and unfile a listener on that one store
2329
2233
  slot, by the listener object. `addReadColumn` / `removeReadColumn` / `addReadAnyColumn` / `removeReadAnyColumn`
2330
2234
  declare reads without a listener of their own, which is how an index declares its bucket columns.
@@ -2339,19 +2243,17 @@ no listener is called, nothing is buffered, and the names array is reused across
2339
2243
  every column write is added with `readColumnNames` `undefined`. **The filter is the union, per store
2340
2244
  slot, not per listener**: once a write touches a column ANY listener of that store reads, every
2341
2245
  listener of that store is called, so a listener that declared a narrower set still checks
2342
- `changedColumns` itself, as `OIMIndexService` does against its key columns.
2343
- `IOIMContentChangeListener<TSlot, TPatch>` has two methods:
2246
+ `changedColumns` itself, as `OIMSlotCollectionIndexService` does against its key columns.
2247
+ `IOIMSlotContentChangeListener<TSlot, TPatch>` has two methods:
2344
2248
  `onContentChanged(slot, patch, changedColumns)` and
2345
- `onContentChangedMany(slotChannelQueue, patchChannelQueue, changedColumnsChannelQueue)` — three
2249
+ `onManyContentsChanged(slotChannelQueue, patchChannelQueue, changedColumnsChannelQueue)` — three
2346
2250
  parallel `OIMChannelQueue`s read with `getEntryCount()` / `getEntryAt(i)`, valid only during the
2347
2251
  call, so a listener copies what it keeps — `patch` is what an update
2348
2252
  wrote, `changedColumns` the names `markEntityChanged(slot, columns)` was given, either
2349
- `undefined` when the write carries none. A content service holds a store slot's notifications until ITS
2350
- outermost content subframe closes: a single write delivers one `onContentChanged` when its own
2351
- subframe closes, and a many-entity door (`upsertEntities`, `insertEntities`, `fillEntities`,
2352
- `deleteEntities`, `clearAllEntities`) delivers ONE
2353
- `onContentChangedMany` when the door returns. A subframe the caller opens on the channel slot
2354
- does not hold them — an index is up to date as soon as the content service's own door returns.
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.
2355
2257
  This hook is the only seam an index uses; an index of your own is a listener.
2356
2258
 
2357
2259
  **Bucket column slots.** An index hands out one column slot per bucket and column:
@@ -2365,24 +2267,24 @@ return the same kind of slot, written when any column of any member changes. A g
2365
2267
  `slot.owner.unholdSlot(slot)`.
2366
2268
 
2367
2269
  ```ts
2368
- const decksService = createOIMIndexService(db, { entityService: cardService, keyOf: (card: Card) => card.deck });
2270
+ const decksService = createOIMSlotCollectionIndexService({ entityService: cardService, keyOf: (card: Card) => card.deck });
2369
2271
  const decks = decksService.createIndexStoreSlot(cards);
2370
2272
  const rankOfSpades = decksService.holdBucketColumnByKey(decks, 'spades', 'rank');
2371
- effectService.disposeEffect(effectService.createEffect(db, [rankOfSpades], [], () => {}));
2372
- decksService.unholdColumn(rankOfSpades);
2273
+ effectService.disposeEffect(effectService.createEffect(db, () => {}, [rankOfSpades]));
2274
+ decksService.columnSlotService.unholdColumn(rankOfSpades);
2373
2275
  ```
2374
2276
 
2375
2277
  ### Sorted lists
2376
2278
 
2377
2279
  ```ts signature
2378
- createOIMComputedServiceSorted<TKey, TEntity extends object, TSlots, TSourceKey, TSourceStore, TComputedStore?>({
2379
- indexService: OIMIndexService<TKey, TEntity, TSlots, TSourceKey, TSourceStore>;
2380
- sortColumns?: readonly TOIMSortColumn<TEntity>[]; // { column: TOIMSortableColumnName<TEntity>; direction: 'asc' | 'desc' }
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' }
2381
2283
  comparator?: (left: TEntity, right: TEntity) => number;
2382
- slotReleasePolicy?: IOIMSlotReleasePolicy; // default OIMSlotReleasePolicyAtUnhold.INSTANCE
2284
+ slotReleasePolicy?: IOIMSlotReleasePolicy; // default OIMSlotAtUnholdReleasePolicy.INSTANCE
2383
2285
  columnSlotStoreService?: IOIMSlotStoreService<string, TOIMComputedColumnSlot>;
2384
2286
  computedSlotStoreService?: IOIMSlotStoreService<TKey, TOIMComputedSlot<…>, TComputedStore>; // Map; REQUIRED for any other store
2385
- }): OIMComputedServiceSorted<TKey, TEntity, TSlots, TSourceKey, TSourceStore, TComputedStore>
2287
+ }): OIMSortedComputedService<TKey, TEntity, TSlots, TSourceKey, TSourceStore, TComputedStore>
2386
2288
 
2387
2289
  createSortedStoreSlot(indexStoreSlot: TOIMIndexStoreSlot<…>): TOIMStoreSlot<TComputedStore, TOIMSortedComputedSlot<TEntity, TKey>>
2388
2290
  readonly computedCollectionService: OIMSlotCollectionService<TKey, TOIMSortedComputedSlot<TEntity, TKey>, TComputedStore, …>
@@ -2390,11 +2292,11 @@ readonly computedCollectionService: OIMSlotCollectionService<TKey, TOIMSortedCom
2390
2292
 
2391
2293
  `slotReleasePolicy` decides whether a key's sorted list outlives its last hold. The
2392
2294
  default lets it go at the unhold, so holding the key again sorts from scratch;
2393
- `OIMSlotReleasePolicyNever.INSTANCE` keeps every key that was ever read and re-sorts it
2295
+ `OIMSlotNeverReleasePolicy.INSTANCE` keeps every key that was ever read and re-sorts it
2394
2296
  only when a read finds it changed — the arm for a view that switches between groups.
2395
2297
  Pass the same policy to the index the list reads, or its bucket is rebuilt instead.
2396
2298
 
2397
- An `OIMComputedServiceSorted` takes no db: its store slots run on the channel slot of the index store slot
2299
+ An `OIMSortedComputedService` takes no db: its store slots run on the channel slot of the index store slot
2398
2300
  they sort. One per index service. `createSortedStoreSlot(indexStoreSlot)` makes one sorted store slot over
2399
2301
  one index store slot, and the read doors of `computedCollectionService` take that store slot first:
2400
2302
  `getSlotByKey`, `holdSlotByKey`, `findSlotByKey`, `getSlots`, `getSlotCount`, and the column doors; per key a
@@ -2420,13 +2322,13 @@ slots sorted. A hold is given back with `slot.owner.unholdSlot(slot)`.
2420
2322
  to a member as with `comparator` alone.
2421
2323
 
2422
2324
  ```ts
2423
- const cardsInDeckService = createOIMIndexService(db, {
2325
+ const cardsInDeckService = createOIMSlotCollectionIndexService({
2424
2326
  entityService: cardService,
2425
2327
  keyOf: (card: Card) => card.deck,
2426
2328
  });
2427
- createOIMComputedServiceSorted({
2329
+ createOIMSortedComputedService({
2428
2330
  indexService: cardsInDeckService,
2429
- sortColumns: [{ column: 'title', direction: 'asc' }, { column: 'rank', direction: 'asc' }],
2331
+ sortColumns: [{ columnName: 'title', direction: 'asc' }, { columnName: 'rank', direction: 'asc' }],
2430
2332
  comparator: (l, r) => l.title.localeCompare(r.title, 'de') || l.rank - r.rank,
2431
2333
  }).createSortedStoreSlot(cardsInDeckService.createIndexStoreSlot(cards));
2432
2334
  // a write to `title` or `rank` re-sorts; a write to `note` does not
@@ -2439,41 +2341,41 @@ SECOND array of `createEffect`. A re-sort that yields the same sequence
2439
2341
  (`oimSameSlotSequence`) keeps the previous array, so its identity is stable.
2440
2342
 
2441
2343
  ```ts
2442
- const decksService = createOIMIndexService(db, { entityService: cardService, keyOf: (card: Card) => card.deck });
2344
+ const decksService = createOIMSlotCollectionIndexService({ entityService: cardService, keyOf: (card: Card) => card.deck });
2443
2345
  const decks = decksService.createIndexStoreSlot(cards);
2444
- const byRankInDeckService = createOIMComputedServiceSorted({
2346
+ const byRankInDeckService = createOIMSortedComputedService({
2445
2347
  indexService: decksService,
2446
- sortColumns: [{ column: 'rank', direction: 'asc' }],
2348
+ sortColumns: [{ columnName: 'rank', direction: 'asc' }],
2447
2349
  });
2448
2350
  const byRankInDeck = byRankInDeckService.createSortedStoreSlot(decks);
2449
2351
  const spadesByRank = byRankInDeckService.computedCollectionService.holdSlotByKey(byRankInDeck, 'spades');
2450
- const sortedEffect = effectService.createEffect(db, [], [spadesByRank], () => {
2352
+ const sortedEffect = effectService.createEffect(db, () => {
2451
2353
  render(spadesByRank.computedValueService.getValueForSlot(spadesByRank));
2452
- });
2354
+ }, [], [spadesByRank]);
2453
2355
  effectService.disposeEffect(sortedEffect);
2454
2356
  spadesByRank.owner.unholdSlot(spadesByRank);
2455
2357
  ```
2456
2358
 
2457
2359
  ### Sorted at write
2458
2360
 
2459
- A second way to get a sorted list. `createOIMComputedServiceSorted` sorts at read, over any
2460
- index. `createOIMIndexServiceSortedArrayDriven` keeps the index's own buckets sorted at write, and
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
2461
2363
  the user picks one at construction.
2462
2364
 
2463
2365
  ```ts signature
2464
- createOIMIndexServiceSortedArrayDriven<TEntity extends object, TSourceKey, TColumns?, TKey extends TOIMStoreKey?, TSourceStore?, TStore?>(db, {
2465
- entityService, ...TOIMIndexKeyOptions<TKey, TEntity, TColumns>, // as createOIMIndexService, scalar key
2366
+ createOIMSlotCollectionSortedArrayIndexService<TEntity extends object, TSourceKey, TColumns?, TKey extends TOIMStoreKey?, TSourceStore?, TStore?>({
2367
+ entityService, ...TOIMIndexKeyOptions<TKey, TEntity, TColumns>, // as createOIMSlotCollectionIndexService, scalar key
2466
2368
  sortColumns: readonly TOIMSortColumn<TEntity>[]; // REQUIRED
2467
2369
  bucketSlotStoreService?, slotReleasePolicy?, columnSlotStoreService?, columnNames?,
2468
- }): OIMIndexServiceSortedArrayDriven<…> // its slotContentService is an OIMSlotContentBucketServiceSortedArrayDriven
2370
+ }): OIMSlotCollectionSortedArrayIndexService<…> // its slotContentService is an OIMSlotContentSortedArrayBucketService
2469
2371
  ```
2470
2372
 
2471
2373
  ```ts
2472
2374
  declare const someCardSlot: TOIMEntitySlot<Card, TOIMStoreKey>;
2473
- const cardsByDeckService = createOIMIndexServiceSortedArrayDriven(db, {
2375
+ const cardsByDeckService = createOIMSlotCollectionSortedArrayIndexService({
2474
2376
  entityService: cardService,
2475
2377
  columns: ['deck'],
2476
- sortColumns: [{ column: 'rank', direction: 'asc' }, { column: 'id', direction: 'desc' }],
2378
+ sortColumns: [{ columnName: 'rank', direction: 'asc' }, { columnName: 'id', direction: 'desc' }],
2477
2379
  });
2478
2380
  const cardsByDeck = cardsByDeckService.createIndexStoreSlot(cards);
2479
2381
  const spades = cardsByDeckService.holdSlotByKey(cardsByDeck, 'spades');
@@ -2483,26 +2385,26 @@ cardsByDeckService.slotContentService.getBucketMemberSlotPosition(spades, someCa
2483
2385
  ```
2484
2386
 
2485
2387
  - **Reads come back in order**: `getBucketMemberSlots`, `getBucketMemberSlotAt` and
2486
- `getBucketMemberSlotPosition` on `OIMSlotContentBucketServiceSortedArrayDriven` answer in `sortColumns` order,
2388
+ `getBucketMemberSlotPosition` on `OIMSlotContentSortedArrayBucketService` answer in `sortColumns` order,
2487
2389
  so no sorted computed is needed.
2488
2390
  - **A lazy insertion buffer.** A write appends the arrival to an unsorted tail of the bucket's
2489
- array (`TOIMBucketSlotsSortedArrayStore`: the array store plus `sortedCount`). The first read
2391
+ array (`TOIMBucketSlotsArrayStore`: the array store plus `sortedCount`). The first read
2490
2392
  settles the tail: binary insertion when the tail is short, one sort of the array when it is
2491
2393
  long. A bucket nobody reads is never sorted.
2492
2394
  - **The index re-files a member when one of its sort columns changes, and only then.** A write
2493
2395
  to a column that is neither the key nor a sort column neither reorders the bucket nor wakes it.
2494
2396
  A re-file wakes the bucket once per frame.
2495
2397
  - **There are no positional writes** on this bucket (`insertBucketMemberSlotAt` and the other
2496
- position writes belong to `OIMSlotContentBucketServiceArrayDriven`): the order is the comparator's,
2398
+ position writes belong to `OIMSlotContentPositionalArrayBucketService`): the order is the comparator's,
2497
2399
  not the caller's.
2498
- - **Bucket commands are always `OIMBucketCommandsNone`**: this strategy records no per-flush
2400
+ - **Bucket commands are always `OIMNoBucketCommands`**: this strategy records no per-flush
2499
2401
  script.
2500
- - **Parts**: `OIMSlotContentBucketServiceSortedArrayDriven` (options
2501
- `TOIMSlotContentBucketServiceSortedArrayDrivenOptions`: `compareBucketMemberSlots`; adds
2502
- `repositionBucketMemberSlot(bucketSlot, bucketMemberSlot)`), `OIMIndexServiceSortedArrayDriven`.
2402
+ - **Parts**: `OIMSlotContentSortedArrayBucketService` (options
2403
+ `TOIMSlotContentSortedArrayBucketServiceOptions`: `compareBucketMemberSlots`; adds
2404
+ `repositionBucketMemberSlot(bucketSlot, bucketMemberSlot)`), `OIMSlotCollectionSortedArrayIndexService`.
2503
2405
 
2504
2406
  **Choosing between the two arms.** Sort-at-write suits point updates: a change to a sort
2505
- column re-files one member. Sort-at-read (`createOIMComputedServiceSorted` over any index)
2407
+ column re-files one member. Sort-at-read (`createOIMSortedComputedService` over any index)
2506
2408
  suits bulk loads. Both are shipped; neither is a default.
2507
2409
 
2508
2410
  **`indexKeys` on each entity slot.** Each indexable slot carries, per index store slot, the key the entity is
@@ -2510,15 +2412,15 @@ filed under now, so a moved entity slot leaves its old bucket in O(1), also unde
2510
2412
  writer. Only the index writes it, through the `IOIMIndexKeysService` (`get(slot, position)`,
2511
2413
  `set(slot, position, indexKey)`), at the position `createIndexStoreSlot` allocated
2512
2414
  (`indexStoreSlot.indexKeysPosition`). Positions are allocated per entity store slot
2513
- (`sourceStoreSlot.indexKeysPositions`). Pick the strategy once per db, `new OIMDB({ indexKeysService })`
2514
- (default `OIMDefaults.INDEX_KEYS_SERVICE.getValue()`):
2415
+ (`sourceStoreSlot.indexKeysPositions`). Pick the strategy with `new OIMDefaults({ indexKeysService })` passed as `defaults`
2416
+ (default `OIMAppendingIndexKeysService.INSTANCE`):
2515
2417
 
2516
2418
  | strategy | on `disposeIndexStoreSlot` | cost |
2517
2419
  |---|---|---|
2518
- | `OIMIndexKeysServiceAppending` (default) | the index's position is never reused; its stale entry stays on each entity slot | an entity slot's `indexKeys` grows to the highest position any index wrote on it (K indexes made and disposed → an array of K on an entity slot the last one filed), and a stale entry keeps its key reachable. Writes pay nothing for a disposed index: only live listeners are kept |
2519
- | `OIMIndexKeysServiceReclaiming` | wipes that position on every entity slot of the source store slot and reuses it | O(N) per dispose |
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 |
2520
2422
 
2521
- Switch to `OIMIndexKeysServiceReclaiming` only if you create indexes dynamically and often. Creating
2423
+ Switch to `OIMReclaimingIndexKeysService` only if you create indexes dynamically and often. Creating
2522
2424
  indexes dynamically and often is expensive, so avoid it.
2523
2425
 
2524
2426
  ### The channel of a bucket store slot
@@ -2540,7 +2442,7 @@ type TOIMComputedSources = {
2540
2442
  In a `createSources` result (of `createOIMComputedService`'s options, or of `createStoreSlot`) both `sourceSlots` and `sourceComputeds` are optional; a missing one means none.
2541
2443
 
2542
2444
  ```ts signature
2543
- type TOIMComputedSlot<TValue = unknown, TKey = unknown> = TOIMIndexableSlot<TKey> & Readonly<TOIMDerivation> & {
2445
+ type TOIMComputedSlot<TValue = unknown, TKey = unknown> = TOIMIndexableSlot<TKey> & Readonly<TOIMComputedDerivation> & {
2544
2446
  readonly owner: IOIMSlotOwner;
2545
2447
  readonly computedValueService: IOIMComputedValueService<TValue>;
2546
2448
  readonly value: TValue;
@@ -2581,22 +2483,30 @@ the next read or hold.
2581
2483
  class OIMComputedService<TKey, TValue, TComputedStore> {
2582
2484
  constructor(options: TOIMComputedServiceOptions<TKey, TValue, TComputedStore>);
2583
2485
  readonly computedCollectionService: OIMSlotCollectionService<TKey, TOIMComputedSlot<TValue, TKey>, TComputedStore,
2584
- OIMComputedValueService<TKey, TValue>, Extract<keyof TValue, string>, TOIMComputedColumnSlot, OIMColumnOwnerPullDriven>;
2486
+ OIMComputedValueService<TKey, TValue>, Extract<keyof TValue, string>, TOIMComputedColumnSlot, OIMPullColumnSlotOwner>;
2585
2487
  createComputedStoreSlot(channelMap: IOIMChannelMap, derive?, createSources?): TOIMComputedStoreSlot<TComputedStore, TValue, TKey>;
2586
2488
  }
2587
2489
 
2588
2490
  createOIMComputedService<TKey, TValue, TComputedStore = Map<TKey, TOIMComputedSlot<TValue, TKey>>>({
2589
- createSources?: (key: TKey, storeSlot: TOIMStoreSlot | undefined) => Partial<TOIMComputedSources>;
2491
+ createSources?: (computedSlot: TOIMComputedSlot<TValue, TKey>) => Partial<TOIMComputedSources>;
2590
2492
  derive?: (computedSlot: TOIMComputedSlot<TValue, TKey>) => TValue;
2591
2493
  sameValueGuard?: TOIMSameValueGuard<TValue>; // default Object.is
2494
+ releaseValue?: TOIMComputedValueReleaser<TValue>; // default: gives back nothing
2592
2495
  initialValue?: TValue;
2593
- slotReleasePolicy?: IOIMSlotReleasePolicy; // default OIMSlotReleasePolicyAtUnhold.INSTANCE
2594
- columnSlotStoreService?: IOIMSlotStoreService<string, TOIMComputedColumnSlot>; // OIMDefaults.COMPUTED_COLUMN_SLOT_STORE_SERVICE
2496
+ slotReleasePolicy?: IOIMSlotReleasePolicy; // default OIMSlotAtUnholdReleasePolicy.INSTANCE
2497
+ columnSlotStoreService?: IOIMSlotStoreService<string, TOIMComputedColumnSlot>; // defaults.computedColumnSlotStoreService, then a shared map store
2595
2498
  columnNames?: readonly Extract<keyof TValue, string>[];
2596
2499
  computedSlotStoreService?: IOIMSlotStoreService<TKey, TOIMComputedSlot<TValue, TKey>, TComputedStore>; // Map; REQUIRED for any other store
2597
2500
  }): OIMComputedService<TKey, TValue, TComputedStore>
2598
2501
  ```
2599
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
+
2600
2510
  The factory takes no db: a computed service is configuration, and each store slot takes its channel slot at
2601
2511
  `createComputedStoreSlot(db, …)`.
2602
2512
 
@@ -2610,15 +2520,13 @@ Doors of a keyed computed store slot are the read doors of `computedCollectionSe
2610
2520
  `getSlots(storeSlot)`, `getSlotCount(storeSlot)`, and the column doors; the value is
2611
2521
  `slot.computedValueService.getValueForSlot(slot)`, and a give-back is `slot.owner.unholdSlot(slot)`.
2612
2522
 
2613
- **One service, both computed shapes.** A keyed store slot and a single one — a store slot of a
2614
- `void`-keyed service over `OIMSlotStoreServiceSingleSlotDriven.INSTANCE` — outlive every computed slot they
2615
- mint, and mint a fresh one on the next read or hold once the previous one is gone. There is no
2616
- single-computed class and no single-computed door: the single one is read by key `undefined`.
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.
2617
2527
 
2618
2528
  ```ts
2619
- const topRankService = createOIMComputedService<void, number, TOIMSingleSlotStore<TOIMComputedSlot<number, void>>>({
2620
- computedSlotStoreService:
2621
- OIMSlotStoreServiceSingleSlotDriven.INSTANCE as unknown as OIMSlotStoreServiceSingleSlotDriven<TOIMComputedSlot<number, void>>,
2529
+ const topRankService = createOIMSingleComputedService<number>({
2622
2530
  sameValueGuard: oimSameValueZeroGuard,
2623
2531
  initialValue: -1,
2624
2532
  });
@@ -2627,18 +2535,16 @@ const topRank = topRankService.createComputedStoreSlot(
2627
2535
  () => cardService.getSlotByKey(cards, 'c1').entity?.rank ?? -1,
2628
2536
  () => ({ sourceSlots: [cardService.getSlotByKey(cards, 'c1')] }),
2629
2537
  );
2630
- const heldTopRank = topRankService.computedCollectionService.holdSlotByKey(topRank, undefined); // the same slot while anything holds it
2538
+ const heldTopRank = topRankService.computedSingleSlotService.holdSingleSlot(topRank); // the same slot while anything holds it
2631
2539
  heldTopRank.computedValueService.getValueForSlot(heldTopRank);
2632
- heldTopRank.owner.unholdSlot(heldTopRank); // gone; the next holdSlotByKey(topRank, undefined) mints a new one
2540
+ heldTopRank.owner.unholdSlot(heldTopRank); // gone; the next holdSingleSlot(topRank) mints a new one
2633
2541
  ```
2634
2542
 
2635
- A single computed store slot has one key, `undefined`, and a store of one slot. `getSlotByKey(storeSlot,
2636
- undefined)` hands a slot without a hold — a fresh one goes to the unused-slot collector and is let go at the
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
2637
2544
  next flush unless something holds it — and `holdSlotByKey(storeSlot, undefined)` holds the live slot, minting
2638
2545
  one if the previous is gone. A computed slot goes at its last unhold once no effect reads it
2639
- (`OIMSlotReleasePolicyAtUnhold`, the default), and gives back the holds it took on its
2640
- sources. A last unhold during a flush releases at the end of that flush, through the
2641
- db's unused-slot collector, as for every non-empty slot. The holds a computed takes on its sources and
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
2642
2548
  gives back are one stateless service, shared by both shapes.
2643
2549
 
2644
2550
  **`createSources` takes the key and the store slot, and nothing else. There is no `keep`.** A build that
@@ -2650,11 +2556,11 @@ list, and a source a build closes over without naming is a source nothing holds.
2650
2556
  **A derivation is never empty.** `OIMComputedService` answers
2651
2557
  "empty" with `false`, so a filed derivation is released only when nothing holds
2652
2558
  it, nothing watches it, and the grace is `0` — which is what
2653
- `OIMSlotReleasePolicyAtUnhold` answers, and the default when no `slotReleasePolicy` is given.
2559
+ `OIMSlotAtUnholdReleasePolicy` answers, and the default when no `slotReleasePolicy` is given.
2654
2560
 
2655
2561
  ```ts setup
2656
2562
  const rankService = createOIMComputedService<TOIMStoreKey, number>({
2657
- createSources: (id: TOIMStoreKey) => ({ sourceSlots: [cardService.getSlotByKey(cards, id)] }),
2563
+ createSources: ({ key }) => ({ sourceSlots: [cardService.getSlotByKey(cards, key)] }),
2658
2564
  derive: (computedSlot) => cardService.getSlotByKey(cards, computedSlot.key).entity?.rank ?? -1,
2659
2565
  sameValueGuard: oimSameValueZeroGuard,
2660
2566
  initialValue: -1,
@@ -2681,41 +2587,38 @@ over the carrier computed, its value `carrier.value[column]` (a tuple of the gro
2681
2587
  the whole value for the any-column), compared with `Object.is`. It is a `TOIMComputedColumnSlot`
2682
2588
  (`TOIMComputedSlot<unknown, string>`), so it goes in the SECOND array of `createEffect`, and its value
2683
2589
  is read off `columnSlot.value`. It wakes an effect only when that field of the derived value changes.
2684
- Pinned by `facade/computed/tests/what-a-computed-column-wakes-on.test.ts`.
2590
+ Pinned by `computed/tests/what-a-computed-column-wakes-on.test.ts`.
2685
2591
 
2686
2592
  ```ts
2687
2593
  type Summary = { top: number; count: number };
2688
- const summaryService = createOIMComputedService<void, Summary, TOIMSingleSlotStore<TOIMComputedSlot<Summary, void>>>({
2689
- computedSlotStoreService:
2690
- OIMSlotStoreServiceSingleSlotDriven.INSTANCE as unknown as OIMSlotStoreServiceSingleSlotDriven<TOIMComputedSlot<Summary, void>>,
2691
- });
2594
+ const summaryService = createOIMSingleComputedService<Summary>();
2692
2595
  const summary = summaryService.createComputedStoreSlot(
2693
2596
  db,
2694
2597
  () => ({ top: slot.entity?.rank ?? -1, count: cardService.getSlotCount(cards) }),
2695
2598
  () => ({ sourceSlots: [slot] }),
2696
2599
  );
2697
- const summaryCollectionService = summaryService.computedCollectionService;
2698
- const summaryTop = summaryCollectionService.holdColumn(summaryCollectionService.getSlotByKey(summary, undefined), 'top');
2699
- const topEffect = effectService.createEffect(db, [], [summaryTop], () => { paint(slot.entity); });
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]);
2700
2603
  effectService.disposeEffect(topEffect);
2701
- summaryCollectionService.unholdColumn(summaryTop);
2604
+ summarySingleSlotService.columnSlotService.unholdColumn(summaryTop);
2702
2605
  ```
2703
2606
 
2704
2607
  ### The order of a bucket, as one derivation per key
2705
2608
 
2706
2609
  ```ts signature
2707
- createOIMComputedServiceBucketMemo<TKey, TBucketMemberSlot extends TOIMSlot, TSlots, TBucketStore, TComputedStore?>({
2610
+ createOIMBucketMemoComputedService<TKey, TBucketMemberSlot extends TOIMSlot, TSlots, TBucketStore, TComputedStore?>({
2708
2611
  bucketCollectionService: OIMSlotCollectionService<TKey, TOIMBucketSlot<TSlots, TKey>, TBucketStore, OIMSlotContentBucketService<TBucketMemberSlot, TSlots>, …>;
2709
- slotReleasePolicy?: IOIMSlotReleasePolicy; // default OIMSlotReleasePolicyAtUnhold.INSTANCE
2612
+ slotReleasePolicy?: IOIMSlotReleasePolicy; // default OIMSlotAtUnholdReleasePolicy.INSTANCE
2710
2613
  columnSlotStoreService?: IOIMSlotStoreService<string, TOIMComputedColumnSlot>;
2711
2614
  computedSlotStoreService?: IOIMSlotStoreService<TKey, TOIMComputedSlot<readonly TBucketMemberSlot[], TKey>, TComputedStore>;
2712
- }): OIMComputedServiceBucketMemo<TKey, TBucketMemberSlot, TSlots, TBucketStore, TComputedStore>
2615
+ }): OIMBucketMemoComputedService<TKey, TBucketMemberSlot, TSlots, TBucketStore, TComputedStore>
2713
2616
 
2714
2617
  createBucketMemoStoreSlot(bucketStoreSlot: TOIMStoreSlot<TBucketStore, TOIMBucketSlot<TSlots, TKey>, readonly TBucketMemberSlot[]>): TOIMStoreSlot<TComputedStore, TOIMBucketMemoComputedSlot<TBucketMemberSlot, TKey>>
2715
2618
  ```
2716
2619
 
2717
2620
  It takes no db: a memo store slot runs on the channel slot of the bucket store slot it reads. The class is
2718
- `new OIMComputedServiceBucketMemo(options)` (options type `TOIMComputedServiceBucketMemoOptions`), whose
2621
+ `new OIMBucketMemoComputedService(options)` (options type `TOIMBucketMemoComputedServiceOptions`), whose
2719
2622
  `computedCollectionService` reads the per-key derivations; `createBucketMemoStoreSlot(bucketStoreSlot)` makes
2720
2623
  one memo store slot over one bucket store slot.
2721
2624
 
@@ -2725,18 +2628,18 @@ it is produced on the read that asks for it and never while nothing reads it, an
2725
2628
  its `changedAtRevision` and `changedAtEpoch` move only when the list a reader would
2726
2629
  see actually changed. The list is handed back by IDENTITY: it is the same array for
2727
2630
  as long as the list holds, so a consumer memoising on identity needs no comparison
2728
- of its own. For a SORTED list use [`createOIMComputedServiceSorted`](#sorted-lists).
2631
+ of its own. For a SORTED list use [`createOIMSortedComputedService`](#sorted-lists).
2729
2632
 
2730
2633
  The source list is ONE slot — the bucket — and it never grows: a bucket
2731
2634
  change is a move of the bucket's value, not of the derivation's sources.
2732
2635
 
2733
2636
  ```ts
2734
- const orderService = createOIMComputedServiceBucketMemo({
2637
+ const orderService = createOIMBucketMemoComputedService({
2735
2638
  bucketCollectionService: orderedService,
2736
2639
  });
2737
2640
  const order = orderService.createBucketMemoStoreSlot(ordered);
2738
2641
  const deck = orderService.computedCollectionService.holdSlotByKey(order, 'spades'); // mints the bucket too, and holds it
2739
- effectService.createEffect(db, [], [deck], () => render(deck.computedValueService.getValueForSlot(deck)));
2642
+ effectService.createEffect(db, () => render(deck.computedValueService.getValueForSlot(deck)), [], [deck]);
2740
2643
  ```
2741
2644
 
2742
2645
  ---
@@ -2786,7 +2689,7 @@ type TOIMCompositeKeyNode<TAtKey = unknown, TCompositeKeyPart = TOIMEntitySlot>
2786
2689
  };
2787
2690
  ```
2788
2691
 
2789
- `TOIMSlotStoreTree` is what `OIMSlotStoreServiceTreeDriven.createStore()` builds:
2692
+ `TOIMSlotStoreTree` is what `OIMSlotTreeStoreService.createStore()` builds:
2790
2693
  the tree's root plus a flat `Set` of every filed slot, which makes `getSlots()` and
2791
2694
  `getSlotCount()` O(1) rather than a walk, and keeps `getSlots()` in filing order like every other store.
2792
2695
  `TOIMCompositeKeyNode` is one level of that tree — a `Map` from composite key part to the
@@ -2796,10 +2699,10 @@ are holding; neither is something you build by hand.
2796
2699
 
2797
2700
  | Store | Key type |
2798
2701
  |---|---|
2799
- | `OIMSlotStoreServiceMapDriven<TSlot>` | `TOIMStoreKey` = `string \| number` |
2800
- | `OIMSlotStoreServiceTreeDriven<TOIMStoreKey, TSlot>` built with `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()` | `readonly TOIMStoreKey[]` — **a chain of VALUES** |
2801
- | `OIMSlotStoreServiceTreeDriven<TOIMEntitySlot, TSlot>` built with `OIMDefaults.KEY_TREE_WALK_COMPOSITE.getValue()` | `readonly TOIMEntitySlot[]` — **a chain of entity SLOTS** |
2802
- | `OIMSlotStoreServiceSingleSlotDriven.INSTANCE` | `void` — one slot per store slot, its store a `TOIMSingleSlotStore` (`{ slot }`); what every single is built on |
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 |
2803
2706
 
2804
2707
  Two stores for keys, one per shape of address, and the single-slot store for no key. The chain store descends one `Map`
2805
2708
  lookup per composite key part, keeps `[a]` and `[a, b]` apart, and has **no encoding step**:
@@ -2839,10 +2742,10 @@ deletes nothing and returns `undefined`.
2839
2742
 
2840
2743
  | Walk | Class | Shared instance | What a composite key part is | Holds? |
2841
2744
  |---|---|---|---|---|
2842
- | scalar | `OIMKeyTreeWalkScalar` | `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()` | `TOIMStoreKey` | no |
2843
- | composite | `OIMKeyTreeWalkComposite` | `OIMDefaults.KEY_TREE_WALK_COMPOSITE.getValue()` | `TOIMEntitySlot` | **yes — one hold per edge** |
2745
+ | scalar | `OIMScalarKeyTreeWalk` | `OIMScalarKeyTreeWalk.INSTANCE` | `TOIMStoreKey` | no |
2746
+ | composite | `OIMCompositeKeyTreeWalk` | `OIMCompositeKeyTreeWalk.INSTANCE` | `TOIMEntitySlot` | **yes — one hold per edge** |
2844
2747
 
2845
- **The hold is the whole difference.** `OIMKeyTreeWalkComposite.mintNodeAt` calls
2748
+ **The hold is the whole difference.** `OIMCompositeKeyTreeWalk.mintNodeAt` calls
2846
2749
  `holdCompositeKeyPart` the first time it creates an edge, which runs
2847
2750
  `entitySlot.owner.holdSlot(entitySlot)`; `deleteKey` calls `unholdCompositeKeyPart` on
2848
2751
  exactly the edges it unlinked, deepest first and only after every unlink is done, which runs `entitySlot.owner.unholdSlot(entitySlot)` unless
@@ -2860,16 +2763,16 @@ type TScalarChain = readonly TOIMStoreKey[];
2860
2763
  type Tally = { deck: string; day: string; count: number };
2861
2764
  type TTallyStore = import('@oimdb/core').TOIMSlotStoreTree<TOIMEntitySlot<Tally, TScalarChain>, TOIMStoreKey>;
2862
2765
 
2863
- const tallyService = createOIMSlotCollectionService({
2864
- slotContentService: createOIMSlotContentEntityService<Tally, keyof Tally, TScalarChain, TTallyStore>(db, {
2865
- slotStoreService: new OIMSlotStoreServiceTreeDriven<TOIMStoreKey, TOIMEntitySlot<Tally, TScalarChain>>(
2866
- OIMDefaults.KEY_TREE_WALK_SCALAR.getValue(),
2867
- ),
2868
- keyOf: (t) => [t.deck, t.day],
2869
- }),
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
+ ),
2870
2773
  });
2871
2774
  const tallies = tallyService.createStoreSlot(db);
2872
- tallyService.slotContentService.upsertEntity(tallies, { deck: 'spades', day: 'mon', count: 1 });
2775
+ tallyService.writeSlotContentByKey(tallies, ['spades', 'mon'], { deck: 'spades', day: 'mon', count: 1 });
2873
2776
  tallyService.getSlotByKey(tallies, ['spades', 'mon']);
2874
2777
  ```
2875
2778
 
@@ -2890,22 +2793,22 @@ type Enrolment = {
2890
2793
  type TChain = readonly TOIMEntitySlot[];
2891
2794
  type TEnrolmentStore = import('@oimdb/core').TOIMSlotStoreTree<TOIMEntitySlot<Enrolment, TChain>, TOIMEntitySlot>;
2892
2795
 
2893
- const enrolmentService = createOIMSlotCollectionService({
2894
- slotContentService: createOIMSlotContentEntityService<Enrolment, keyof Enrolment, TChain, TEnrolmentStore>(db, {
2895
- slotStoreService: new OIMSlotStoreServiceTreeDriven<TOIMEntitySlot, TOIMEntitySlot<Enrolment, TChain>>(
2896
- OIMDefaults.KEY_TREE_WALK_COMPOSITE.getValue(),
2897
- ),
2898
- keyOf: (m) => [m.user, m.group],
2899
- }),
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
+ ),
2900
2803
  });
2901
2804
  const enrolments = enrolmentService.createStoreSlot(db);
2902
- enrolmentService.slotContentService.upsertEntity(enrolments, { user: alice, group: admins, since: 2026 });
2805
+ enrolmentService.writeSlotContentByKey(enrolments, [alice, admins], { user: alice, group: admins, since: 2026 });
2903
2806
  enrolmentService.getSlotByKey(enrolments, [alice, admins]);
2904
2807
  ```
2905
2808
 
2906
2809
  **Never put a scalar in a composite-walk key.** It is a type error, and
2907
2810
  past a cast it throws a TypeError inside the mint, setting `holdCount` on a
2908
- string, after the edge is already in the tree. `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()` is
2811
+ string, after the edge is already in the tree. `OIMScalarKeyTreeWalk.INSTANCE` is
2909
2812
  the walk for that key — the same store class, the other strategy.
2910
2813
 
2911
2814
  **Compare a chain with the strategy that matches it**: `oimSameSlotSequence` for identities
@@ -2918,8 +2821,7 @@ address" for the address the tree is looking at.
2918
2821
  ## Lifetime — the owner protocol
2919
2822
 
2920
2823
  **`slot.owner` is the only reference a slot carries, and it is never
2921
- `undefined`.** Every slot minted anywhere has one; a slot nothing filed carries
2922
- `OIMDefaults.STANDALONE_SLOT_OWNER.getValue()`, a slot a collection filed carries that collection type's slot owner (an `OIMSlotOwnerService`) — keyed or single alike — and a slot that has been released KEEPS its owner
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
2923
2825
  and raises the RELEASED flag in `slot.slotFlags` instead, which the doors below read.
2924
2826
 
2925
2827
  ```ts signature
@@ -2944,26 +2846,27 @@ At a deadline the release queue reads its MARKER off the slot, decides, and call
2944
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 |
2945
2847
 
2946
2848
  **`OIMSlotOwner`** is the base class and it is concrete: it implements all five.
2947
- Its `releaseSlotIfUnheld` does nothing, and a `protected
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
2948
2854
  onSlotIdle` does nothing; a `protected assertNotReleased` is what every refusing
2949
2855
  door above calls. `OIMSlotOwnerService` overrides `onSlotIdle` and
2950
- `releaseSlotIfUnheld`, asking its release policy and its content strategy; the clock is the slot's channel slot's.
2951
- Extend `OIMSlotOwner` when you own slots that no collection files.
2952
- **`OIMDefaults.STANDALONE_SLOT_OWNER`** is one shared instance of it — the owner of a
2953
- computed slot nobody has filed. It never releases anything, which is exactly what
2954
- "filed nowhere, so nothing can unfile it" means. A single's slot is not such a slot: its
2955
- owner is its collection type's slot owner, and that release policy applies to it.
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.
2956
2859
 
2957
2860
  ### The column owner
2958
2861
 
2959
2862
  **Columns are a module of every collection.** The column slots of a carrier slot live
2960
2863
  in `carrierSlot.columnSlotStore`, a field of the base `TOIMSlot`, so any slot can carry
2961
- them — an entity slot, a bucket slot, a computed slot. `OIMColumnOwner` is abstract, an
2864
+ them — an entity slot, a bucket slot, a computed slot. `OIMColumnSlotOwner` is abstract, an
2962
2865
  `OIMSlotOwner` subclass, with two strategies:
2963
- `OIMColumnOwnerPushDriven` (plain column slots woken by marks on a write; entity and bucket collections,
2964
- keyed and single) and `OIMColumnOwnerPullDriven` (each column slot a computed slot over its carrier;
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;
2965
2868
  computed collections, keyed and single). The factory hands the strategy's factory to the slot owner as
2966
- `createColumnOwner`. Each collection type has its own column owner, and every column slot it mints names that
2869
+ `createColumnSlotOwner`. Each collection type has its own column owner, and every column slot it mints names that
2967
2870
  owner as its `owner`. The owner holds only the column slot store service; the column state — the column
2968
2871
  groups and which columns are referenced — lives on the store slot. The collection exposes it only
2969
2872
  through its column doors: `getColumn(slot, columnName)`, `holdColumn(slot, columnName)`,
@@ -2990,12 +2893,12 @@ so a slot with any live column slot is never released.
2990
2893
  ### Disposers
2991
2894
 
2992
2895
  The disposers this package hands back are the unsubscribes `OIMChannelService.INSTANCE.subscribeAfterEachFrame`
2993
- and its siblings return, and the one `effectService.subscribeEffect(db, sourceSlots, sourceComputeds, body)`
2896
+ and its siblings return, and the one `effectService.subscribeEffect(db, body, sourceSlots?, sourceComputeds?, sourceTrackingComputeds?)`
2994
2897
  returns. An effect from `createEffect` has none, so the caller writes
2995
2898
  `() => effectService.disposeEffect(effect)`. A hold is given back through its owner, not through a disposer.
2996
2899
 
2997
2900
  ```ts
2998
- const effect = effectService.createEffect(db, [slot], [], () => { paint(slot.entity); });
2901
+ const effect = effectService.createEffect(db, () => { paint(slot.entity); }, [slot]);
2999
2902
  const disposers: TOIMDisposer[] = [];
3000
2903
  disposers.push(() => { effectService.disposeEffect(effect); });
3001
2904
  disposers.push(() => { slot.owner.unholdSlot(slot); });
@@ -3026,12 +2929,11 @@ content service hands back the LIVE list — declared `Iterable`, not an array,
3026
2929
  `Array.from(bucketService.slotContentService.getBucketMemberSlots(bucketSlot))` is required and is the copy. A `prev` kept
3027
2930
  live is the same array as `next` on the following frame: the diff is empty,
3028
2931
  no reorder is ever drawn, and nothing throws. The other safe `prev` is
3029
- `createOIMComputedServiceBucketMemo`'s value — a fresh array on every write that
2932
+ `createOIMBucketMemoComputedService`'s value — a fresh array on every write that
3030
2933
  changed the list, the same array while it did not.
3031
2934
 
3032
2935
  A LIST MAY NAME ONE SLOT TWICE, and then it holds that slot twice.
3033
- `OIMBucketArrayMembershipNonUnique` — the default strategy of `OIMSlotContentBucketServiceArrayDriven` —
3034
- records every mention, so an ordered bucket's list is exactly such a list. The
2936
+ `OIMSlotContentPositionalArrayBucketService` records every mention, so an ordered bucket's list is exactly such a list. The
3035
2937
  mentions of one identity are matched in the order they appear, and the script
3036
2938
  reproduces `next` mention for mention. The minimal move count (`common − LIS`)
3037
2939
  is claimed for lists whose identities are distinct: where the two lists name one
@@ -3047,15 +2949,15 @@ leave the longest.
3047
2949
  type TOIMStoreKey = string | number;
3048
2950
  type TOIMDisposer = () => void;
3049
2951
  type TOIMEffectBody = () => (() => void) | void; // a returned function is the undo
3050
- type TOIMEffectBodyWithSourceTracking = (effectSourceTracker: OIMEffectSourceTracker) => (() => void) | void;
2952
+ type TOIMSourceTrackingEffectBody = (sourceTracker: OIMSourceTracker) => (() => void) | void;
3051
2953
  type TOIMSameValueGuard<TValue = unknown> = (prev: TValue, next: TValue) => boolean;
3052
2954
  type TOIMChannelScheduler = (flush: () => void) => void;
3053
- type TOIMSortColumn<TEntity extends object> = { column: TOIMSortableColumnName<TEntity>; direction: 'asc' | 'desc' };
2955
+ type TOIMSortColumn<TEntity extends object> = { columnName: TOIMSortableColumnName<TEntity>; direction: 'asc' | 'desc' };
3054
2956
  type TOIMSortableColumnName<TEntity extends object> = // a column whose values are string | number | bigint
3055
2957
  { [TColumn in keyof TEntity]-?: NonNullable<TEntity[TColumn]> extends string | number | bigint ? TColumn : never }[keyof TEntity] & string;
3056
2958
 
3057
2959
  // EVERY property is `readonly`. A slot is written through its owner and its
3058
- // service, never by assigning to it; `TOIMSlotEditable` is how the engine
2960
+ // service, never by assigning to it; `TOIMEditable` is how the engine
3059
2961
  // itself drops that guard where it is the one mutator.
3060
2962
  type TOIMSlot = {
3061
2963
  readonly effects: TOIMEffect[] | undefined;
@@ -3066,9 +2968,9 @@ type TOIMSlot = {
3066
2968
  readonly releaseDueAfterWriteMs: number; // -1 until a write stamps it
3067
2969
  readonly releaseDueAfterIdleMs: number; // -1 until idleness stamps it
3068
2970
  readonly releaseDueAfterUnholdMs: number; // -1 until the last unhold stamps it
3069
- readonly slotFlags: number; // OIM_SLOT_FLAG_EMPTY = 1, OIM_SLOT_FLAG_RELEASED = 2
2971
+ readonly slotFlags: number; // OIM_SLOT_FLAG_EMPTY = 1, OIM_SLOT_FLAG_RELEASED = 2; bits up to 32 are the engine's
3070
2972
  readonly columnSlotStore: unknown; // the column slots this slot carries; undefined while none
3071
- readonly storeSlot: TOIMStoreSlot | undefined;
2973
+ readonly storeSlot: TOIMStoreSlot; // the store slot it belongs to; a column slot's is its carrier's
3072
2974
  readonly owner: IOIMSlotOwner; // never undefined, never cleared
3073
2975
  };
3074
2976
 
@@ -3078,12 +2980,14 @@ type TOIMSlot = {
3078
2980
  type TOIMStoreSlot<TStore = unknown, TSlot extends TOIMSlot = TOIMSlot, TPatch = unknown> = {
3079
2981
  readonly store: TStore;
3080
2982
  readonly channelSlot: TOIMChannelSlot;
3081
- readonly contentChangeListeners: IOIMContentChangeListener<TSlot, TPatch>[] | undefined;
2983
+ readonly contentChangeListeners: IOIMSlotContentChangeListener<TSlot, TPatch>[] | undefined;
3082
2984
  readonly contentChangeListenerReadColumnNames: (readonly string[] | undefined)[] | undefined;
3083
2985
  readonly indexKeysPositions: TOIMIndexKeysPositions | undefined;
3084
2986
  readonly sourceStoreSlot: TOIMStoreSlot | undefined; // an index store slot's source
3085
2987
  readonly isDisposed: boolean; // an index store slot after disposeIndexStoreSlot
3086
- // … column-reader counts, column groups, subframe queues, bucket write queues: the services' own
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
3087
2991
  };
3088
2992
 
3089
2993
  type TOIMSingleSlotStore<TSlot> = { slot: TSlot | undefined };
@@ -3093,7 +2997,7 @@ type TOIMIndexStoreSlot<TStore, TBucketSlot, TBucketPatch, TSourceSlot, TSourceP
3093
2997
  TOIMStoreSlot<TStore, TBucketSlot, TBucketPatch> & {
3094
2998
  readonly sourceStoreSlot: TOIMStoreSlot<TSourceStore, TSourceSlot, TSourcePatch>;
3095
2999
  readonly indexKeysPosition: number;
3096
- readonly contentChangeListener: IOIMContentChangeListener<TSourceSlot, TSourcePatch>;
3000
+ readonly contentChangeListener: IOIMSlotContentChangeListener<TSourceSlot, TSourcePatch>;
3097
3001
  readonly requestedKeys: readonly TKey[] | undefined;
3098
3002
  readonly bucketColumnNames: Set<string>;
3099
3003
  readonly isAnyBucketColumnRead: boolean;
@@ -3103,10 +3007,10 @@ type TOIMIndexStoreSlot<TStore, TBucketSlot, TBucketPatch, TSourceSlot, TSourceP
3103
3007
  type TOIMComputedStoreSlot<TStore = unknown, TValue = unknown, TKey = unknown> =
3104
3008
  TOIMStoreSlot<TStore, TOIMComputedSlot<TValue, TKey>> & {
3105
3009
  readonly derive: ((computedSlot: TOIMComputedSlot<TValue, TKey>) => TValue) | undefined;
3106
- readonly createSources: ((key: TKey, storeSlot: TOIMStoreSlot | undefined) => Partial<TOIMComputedSources>) | undefined;
3010
+ readonly createSources: ((computedSlot: TOIMComputedSlot<TValue, TKey>) => Partial<TOIMComputedSources>) | undefined;
3107
3011
  };
3108
3012
 
3109
- type TOIMSlotEditable<TSlot> = { -readonly [K in keyof TSlot]: TSlot[K] };
3013
+ type TOIMEditable<TSlot> = { -readonly [K in keyof TSlot]: TSlot[K] };
3110
3014
 
3111
3015
  type TOIMKeyedSlot<TKey = unknown> = TOIMSlot & { readonly key: TKey };
3112
3016
  type TOIMIndexableSlot<TKey = unknown> = TOIMKeyedSlot<TKey> & { readonly indexKeys: unknown[] | undefined };
@@ -3117,8 +3021,8 @@ type TOIMEntitySlot<TEntity extends object = object, TKey = unknown> = TOIMIndex
3117
3021
  };
3118
3022
 
3119
3023
  // `data` belongs to the consumer: the core never reads or writes it, and clearing or
3120
- // removing the slot (`slotContentService.deleteEntity(slot)`) does not reset it. Each index keeps its
3121
- // last key for the entity slot in `indexKeys`, through the db's `IOIMIndexKeysService`.
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`.
3122
3026
 
3123
3027
  type TOIMColumnSlot = TOIMSlot & {
3124
3028
  readonly carrierSlot: TOIMSlot; // the entity slot or bucket slot it hangs off
@@ -3127,39 +3031,39 @@ type TOIMColumnSlot = TOIMSlot & {
3127
3031
 
3128
3032
  type TOIMComputedColumnSlot = TOIMComputedSlot<unknown, string>; // a computed's getColumn / holdColumn return it; it goes in createEffect's second list
3129
3033
 
3130
- type TOIMBucketSlot<TSlots = unknown, TKey = unknown> = TOIMIndexableSlot<TKey> & {
3034
+ type TOIMBucketContentSlot<TSlots = unknown> = TOIMSlot & {
3131
3035
  readonly owner: IOIMSlotOwner;
3132
- readonly storeSlot: TOIMStoreSlot<unknown, TOIMBucketSlot<TSlots>>;
3036
+ readonly storeSlot: TOIMStoreSlot<unknown, TOIMBucketContentSlot<TSlots>>;
3133
3037
  readonly bucketSlotsStore: TSlots;
3134
3038
  readonly commands: readonly TOIMOrderedListCommand[] | undefined; // emptied when the flush has ended
3135
3039
  readonly isWholeListRecorded: boolean; // reset to false with it
3136
3040
  readonly commandsStartedAtRevision: number; // the clock the script started at
3137
3041
  readonly commandsStartedAtEpoch: number;
3138
- readonly wasEmptyBeforeChange: boolean | undefined;
3139
- readonly isChangedAgainInSubframe: boolean;
3140
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
3141
3045
 
3142
3046
  // `holdSlotByKey(storeSlot, key)` hands back the slot itself; give the hold back with `slot.owner.unholdSlot(slot)`.
3143
3047
 
3144
3048
  type TOIMBucketSlotsSetStore<TBucketMemberSlot extends TOIMSlot = TOIMSlot> = Set<TBucketMemberSlot>;
3145
3049
  type TOIMBucketSlotsArrayStore<TBucketMemberSlot extends TOIMSlot = TOIMSlot> = {
3146
3050
  bucketMemberSlots: TBucketMemberSlot[];
3051
+ uniqueBucketMemberSlots: Set<TBucketMemberSlot> | undefined; // kept by OIMBucketUniqueMemberSlotsArrayService
3052
+ sortedCount: number;
3147
3053
  };
3148
- type TOIMBucketSlotsArrayUniqueStore<TBucketMemberSlot extends TOIMSlot = TOIMSlot> =
3149
- TOIMBucketSlotsArrayStore<TBucketMemberSlot> & { uniqueBucketMemberSlots: Set<TBucketMemberSlot> };
3150
3054
 
3151
3055
  type TOIMEntityColumnName<TEntity extends object> =
3152
3056
  [TEntity] extends [never] ? never
3153
3057
  : object extends TEntity ? string : keyof TEntity & string;
3154
3058
 
3155
- type TOIMDerivation = TOIMComputedSources & { // what a computed slot and a TOIMEffect share
3059
+ type TOIMComputedDerivation = TOIMComputedSources & { // what a computed slot and a TOIMEffect share
3156
3060
  ranAtRevision: number; ranAtEpoch: number;
3157
3061
  };
3158
3062
 
3159
3063
  type TOIMTimer = (run: () => void, inMs: number) => TOIMDisposer;
3160
3064
  ```
3161
3065
 
3162
- **`TOIMSlotEditable<TSlot>` strips `readonly` from every property.** It is exported
3066
+ **`TOIMEditable<TSlot>` strips `readonly` from every property.** It is exported
3163
3067
  because the rule "data has one mutator" is enforced by the slot types being
3164
3068
  readonly everywhere else: a class that IS the sole mutator of a slot's property
3165
3069
  casts through this type at the one place it writes, and every other reader is
@@ -3181,103 +3085,103 @@ that matches what you are comparing; they are not interchangeable.
3181
3085
 
3182
3086
  ## Full export list
3183
3087
 
3184
- **200 names, every export of `packages/core/src/index.ts`, grouped by topic.**
3088
+ **230 names, every export of `packages/core/src/index.ts`, grouped by topic.**
3185
3089
 
3186
3090
  *Channel and database slots* — `OIMChannelService` · `TOIMChannelSlot` · `TOIMChannelSlotOptions` · `createOIMChannelSlot` ·
3187
- `IOIMChannelMap` · `TOIMDBOptions` · `OIMFlushSchedulerService` · `TOIMFlushSchedulerSlot`
3091
+ `IOIMChannelMap` · `TOIMDBOptions` · `OIMFlushSchedulerService` · `TOIMFlushSchedulerSlot` · `IOIMFlushSchedulerListener`
3188
3092
 
3189
3093
  *Collector services and slots* — `OIMUnusedSlotCollectorService` · `TOIMUnusedSlotCollectorSlot` ·
3190
3094
  `createOIMUnusedSlotCollectorSlot` · `TOIMCollectorSlot` · `TOIMCollectorSlotOptions` · `createOIMCollectorSlot` ·
3191
- `OIMReleaseSlotCollectorService` · `OIMReleaseSlotCollectorServiceAfterIdle` · `OIMReleaseSlotCollectorServiceAfterUnhold` ·
3192
- `OIMReleaseSlotCollectorServiceAfterWrite` · `OIMReleaseSlotQueueService` · `TOIMReleaseSlotCollectorSlot` ·
3193
- `createOIMReleaseSlotCollectorSlot`
3194
-
3195
- *Slot services* — `OIMSlotColumnService` (abstract; the column doors both slot services inherit) ·
3196
- `OIMSlotCollectionService` · `TOIMSlotCollectionServiceOptions` · `createOIMSlotCollectionService` ·
3197
- `OIMSingleSlotService` · `TOIMSingleSlotServiceOptions` · `createOIMSingleSlotService` · `OIMSlotOwnerService` (**type only**) ·
3198
- `TOIMSlotOwnerServiceOptions` · `IOIMSlotContentStrategy` · `IOIMSlotContentFlushStrategy` · `IOIMSubframeCloseListener` ·
3199
- `OIMContentChangeListenerService`
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`
3200
3106
 
3201
3107
  *Content services* — `OIMSlotContentEntityService` · `TOIMSlotContentEntityServiceOptions` ·
3202
- `createOIMSlotContentEntityService` · `TOIMSlotContentEntityServiceFactoryOptions` · `TOIMEntitySlotOwnerService` · `createOIMEntityService` and
3203
- `createOIMSingleEntityService` (convenience wrappers) · `OIMSlotContentBucketService` ·
3204
- `OIMSlotContentBucketServiceArrayBased` · `OIMSlotContentBucketServiceArrayDriven` · `OIMSlotContentBucketServiceSetDriven` ·
3205
- `OIMSlotContentBucketServiceSortedArrayDriven` · `TOIMSlotContentBucketServiceOptions` ·
3206
- `TOIMSlotContentBucketServiceSortedArrayDrivenOptions` · `TOIMBucketSlotOwnerService` · `createOIMBucketService` and
3207
- `createOIMSingleBucketService` (convenience wrappers)
3108
+ `createOIMSlotContentEntityService` · `TOIMEntitySlotOwnerService` · `TOIMEntityContentSlot` · `OIMSlotContentBucketService` ·
3109
+ `OIMSlotContentArrayBucketService` · `OIMSlotContentPositionalArrayBucketService` · `OIMSlotContentSetBucketService` ·
3110
+ `OIMSlotContentSortedArrayBucketService` · `TOIMSlotContentBucketServiceOptions` ·
3111
+ `TOIMSlotContentArrayBucketServiceOptions` · `TOIMSlotContentSortedArrayBucketServiceOptions` · `TOIMBucketSlotOwnerService` ·
3112
+ `TOIMBucketContentSlot` · `OIMSlotContentComputedService` (**type only**) · `TOIMSlotContentComputedServiceOptions` ·
3113
+ `TOIMComputedContentSlot`
3208
3114
 
3209
- *Index and computed store slots* — `createOIMIndexService` · `TOIMIndexServiceFactoryOptions` ·
3210
- `createOIMIndexServiceByCompositeKey` (convenience wrapper) ·
3211
- `TOIMIndexServiceStoreSlot` · `TOIMSortedComputedSlot` · `TOIMBucketMemoComputedSlot`
3115
+ *Index and computed store slots* — `createOIMSlotCollectionIndexService` · `TOIMSlotCollectionIndexServiceFactoryOptions` ·
3116
+ `TOIMSlotCollectionIndexServiceStoreSlot` · `TOIMSortedComputedSlot` · `TOIMBucketMemoComputedSlot`
3212
3117
 
3213
3118
  *Runtime* — `TOIMChannelFrame` · `TOIMChannelClock` ·
3214
- `TOIMChannelScheduler` · `TOIMDisposer` · `IOIMErrorPolicy` · `OIMErrorPolicyLogAndContinue` ·
3215
- `TOIMErrorPolicyLogAndContinueOptions` · `OIMErrorPolicyThrowAtOnce` · `OIMChannelQueue` (**type only**) ·
3119
+ `TOIMChannelScheduler` · `TOIMDisposer` · `IOIMErrorPolicy` · `OIMLogAndContinueErrorPolicy` ·
3120
+ `TOIMLogAndContinueErrorPolicyOptions` · `OIMThrowAtOnceErrorPolicy` · `OIMChannelQueue` (**type only**) ·
3216
3121
  `oimIsAnySlotChangedAfter`
3217
3122
 
3218
3123
  `oimIsAnySlotChangedAfter(slots, revision, epoch): boolean` answers whether any of the slots was
3219
3124
  written after that clock reading — `channelSlot.clock` read earlier.
3220
3125
 
3221
3126
  *Derivations* — `OIMEffectService` · `IOIMEffectService` · `TOIMEffect` (**type only** —
3222
- `OIMEffectService.createEffect` is the constructor) · `TOIMEffectBody` · `TOIMEffectBodyWithSourceTracking` ·
3223
- `OIMEffectSourceTracker` (**type only** — the service builds it) · `TOIMComputedSlot` · `TOIMComputedSources` ·
3224
- `OIMComputedValueService` · `IOIMComputedValueService` · `TOIMComputedValueServiceOptions` · `oimRefreshSourceComputeds` · `TOIMDerivation`
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`
3225
3130
 
3226
3131
  `oimRefreshSourceComputeds(sourceComputeds)` brings every computed in the list current. A binding
3227
3132
  read outside an effect (`useOIMValue` in `@oimdb/react`, `exoBinding` in `@oimdb/exodra`) calls it
3228
3133
  on its `sourceComputeds` before `derive`, so the derive never reads a stale computed.
3229
3134
 
3230
- *Slots and their owners* — `TOIMSlot` · `TOIMStoreSlot` · `TOIMSingleSlotStoreSlot` ·`IOIMStoreSlotListener` · `OIMStoreSlotListenerNone` ·`oimIsSlotEmpty` · `oimIsSlotReleased` · `TOIMSlotEditable` · `TOIMKeyedSlot` · `TOIMIndexableSlot` ·
3231
- `TOIMEntitySlot` · `TOIMColumnSlot` · `TOIMComputedColumnSlot` · `TOIMColumnGroup` · `TOIMColumnReaders` · `OIMColumnOwner` ·
3232
- `OIMColumnOwnerPushDriven` · `OIMColumnOwnerPullDriven` · `TOIMColumnOwnerOptions` · `TOIMBucketSlot` ·
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` ·
3233
3138
  `TOIMSameValueGuard` · `OIMSlotOwner` ·
3234
- `IOIMSlotOwner` · `IOIMSlotEmptiness` ·
3235
- `IOIMSlotStamp` · `oimHoldSlots` · `oimUnholdSlotsIfAlive` · `IOIMContentChangeListener`
3139
+ `IOIMSlotOwner` · `IOIMSlotEmptinessPolicy` ·
3140
+ `IOIMSlotStamp` · `oimHoldSlots` · `oimUnholdSlotsIfAlive` · `IOIMSlotContentChangeListener`
3236
3141
 
3237
3142
  `oimIsSlotEmpty(slot): boolean` reads the EMPTY bit of `slot.slotFlags`.
3238
3143
  `oimIsSlotReleased(slot): boolean` reads its RELEASED bit.
3239
3144
 
3240
3145
  *Release policies* — `IOIMSlotReleasePolicy` ·
3241
- `OIMSlotReleasePolicyAfterWrite` · `TOIMSlotReleasePolicyAfterWriteOptions` ·
3242
- `OIMSlotReleasePolicyAfterIdle` · `TOIMSlotReleasePolicyAfterIdleOptions` ·
3243
- `OIMSlotReleasePolicyAfterUnhold` · `TOIMSlotReleasePolicyAfterUnholdOptions` ·
3244
- `OIMSlotReleasePolicyAtUnhold` · `OIMSlotReleasePolicyAtEmpty` · `OIMSlotReleasePolicyNever` · `OIMSlotReleasePolicyEarliest` ·
3146
+ `OIMSlotAfterWriteReleasePolicy` · `TOIMSlotAfterWriteReleasePolicyOptions` ·
3147
+ `IOIMSlotReleaseDueFramePolicy` · `OIMSlotReleaseAutoDueFramePolicy` · `OIMSlotReleaseManualDueFramePolicy` ·
3148
+ `OIMSlotAfterIdleReleasePolicy` · `TOIMSlotAfterIdleReleasePolicyOptions` ·
3149
+ `OIMSlotAfterUnholdReleasePolicy` · `TOIMSlotAfterUnholdReleasePolicyOptions` ·
3150
+ `OIMSlotAtUnholdReleasePolicy` · `OIMSlotAtEmptyReleasePolicy` · `OIMSlotNeverReleasePolicy` · `OIMSlotEarliestReleasePolicy` ·
3245
3151
  `TOIMTimer` · `oimSliceOfMs` · `OIM_SLICE_LENGTH_MS`
3246
3152
 
3247
- *Entity writers* — `IOIMEntityWriter` · `createOIMEntityWriterInPlace` ·
3248
- `createOIMEntityWriterReplacing` · `TOIMEntityWriterOptions` · `TOIMEntityInsertStrategy` ·
3249
- `oimInsertEntityWithCopy` · `oimInsertEntityWithoutCopy` · `oimInsertEntityWithSanitizedCopy` · `oimPatchEntityColumns` · `oimPatchEntityColumn` · `oimPatchEntityColumnsGuarded` · `oimPatchEntityColumnGuarded` · `oimPutEntityColumns` · `oimPutEntityColumnsGuarded` · `TOIMEntityColumnName` · `TOIMEntityColumnValue`
3153
+ *Entity writers* — `IOIMEntityWriter` · `createOIMInPlaceEntityWriter` ·
3154
+ `createOIMReplacingEntityWriter` · `TOIMEntityWriterOptions` · `TOIMEntityInserter` ·
3155
+ `oimInsertEntityWithCopy` · `oimInsertEntityWithoutCopy` · `oimInsertEntityWithSanitizedCopy` · `oimPatchEntityColumns` · `oimPatchEntityColumn` · `oimGuardedPatchEntityColumns` · `oimGuardedPatchEntityColumn` · `oimPutEntityColumns` · `oimGuardedPutEntityColumns` · `TOIMEntityColumnName` · `TOIMEntityColumnValue`
3250
3156
 
3251
- *Buckets* — `TOIMBucketCollectionFactoryArguments` ·
3252
- `OIMBucketCommands` · `TOIMBucketCommandsOptions` · `IOIMBucketCommands` ·
3253
- `IOIMBucketArrayMembership` · `OIMBucketArrayMembershipNonUnique` ·
3254
- `OIMBucketArrayMembershipUnique` · `TOIMBucketSlotsSetStore` · `TOIMBucketSlotsArrayStore` ·
3255
- `TOIMBucketSlotsSortedArrayStore` · `TOIMBucketSlotsArrayUniqueStore` · `OIM_NOTHING_WAS_INSERTED` ·
3157
+ *Buckets* — `OIMBucketCommands` · `TOIMBucketCommandsOptions` · `IOIMBucketCommands` ·
3158
+ `IOIMBucketMemberSlotsArrayService` · `OIMBucketMemberSlotsArrayService` · `OIMBucketUniqueMemberSlotsArrayService` ·
3159
+ `TOIMBucketSlotsSetStore` · `TOIMBucketSlotsArrayStore` · `OIM_NOTHING_WAS_INSERTED` ·
3256
3160
  `OIM_NOTHING_WAS_REMOVED` · `OIM_FROZEN_EMPTY_SLOTS` · `OIM_REMOVED_WITHOUT_A_POSITION`
3257
3161
 
3258
- *Index services* — `OIMIndexService` · `OIMIndexServiceSortedArrayDriven` · `TOIMIndexServiceOptions` · `TOIMIndexStoreSlot` ·
3259
- `createOIMIndexServiceSortedArrayDriven` · `TOIMIndexKeyOptions` · `TOIMIndexKey` ·
3162
+ *Index services* — `OIMSlotCollectionIndexService` · `OIMSlotCollectionSortedArrayIndexService` · `TOIMSlotCollectionIndexServiceOptions` · `TOIMIndexStoreSlot` ·
3163
+ `createOIMSlotCollectionSortedArrayIndexService` · `TOIMIndexKeyOptions` · `TOIMIndexKey` ·
3260
3164
  `IOIMIndexKeysService` · `TOIMIndexKeysPositions` · `OIMIndexKeysService` ·
3261
- `OIMIndexKeysServiceAppending` · `OIMIndexKeysServiceReclaiming`
3165
+ `OIMAppendingIndexKeysService` · `OIMReclaimingIndexKeysService`
3262
3166
 
3263
3167
  *Computed services* — `OIMComputedService` · `TOIMComputedServiceOptions` ·
3264
- `createOIMComputedService` · `TOIMComputedStoreSlot` · `OIMComputedServiceSorted` · `TOIMComputedServiceSortedOptions` ·
3265
- `createOIMComputedServiceSorted` · `OIMComputedServiceBucketMemo` · `TOIMComputedServiceBucketMemoOptions` ·
3266
- `createOIMComputedServiceBucketMemo` · `TOIMSortColumn` · `TOIMSortableColumnName` · `oimColumnsComparator`
3168
+ `createOIMComputedService` · `TOIMComputedStoreSlot` · `OIMSortedComputedService` · `TOIMSortedComputedServiceOptions` ·
3169
+ `createOIMSortedComputedService` · `OIMBucketMemoComputedService` · `TOIMBucketMemoComputedServiceOptions` ·
3170
+ `createOIMBucketMemoComputedService` · `OIMSingleComputedService` · `createOIMSingleComputedService` ·
3171
+ `TOIMComputedDerivationOptions` · `TOIMSortColumn` · `TOIMSortableColumnName` · `oimColumnsComparator`
3267
3172
 
3268
3173
  *Stores and keys* — `TOIMStoreKey` · `IOIMSlotStoreService` ·
3269
- `OIMSlotStoreServiceMapDriven` · `OIMSlotStoreServiceTreeDriven` ·
3270
- `OIMSlotStoreServiceSingleSlotDriven` · `TOIMSingleSlotStore` · `TOIMDefaultSlotStore` ·
3174
+ `OIMSlotMapStoreService` · `OIMSlotTreeStoreService` ·
3175
+ `OIMSingleSlotStoreService` · `TOIMSingleSlotStore` · `TOIMDefaultSlotStore` ·
3271
3176
  `TOIMSlotStoreTree` · `IOIMKeyTreeWalk` · `TOIMCompositeKeyNode` ·
3272
- `OIMKeyTreeWalk` · `OIMKeyTreeWalkComposite` · `OIMKeyTreeWalkScalar`
3177
+ `OIMKeyTreeWalk` · `OIMCompositeKeyTreeWalk` · `OIMScalarKeyTreeWalk`
3273
3178
 
3274
- *Assemblies* — `OIMDB` · `OIMDefaults` · `OIMDefaultValue` · `TOIMFactoryOptions`
3179
+ *Assemblies* — `OIMDB` · `OIMDefaults` · `TOIMDefaultsOptions` · `OIMDefaultValue` · `TOIMFactoryOptions`
3275
3180
 
3276
3181
  *Comparison and diffing* — `oimSameValueZeroGuard` ·
3277
3182
  `oimSameSlotSequence` · `oimSameKeySequence` · `oimOrderedListCommandsOfTransition` · `TOIMOrderedListCommand`
3278
3183
 
3279
- *Shared defaults* — `OIMDefaults.STANDALONE_SLOT_OWNER`,
3280
- `OIMDefaults.KEY_TREE_WALK_SCALAR` and `OIMDefaults.KEY_TREE_WALK_COMPOSITE`.
3184
+ *Shared defaults* — `OIMScalarKeyTreeWalk.INSTANCE` and `OIMCompositeKeyTreeWalk.INSTANCE`.
3281
3185
 
3282
3186
  **Nothing else is exported.** If a symbol is not on this list, it does not exist.
3283
3187
 
@@ -3285,18 +3189,18 @@ on its `sourceComputeds` before `derive`, so the derive never reads a stale comp
3285
3189
 
3286
3190
  ## Known defects
3287
3191
 
3288
- ### A scalar in a `KEY_TREE_WALK_COMPOSITE` key throws
3192
+ ### A scalar in a key of the composite key tree walk throws
3289
3193
 
3290
- `OIMSlotStoreServiceTreeDriven` built with `OIMDefaults.KEY_TREE_WALK_COMPOSITE.getValue()` keys on
3194
+ `OIMSlotTreeStoreService` built with `OIMCompositeKeyTreeWalk.INSTANCE` keys on
3291
3195
  `readonly TOIMEntitySlot[]`, so a string or a number in the key throws
3292
3196
  a TypeError past a cast, setting `holdCount` on a string. The store for a tuple
3293
- of scalars is `OIMSlotStoreServiceTreeDriven` built with
3294
- `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()` —
3197
+ of scalars is `OIMSlotTreeStoreService` built with
3198
+ `OIMScalarKeyTreeWalk.INSTANCE` —
3295
3199
  see **Key stores** above. The fix is the other walk, never a cast.
3296
3200
 
3297
3201
  ### `TOIMEffect` is a type-only export
3298
3202
 
3299
- `effectService.createEffect(...)` and `effectService.createEffectImmediate(...)` are the whole of
3203
+ `effectService.createEffect(...)` and `effectService.createAndRunEffect(...)` are the whole of
3300
3204
  legitimate construction. An object literal typed `TOIMEffect` subscribes to nothing and
3301
3205
  schedules nothing: when one of its sources already moved in the open batch, a
3302
3206
  later write to that source is swallowed by the coalescing guard and the effect
@@ -3310,8 +3214,8 @@ Every `@oimdb` package ships both formats — `dist/index.js` for `import`,
3310
3214
  condition so a CommonJS consumer's declarations match its runtime), both named
3311
3215
  in the `exports` map. A runtime loads a package once per format, so a process
3312
3216
  that reaches the core both ways would hold two copies of every class and every
3313
- module-level value — two `OIMDefaults`, so a default set through one never
3314
- reaches a service built by the other.
3217
+ module-level value — two sets of built-in defaults and two `OIMDefaults` classes,
3218
+ so services built by the two copies share nothing.
3315
3219
 
3316
3220
  So `src/index.ts` registers its copy under `Symbol.for('@oimdb/core')` on
3317
3221
  `globalThis`, and the second copy to load throws
@@ -3329,8 +3233,8 @@ core objects around — through both spellings in one process. Pick one.
3329
3233
 
3330
3234
  ## Other packages
3331
3235
 
3332
- `@oimdb/react`, `@oimdb/persist` (with its five backends) and `@oimdb/exodra` build
3333
- on the current core — persist and exodra have sections of their own below. Every
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
3334
3238
  remaining package imports only names the core exports. Read a package's own
3335
3239
  source before generating code against it.
3336
3240
 
@@ -3357,14 +3261,14 @@ The computed SERVICE is the caller's, built once at module scope with `createOIM
3357
3261
  slot per mount — `computedService.createComputedStoreSlot(db, derive, createSources)` in a `useMemo` on `deps`;
3358
3262
  `derive` / `createSources` may be `undefined` when the service has them.
3359
3263
  `useOIMSingleComputedValue(computedService, derive, createSources, deps): TValue` is the same for a void-keyed
3360
- service whose `computedSlotStoreService` is `OIMSlotStoreServiceSingleSlotDriven.INSTANCE`, read through
3264
+ service whose `computedSlotStoreService` is `OIMSingleSlotStoreService.INSTANCE`, read through
3361
3265
  `useOIMComputedValueByKey(computedService.computedCollectionService, computedStoreSlot, undefined)`, so a flush
3362
3266
  between render and subscribe is safe.
3363
3267
  `useOIMSlotsByKeys(slotCollectionService, storeSlot, keys)` holds the identities its keys name for as long as the
3364
3268
  component is mounted (a `holdSlotByKey` per key on commit, the same holds given back on unmount or when the list
3365
3269
  moves) and subscribes to nothing; it needs no provider.
3366
- Singles are not keyed collections: `createOIMSingleEntityService(db)` / `createOIMSingleBucketService(db)` return an
3367
- `OIMSingleSlotService` (`getSingleSlot(storeSlot)`, `holdSingleSlot(storeSlot)`), read by
3270
+ Singles are not keyed collections: `createOIMSingleSlotService({ slotContentService })` returns an
3271
+ `OIMWritableSingleSlotService` (`getSingleSlot(storeSlot)`, `holdSingleSlot(storeSlot)`), read by
3368
3272
  `useOIMSingleEntity(singleSlotService, singleSlotStoreSlot): TEntity | undefined` and
3369
3273
  `useOIMSingleBucketSlots(singleSlotService, singleSlotStoreSlot): readonly TOIMEntitySlot<TEntity>[]` — the by-key
3370
3274
  mechanics without a key (getSingleSlot on render, holdSingleSlot on commit, resolve-hold given back once the
@@ -3375,11 +3279,12 @@ re-read the imports rather than trusting this table.
3375
3279
 
3376
3280
  | Package | Imports from the core | Imports a name the core does not export? |
3377
3281
  |---|---|---|
3378
- | `@oimdb/persist` | `IOIMChannelMap`, `IOIMContentChangeListener`, `IOIMEntityWriter`, `OIMChannelService`, `OIMColumnOwnerPushDriven`, `OIMContentChangeListenerService`, `OIMEffectService`, `OIMSingleSlotService`, `OIMSlotCollectionService`, `OIMSlotContentBucketService`, `OIMSlotContentEntityService`, `TOIMBucketSlot`, `TOIMChannelClock`, `TOIMChannelSlot`, `TOIMColumnSlot`, `TOIMDefaultSlotStore`, `TOIMDisposer`, `TOIMEffect`, `TOIMEntitySlot`, `TOIMSingleSlotStore`, `TOIMSingleSlotStoreSlot`, `TOIMSlot`, `TOIMStoreKey`, `TOIMStoreSlot`, `oimHoldSlots`, `oimIsAnySlotChangedAfter`, `oimIsSlotReleased`, `oimSameValueZeroGuard`, `oimUnholdSlotsIfAlive` | no |
3379
- | `@oimdb/redux-adapter` | `IOIMChannelMap`, `IOIMContentChangeListener`, `OIMChannelService`, `OIMColumnOwnerPushDriven`, `OIMContentChangeListenerService`, `OIMEffectService`, `OIMSlotCollectionService`, `OIMSlotContentBucketService`, `OIMSlotContentEntityService`, `TOIMBucketSlot`, `TOIMColumnSlot`, `TOIMDefaultSlotStore`, `TOIMDisposer`, `TOIMEffect`, `TOIMEntityColumnName`, `TOIMEntitySlot`, `TOIMStoreKey`, `TOIMStoreSlot` | no |
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 |
3380
3284
  | `@oimdb/exodra` | `IOIMChannelMap`, `OIMChannelService`, `OIMEffectService`, `OIMSlotContentBucketService`, `OIM_FROZEN_EMPTY_SLOTS`, `TOIMBucketSlot`, `TOIMComputedSlot`, `TOIMDisposer`, `TOIMEffect`, `TOIMEntitySlot`, `TOIMOrderedListCommand`, `TOIMSlot`, `TOIMStoreKey`, `oimOrderedListCommandsOfTransition`, `oimRefreshSourceComputeds`, `oimSameKeySequence` | no |
3381
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 |
3382
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 |
3383
3288
  | `@oimdb/async` | — | deprecated stub, `export {}` |
3384
3289
  | `@oimdb/snapshot-manager` | — | empty stub |
3385
3290
 
@@ -3397,10 +3302,10 @@ Every constructor in the family takes ONE options object.
3397
3302
  | package | exports |
3398
3303
  |---|---|
3399
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`) |
3400
- | `@oimdb/persist-memory` | `OIMPersistMemoryBackend`, `OIMPersistMemoryWriteBatch`, `OIMPersistMemoryRecordsStrategy`, `OIMPersistMemorySnapshotStrategy`, `TOIMPersistMemoryStrategyOptions` |
3401
- | `@oimdb/persist-json` | `OIMPersistJsonBackend`, `TOIMPersistJsonBackendOptions`, `TOIMPersistJsonDump`; depends on `@oimdb/persist-memory` |
3402
- | `@oimdb/persist-idb` | `OIMPersistIndexedDbBackend`, `OIMPersistIndexedDbWriteBatch`, `OIMPersistIndexedDbRecordsStrategy`, `OIMPersistIndexedDbSnapshotStrategy`, `TOIMPersistIndexedDbBackendOptions`, `TOIMPersistIndexedDbRecordsStrategyOptions`, `TOIMPersistIndexedDbSnapshotStrategyOptions`, `TOIMPersistIndexedDbPrimaryKey`, `TOIMPersistIndexedDbTableRecord` |
3403
- | `@oimdb/persist-localstorage` | `OIMPersistLocalStorageBackend`, `TOIMPersistLocalStorageBackendOptions` |
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` |
3404
3309
  | `@oimdb/persist-async-kv` | `OIMPersistAsyncKVStorage`, `TOIMPersistAsyncKVStorageOptions`, `TOIMPersistAsyncKVMultiStorage` |
3405
3310
 
3406
3311
  ### The wiring
@@ -3411,42 +3316,37 @@ lives in the backend) and what to do on hydrate.
3411
3316
 
3412
3317
  ```ts
3413
3318
  import {
3414
- OIMPersistEntityWriter, OIMPersistRecordSourceCollectionWriteDriven, OIMPersistResourceRecords,
3415
- OIMPersistSchedulerMicrotaskDriven, OIMPersistor,
3319
+ OIMPersistEntityWriter, OIMPersistCollectionWriteRecordSource, OIMPersistRecordsResource,
3320
+ OIMPersistMicrotaskScheduler, OIMPersistor,
3416
3321
  } from '@oimdb/persist';
3417
- import { OIMPersistMemoryBackend, OIMPersistMemoryRecordsStrategy, OIMPersistMemoryWriteBatch } from '@oimdb/persist-memory';
3322
+ import { OIMPersistMemoryBackend, OIMPersistMemoryRecordsService, OIMPersistMemoryWriteBatch } from '@oimdb/persist-memory';
3418
3323
 
3419
3324
  const persistEntityWriter = new OIMPersistEntityWriter<Card, TOIMStoreKey>({
3420
- entityWriter: createOIMEntityWriterInPlace(),
3421
- });
3422
- const persistedCardCollectionService = createOIMEntityService<Card>(db, {
3423
- keyOf: card => card.id,
3424
- entityWriter: persistEntityWriter,
3325
+ entityWriter: createOIMInPlaceEntityWriter(),
3425
3326
  });
3327
+ const persistedCardCollectionService = createOIMSlotCollectionService({ slotContentService: createOIMSlotContentEntityService<Card>({ entityWriter: persistEntityWriter }) });
3426
3328
  const persistedCardEntityStoreSlot = persistedCardCollectionService.createStoreSlot(db);
3427
3329
 
3428
3330
  const persistMemoryBackend = new OIMPersistMemoryBackend();
3429
3331
  const persistor = new OIMPersistor<OIMPersistMemoryBackend, OIMPersistMemoryWriteBatch>({
3430
3332
  persistBackend: persistMemoryBackend,
3431
- persistScheduler: new OIMPersistSchedulerMicrotaskDriven(),
3333
+ persistScheduler: new OIMPersistMicrotaskScheduler(),
3432
3334
  onPersistError: (error, persistErrorContext) => {
3433
3335
  console.error(persistErrorContext.persistOperation, error);
3434
3336
  },
3435
3337
  });
3436
- const cardPersistResourceRecords = persistor.addPersistResource(new OIMPersistResourceRecords({
3437
- persistRecordSource: new OIMPersistRecordSourceCollectionWriteDriven({
3338
+ const cardPersistResourceRecords = persistor.addPersistResource(new OIMPersistRecordsResource({
3339
+ persistRecordSource: new OIMPersistCollectionWriteRecordSource({
3438
3340
  entityCollectionService: persistedCardCollectionService,
3439
3341
  entityStoreSlot: persistedCardEntityStoreSlot,
3440
3342
  persistEntityWriter,
3441
3343
  }),
3442
- persistRecordsStrategy: new OIMPersistMemoryRecordsStrategy<TOIMStoreKey, Card>({ storageKey: 'cards' }),
3344
+ persistRecordsService: new OIMPersistMemoryRecordsService<TOIMStoreKey, Card>({ storageKey: 'cards' }),
3443
3345
  }));
3444
3346
 
3445
3347
  await persistor.hydrate();
3446
3348
  persistor.start();
3447
- persistedCardCollectionService.slotContentService.upsertEntity(
3448
- persistedCardEntityStoreSlot, { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' },
3449
- );
3349
+ persistedCardCollectionService.writeSlotContentByKey(persistedCardEntityStoreSlot, 'c1', { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' });
3450
3350
  await persistor.persist();
3451
3351
  await persistor.unload(cardPersistResourceRecords, ['c1']);
3452
3352
  await persistor.load(cardPersistResourceRecords, ['c1']);
@@ -3456,8 +3356,8 @@ Rules a generator must keep:
3456
3356
 
3457
3357
  - Pass both generics of `OIMPersistor<TPersistBackend, TWriteBatch>`: the backend
3458
3358
  class and its write batch class (`OIMPersistMemoryWriteBatch`, `OIMPersistIndexedDbWriteBatch`,
3459
- `OIMPersistKeyValueWriteBatch`; `OIMPersistJsonBackend` uses `OIMPersistMemoryWriteBatch`,
3460
- `OIMPersistLocalStorageBackend` uses `OIMPersistKeyValueWriteBatch`).
3359
+ `OIMPersistKeyValueWriteBatch`; `OIMPersistJsonMemoryBackend` uses `OIMPersistMemoryWriteBatch`,
3360
+ `OIMPersistLocalStorageKeyValueBackend` uses `OIMPersistKeyValueWriteBatch`).
3461
3361
  - Pass the generics of strategies, codecs and `OIMPersistEntityWriter<TEntity, TKey>`
3462
3362
  explicitly; their options carry no type to infer them from.
3463
3363
  - An entity collection persisted through a collection source is built with the
@@ -3469,9 +3369,9 @@ Rules a generator must keep:
3469
3369
  `new OIMPersistEntityWriter({ entityWriter })` wraps the `IOIMEntityWriter` that
3470
3370
  really writes, and reports the entity slot of every fill, put, patch, column patch,
3471
3371
  column delete and clear that changed something (`subscribeEntitySlotWritten`).
3472
- - The entity collection service a source takes is any collection service with `getSlots`, `findSlotByKey` and a
3473
- `slotContentService` with `upsertEntities`, `deleteEntity` and `deleteEntities`
3474
- (`TOIMPersistEntityCollectionService`), so `createOIMEntityService(db, …)` and the base-layer assembly both fit.
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.
3475
3375
  - A source refuses, by name, a store slot on another channel slot than the one it was given (the bucket source
3476
3376
  checks both the entity and the bucket store slot).
3477
3377
  - Order: `addPersistResource` every resource, `await persistor.hydrate()`, then
@@ -3482,29 +3382,29 @@ Rules a generator must keep:
3482
3382
  | constructor | options |
3483
3383
  |---|---|
3484
3384
  | `OIMPersistor` | `{ persistBackend, persistScheduler, onPersistError? }` |
3485
- | `OIMPersistResourceRecords` | `{ persistRecordSource, persistRecordsStrategy, persistHydrateReconcile? }` |
3486
- | `OIMPersistResourceSnapshot` | `{ persistSnapshotSource, persistSnapshotStrategy, persistCodec, persistHydrateReconcile? }` |
3487
- | `OIMPersistRecordSourceCollectionWriteDriven`, `OIMPersistRecordSourceCollectionChangeDriven` | `{ entityCollectionService, entityStoreSlot, persistEntityWriter }` (`TOIMPersistRecordSourceCollectionOptions`) |
3488
- | `OIMPersistRecordSourceBucketDriven` | `{ channelMap, entityCollectionService, entityStoreSlot, slotContentBucketService, bucketSlot }` — `channelMap` is the db, `slotContentBucketService` the bucket collection's `slotContentService` |
3489
- | `OIMPersistSnapshotSourceSingleEntityDriven` | `{ channelMap, singleSlotService, entityStoreSlot }` — a single entity service (`createOIMSingleEntityService`) and one of its store slots (`TOIMSingleSlotStoreSlot`) |
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`) |
3490
3390
  | `OIMPersistEntityWriter` | `{ entityWriter }` |
3491
- | `OIMPersistSchedulerMicrotaskDriven` | none |
3492
- | `OIMPersistSchedulerFrameDriven` | `{ channelSlot }` — `db.defaultChannelSlot` |
3493
- | `OIMPersistCodecIdentity` | none |
3494
- | `OIMPersistCodecVersioned` | `{ version, migrations, persistCodec }` |
3391
+ | `OIMPersistMicrotaskScheduler` | none |
3392
+ | `OIMPersistFrameScheduler` | `{ channelSlot }` — `db.defaultChannelSlot` |
3393
+ | `OIMPersistIdentityCodec` | none |
3394
+ | `OIMPersistVersionedCodec` | `{ version, migrations, persistCodec }` |
3495
3395
  | `OIMPersistMemoryBackend` | none |
3496
- | `OIMPersistMemoryRecordsStrategy`, `OIMPersistMemorySnapshotStrategy` | `{ storageKey }` |
3497
- | `OIMPersistJsonBackend` | `{ initialPersistJsonDump? }`, the object itself optional |
3396
+ | `OIMPersistMemoryRecordsService`, `OIMPersistMemorySnapshotService` | `{ storageKey }` |
3397
+ | `OIMPersistJsonMemoryBackend` | `{ initialPersistJsonDump? }`, the object itself optional |
3498
3398
  | `OIMPersistIndexedDbBackend` | `{ databaseName, databaseVersion?, indexedDb? }` |
3499
- | `OIMPersistIndexedDbRecordsStrategy` | `{ tableName }` |
3500
- | `OIMPersistIndexedDbSnapshotStrategy` | `{ tableName, primaryKey }` |
3399
+ | `OIMPersistIndexedDbRecordsService` | `{ tableName }` |
3400
+ | `OIMPersistIndexedDbSnapshotService` | `{ tableName, primaryKey }` |
3501
3401
  | `OIMPersistKeyValueBackend` | `{ persistKeyValueStorage, persistKeyValueSerializer? }` |
3502
- | `OIMPersistKeyValueStorageItemDriven` | `{ persistKeyValueItemStorage }` |
3503
- | `OIMPersistKeyValueEntryStrategy` | `{ storageKey }` |
3504
- | `OIMPersistKeyValuePathStrategy` | `{ storageKey, path }` |
3505
- | `OIMPersistLocalStorageBackend` | `{ persistKeyValueItemStorage?, persistKeyValueSerializer? }`, the object itself optional |
3506
- | `OIMPersistSourceChangeMarkSlotDriven` | `{ channelClock, slots }` |
3507
- | `OIMPersistSourceChangeMarkSubscriptionDriven` | `{ subscribeSourceChanged }` |
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 }` |
3508
3408
  | `OIMPersistAsyncKVStorage` | `{ persistAsyncKVMultiStorage }` |
3509
3409
 
3510
3410
  `onPersistError(error, persistErrorContext)` receives a `TOIMPersistErrorContext`,
@@ -3515,10 +3415,10 @@ or collected into the `AggregateError`.
3515
3415
 
3516
3416
  ### Two resource kinds, and when to pick each
3517
3417
 
3518
- | | `OIMPersistResourceRecords` | `OIMPersistResourceSnapshot` |
3418
+ | | `OIMPersistRecordsResource` | `OIMPersistSnapshotResource` |
3519
3419
  |---|---|---|
3520
3420
  | source | a record source: entity store slot or bucket | any snapshot source: entity store slot, bucket or single entity |
3521
- | strategy | `OIMPersistMemoryRecordsStrategy`, `OIMPersistIndexedDbRecordsStrategy` | `OIMPersistMemorySnapshotStrategy`, `OIMPersistIndexedDbSnapshotStrategy`, `OIMPersistKeyValueEntryStrategy`, `OIMPersistKeyValuePathStrategy` |
3421
+ | strategy | `OIMPersistMemoryRecordsService`, `OIMPersistIndexedDbRecordsService` | `OIMPersistMemorySnapshotService`, `OIMPersistIndexedDbSnapshotService`, `OIMPersistKeyValueEntrySnapshotService`, `OIMPersistKeyValuePathSnapshotService` |
3522
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 |
3523
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 |
3524
3424
  | codec | none | `persistCodec`, required |
@@ -3544,10 +3444,10 @@ slot wins: an upsert, not a deletion.
3544
3444
 
3545
3445
  | source | reports | scheduler |
3546
3446
  |---|---|---|
3547
- | `OIMPersistRecordSourceCollectionWriteDriven` | synchronously, at the write, from `OIMPersistEntityWriter` only | `OIMPersistSchedulerMicrotaskDriven` |
3548
- | `OIMPersistRecordSourceCollectionChangeDriven` | synchronously, at the write, from `OIMPersistEntityWriter` and from a content-change listener on the entity store slot | `OIMPersistSchedulerMicrotaskDriven` |
3549
- | `OIMPersistRecordSourceBucketDriven` | from effects (`OIMEffectService`), when the channel runs its frame | `OIMPersistSchedulerFrameDriven({ channelSlot })` |
3550
- | `OIMPersistSnapshotSourceSingleEntityDriven` | from an effect, when the channel runs its frame | `OIMPersistSchedulerFrameDriven({ channelSlot })` |
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 })` |
3551
3451
 
3552
3452
  - **Collection** (an entity collection service and one of its store slots): every entity slot of that store slot that holds an entity. Starting
3553
3453
  reports nothing; content present before `start()` is written by `persist()` or by
@@ -3558,8 +3458,8 @@ slot wins: an upsert, not a deletion.
3558
3458
 
3559
3459
  | collection source | in-place edit announced with `slotContentService.markEntityChanged(entitySlot, changedColumns?)` | cost while a resource listens | pick it when |
3560
3460
  |---|---|---|---|
3561
- | `OIMPersistRecordSourceCollectionWriteDriven` | NOT persisted: it hears only the writer | none: every write stays on the core fast path | every change goes through the upsert / update / delete doors |
3562
- | `OIMPersistRecordSourceCollectionChangeDriven` | persisted: while subscribed it adds a content-change listener of every column to the store slot | EVERY write to that store slot leaves the core fast path, not only `markEntityChanged`: one subframe open/close, changed-column bookkeeping and the listener loop per write | the app edits entities in place |
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 |
3563
3463
  - **Bucket**: the entities one bucket slot holds. Get the slot with
3564
3464
  `bucketCollectionService.holdSlotByKey(bucketStoreSlot, key)`. The source watches the
3565
3465
  bucket slot with one effect and each member entity slot with one effect. An entity
@@ -3569,7 +3469,7 @@ slot wins: an upsert, not a deletion.
3569
3469
  hydrate restored.
3570
3470
  - **Single entity**: the snapshot is the entity itself (a store slot of a single entity service, read through
3571
3471
  `findSingleSlot(entityStoreSlot)`), so it
3572
- goes only into `OIMPersistResourceSnapshot`. Its first subscription reports the
3472
+ goes only into `OIMPersistSnapshotResource`. Its first subscription reports the
3573
3473
  current value unless it is the value a hydrate restored. `readSnapshot` throws when
3574
3474
  the entity slot holds no value.
3575
3475
 
@@ -3582,38 +3482,36 @@ frame, are both written.
3582
3482
 
3583
3483
  ```ts
3584
3484
  import {
3585
- OIMPersistRecordSourceBucketDriven, OIMPersistResourceRecords, OIMPersistSchedulerFrameDriven, OIMPersistor,
3485
+ OIMPersistBucketRecordSource, OIMPersistRecordsResource, OIMPersistFrameScheduler, OIMPersistor,
3586
3486
  } from '@oimdb/persist';
3587
- import { OIMPersistMemoryBackend, OIMPersistMemoryRecordsStrategy, OIMPersistMemoryWriteBatch } from '@oimdb/persist-memory';
3487
+ import { OIMPersistMemoryBackend, OIMPersistMemoryRecordsService, OIMPersistMemoryWriteBatch } from '@oimdb/persist-memory';
3588
3488
 
3589
3489
  type TDeckCardSlot = TOIMEntitySlot<Card, TOIMStoreKey>;
3590
- const deckCardCollectionService = createOIMEntityService<Card>(db, { keyOf: card => card.id });
3490
+ const deckCardCollectionService = createOIMSlotCollectionService({ slotContentService: createOIMSlotContentEntityService<Card>() });
3591
3491
  const deckCardEntityStoreSlot = deckCardCollectionService.createStoreSlot(db);
3592
- const deckBucketService = createOIMBucketService<TDeckCardSlot>(db);
3492
+ const deckBucketService = createOIMSlotCollectionService({ slotContentService: new OIMSlotContentPositionalArrayBucketService<TDeckCardSlot>({ bucketCommands: new OIMBucketCommands({ maxCommandsPerSlot: undefined }) }) });
3593
3493
  const deckBucketStoreSlot = deckBucketService.createStoreSlot(db);
3594
3494
  const spadesBucketSlot = deckBucketService.holdSlotByKey(deckBucketStoreSlot, 'spades');
3595
3495
 
3596
3496
  const deckPersistor = new OIMPersistor<OIMPersistMemoryBackend, OIMPersistMemoryWriteBatch>({
3597
3497
  persistBackend: new OIMPersistMemoryBackend(),
3598
- persistScheduler: new OIMPersistSchedulerFrameDriven({ channelSlot: db.defaultChannelSlot }),
3498
+ persistScheduler: new OIMPersistFrameScheduler({ channelSlot: db.defaultChannelSlot }),
3599
3499
  });
3600
- deckPersistor.addPersistResource(new OIMPersistResourceRecords({
3601
- persistRecordSource: new OIMPersistRecordSourceBucketDriven({
3500
+ deckPersistor.addPersistResource(new OIMPersistRecordsResource({
3501
+ persistRecordSource: new OIMPersistBucketRecordSource({
3602
3502
  channelMap: db,
3603
3503
  entityCollectionService: deckCardCollectionService,
3604
3504
  entityStoreSlot: deckCardEntityStoreSlot,
3605
3505
  slotContentBucketService: deckBucketService.slotContentService,
3606
3506
  bucketSlot: spadesBucketSlot,
3607
3507
  }),
3608
- persistRecordsStrategy: new OIMPersistMemoryRecordsStrategy<TOIMStoreKey, Card>({ storageKey: 'spades' }),
3508
+ persistRecordsService: new OIMPersistMemoryRecordsService<TOIMStoreKey, Card>({ storageKey: 'spades' }),
3609
3509
  }));
3610
3510
  await deckPersistor.hydrate();
3611
3511
  deckPersistor.start();
3612
3512
 
3613
- const aceSlot = deckCardCollectionService.slotContentService.upsertEntity(
3614
- deckCardEntityStoreSlot, { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' },
3615
- );
3616
- deckBucketService.slotContentService.writeBucketMemberSlots(spadesBucketSlot, [aceSlot]);
3513
+ const aceSlot = deckCardCollectionService.writeSlotContentByKey(deckCardEntityStoreSlot, 'c1', { id: 'c1', title: 'Ace', rank: 1, deck: 'spades' });
3514
+ deckBucketService.slotContentService.writeSlotContent(spadesBucketSlot, [aceSlot]);
3617
3515
  db.flush();
3618
3516
  await deckPersistor.whenIdle();
3619
3517
  ```
@@ -3624,10 +3522,10 @@ await deckPersistor.whenIdle();
3624
3522
  The persistor calls `attachDrain` from its constructor; both shipped schedulers throw
3625
3523
  when a second persistor attaches, so construct one scheduler per persistor.
3626
3524
 
3627
- - `OIMPersistSchedulerMicrotaskDriven`: `requestDrain()` queues one microtask; the
3525
+ - `OIMPersistMicrotaskScheduler`: `requestDrain()` queues one microtask; the
3628
3526
  requests that follow before it runs are absorbed by it. `stop()` makes a queued
3629
3527
  microtask do nothing.
3630
- - `OIMPersistSchedulerFrameDriven({ channelSlot })`: while started it subscribes with
3528
+ - `OIMPersistFrameScheduler({ channelSlot })`: while started it subscribes with
3631
3529
  `OIMChannelService.INSTANCE.subscribeAfterEachFrame(channelSlot, …)` and drains after a frame only when a drain was
3632
3530
  requested since the last one; `stop()` unsubscribes and forgets the request. It
3633
3531
  holds no slot and never asks the channel for a frame: it drains only after a frame
@@ -3636,11 +3534,11 @@ when a second persistor attaches, so construct one scheduler per persistor.
3636
3534
  **The pairing is a rule.** A write to an entity slot that nobody holds causes no frame
3637
3535
  (`OIMChannelService.INSTANCE.writeWithoutFlushCheck` returns at once when `holdCount` is `0`), so a
3638
3536
  collection source on the frame scheduler can report changes that no frame ever
3639
- drains. Pair `OIMPersistRecordSourceCollectionWriteDriven` and
3640
- `OIMPersistRecordSourceCollectionChangeDriven` with
3641
- `OIMPersistSchedulerMicrotaskDriven`. The bucket and single-entity sources report from
3537
+ drains. Pair `OIMPersistCollectionWriteRecordSource` and
3538
+ `OIMPersistCollectionChangeWriteRecordSource` with
3539
+ `OIMPersistMicrotaskScheduler`. The bucket and single-entity sources report from
3642
3540
  effects, which cause the frames they run in, so pair them with
3643
- `OIMPersistSchedulerFrameDriven`. Resources of both groups in one persistor share its
3541
+ `OIMPersistFrameScheduler`. Resources of both groups in one persistor share its
3644
3542
  one scheduler; give them two persistors when they need different schedulers.
3645
3543
 
3646
3544
  `whenIdle()` resolves when the work queued at the moment of the call has settled. On
@@ -3660,8 +3558,8 @@ is queued on the microtask after the write: let one microtask pass before callin
3660
3558
  | `hydrate()` | reads every resource and writes it into its source; ends the upsert-only mode of an unloaded resource |
3661
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 |
3662
3560
  | `clearPersisted()` | deletes every resource's stored data in one write batch; memory is untouched; ends the upsert-only mode of an unloaded resource |
3663
- | `unload(persistResourceRecords, keys)` | writes the keys' current values plus the resource's pending change in one write batch, repeated until the keys have no unwritten change (unload holds the keys' entity slots while it writes and compares their revision stamps, a slot's stamp moving only on a real write; it also checks the resource's pending set and whether a key's slot was replaced), then removes the keys from memory only |
3664
- | `load(persistResourceRecords, keys)` | the pair of `unload`: queued after the pending writes, reads only those keys from the backend (`readRecordsOfKeys`) and puts the stored entities back without writing them back; skips a key that holds a value in memory or has a change the resource has not written yet; the keys stop counting as unloaded |
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 |
3665
3563
  | `whenIdle()` | resolves when the queued work settles; never rejects |
3666
3564
 
3667
3565
  Every promise-returning call and every autosave runs on one ordered chain, one at a
@@ -3712,7 +3610,7 @@ What the source does with it:
3712
3610
  name; the store slot becomes exactly the stored content;
3713
3611
  - bucket: upserts every record into the entity store slot and sets the bucket's
3714
3612
  members to exactly the restored entities;
3715
- - single entity: `singleSlotService.slotContentService.upsertEntity(entityStoreSlot, entity)`.
3613
+ - single entity: `singleSlotService.writeSingleSlotContent(entityStoreSlot, entity)`.
3716
3614
 
3717
3615
  **Refusal.** If the source changes while the backend read is in flight and the
3718
3616
  resource has no `persistHydrateReconcile`, hydrate throws instead of applying, because
@@ -3720,10 +3618,10 @@ applying would revert that change. The source keeps its newer state; the error g
3720
3618
  to `onPersistError` with `'hydrate'`, or into the `AggregateError`. Before the read
3721
3619
  the resource calls `persistSnapshotSource.markSourceChanges()`, which returns an
3722
3620
  `IOIMPersistSourceChangeMark` (`isSourceChangedSinceMark()`, `dispose()`): the
3723
- single-entity and bucket sources return an `OIMPersistSourceChangeMarkSlotDriven`
3621
+ single-entity and bucket sources return an `OIMPersistSlotSourceChangeMark`
3724
3622
  that holds the slots and compares their revision stamps (`changedAtRevision`, `changedAtEpoch`) with the
3725
3623
  channel clock reading taken at the mark (`oimIsAnySlotChangedAfter`), the
3726
- collection sources an `OIMPersistSourceChangeMarkSubscriptionDriven` whose flag their
3624
+ collection sources an `OIMPersistSubscriptionSourceChangeMark` whose flag their
3727
3625
  own report sets. A change made during the read is seen even when no frame runs, and
3728
3626
  the guard runs whether or not the persistor is started.
3729
3627
 
@@ -3747,7 +3645,7 @@ const answerReconcile = oimReconcileByKey<TOIMStoreKey, Card>(
3747
3645
 
3748
3646
  ### unload, load and clearPersisted
3749
3647
 
3750
- `persistor.unload(persistResourceRecords, keys)` takes an `OIMPersistResourceRecords`
3648
+ `persistor.unload(persistRecordsResource, keys)` takes an `OIMPersistRecordsResource`
3751
3649
  only. It first drains the persistor's dirty resources onto the chain, then, in its
3752
3650
  own turn, writes in one write batch that resource's pending change and the current
3753
3651
  value of every unloaded key (`persistRecordSource.readRecordsDelta(persistRecordSource.findEntitySlotsByKeys(keys))`), so
@@ -3775,11 +3673,11 @@ stay on the backend. A deletion it never heard is not written in that mode.
3775
3673
  `hydrate()` and `clearPersisted()` end that mode, and so does a `load` of every
3776
3674
  unloaded key.
3777
3675
 
3778
- `persistor.load(persistResourceRecords, keys)` is the pair of `unload` and takes an
3779
- `OIMPersistResourceRecords` only (a key-value backend has no records strategy, so a
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
3780
3678
  snapshot resource has no `load`). It drains the dirty resources onto the chain and
3781
3679
  runs after those writes. In its turn it reads only those keys through
3782
- `persistRecordsStrategy.readRecordsOfKeys(persistBackend, keys)`: on IndexedDB one
3680
+ `persistRecordsService.readRecordsOfKeys(persistBackend, keys)`: on IndexedDB one
3783
3681
  `readonly` transaction with one `get` per key, on memory a lookup of each key in the
3784
3682
  stored map; a key the backend does not hold is left out. It skips every key that holds
3785
3683
  a value in memory or has a change the resource has not written yet (pending or in a
@@ -3819,10 +3717,10 @@ required `persistCodec`, an `IOIMPersistCodec<TSourceSnapshot, TPersistedSnapsho
3819
3717
  (`encode`, `decode`). A records resource has no codec: it stores entities as the
3820
3718
  source holds them.
3821
3719
 
3822
- - `new OIMPersistCodecIdentity<TSnapshot>()`: passes the snapshot through.
3823
- - `new OIMPersistCodecVersioned<TSourceSnapshot, TPersistedSnapshot>({ version, migrations, persistCodec })`:
3720
+ - `new OIMPersistIdentityCodec<TSnapshot>()`: passes the snapshot through.
3721
+ - `new OIMPersistVersionedCodec<TSourceSnapshot, TPersistedSnapshot>({ version, migrations, persistCodec })`:
3824
3722
  stores a `TOIMPersistVersionedShape`, `{ version, data }`, where `data` is the
3825
- inner `persistCodec`'s encoding (required; use `OIMPersistCodecIdentity` when
3723
+ inner `persistCodec`'s encoding (required; use `OIMPersistIdentityCodec` when
3826
3724
  nothing transforms). `migrations` is a `Record<number, (data: unknown) => unknown>`
3827
3725
  keyed by the version each migration produces. On decode it runs, ascending, every
3828
3726
  migration whose key is above the stored `version` and at most the current one,
@@ -3832,21 +3730,21 @@ source holds them.
3832
3730
 
3833
3731
  ```ts
3834
3732
  import {
3835
- OIMPersistCodecIdentity, OIMPersistCodecVersioned, OIMPersistKeyValueBackend, OIMPersistKeyValueEntryStrategy,
3836
- OIMPersistKeyValueStorageItemDriven, OIMPersistKeyValueWriteBatch, OIMPersistResourceSnapshot,
3837
- OIMPersistSchedulerFrameDriven, OIMPersistSnapshotSourceSingleEntityDriven, OIMPersistor,
3733
+ OIMPersistIdentityCodec, OIMPersistVersionedCodec, OIMPersistKeyValueBackend, OIMPersistKeyValueEntrySnapshotService,
3734
+ OIMPersistItemKeyValueStorage, OIMPersistKeyValueWriteBatch, OIMPersistSnapshotResource,
3735
+ OIMPersistFrameScheduler, OIMPersistSingleEntitySnapshotSource, OIMPersistor,
3838
3736
  } from '@oimdb/persist';
3839
3737
  import type { TOIMPersistVersionedShape } from '@oimdb/persist';
3840
3738
 
3841
3739
  type TSettings = { theme: 'light' | 'dark'; density: 'compact' | 'comfortable' };
3842
3740
 
3843
- const settingsSingleEntityService = createOIMSingleEntityService<TSettings>(db);
3741
+ const settingsSingleEntityService = createOIMSingleSlotService({ slotContentService: createOIMSlotContentEntityService<TSettings>() });
3844
3742
  const settingsEntityStoreSlot = settingsSingleEntityService.createStoreSlot(db);
3845
- settingsSingleEntityService.slotContentService.upsertEntity(settingsEntityStoreSlot, { theme: 'light', density: 'compact' });
3743
+ settingsSingleEntityService.writeSingleSlotContent(settingsEntityStoreSlot, { theme: 'light', density: 'compact' });
3846
3744
  const itemByStorageKey = new Map<string, string>();
3847
3745
  const settingsPersistor = new OIMPersistor<OIMPersistKeyValueBackend, OIMPersistKeyValueWriteBatch>({
3848
3746
  persistBackend: new OIMPersistKeyValueBackend({
3849
- persistKeyValueStorage: new OIMPersistKeyValueStorageItemDriven({
3747
+ persistKeyValueStorage: new OIMPersistItemKeyValueStorage({
3850
3748
  persistKeyValueItemStorage: {
3851
3749
  getItem: storageKey => itemByStorageKey.get(storageKey) ?? null,
3852
3750
  setItem: (storageKey, item) => { itemByStorageKey.set(storageKey, item); },
@@ -3854,17 +3752,17 @@ const settingsPersistor = new OIMPersistor<OIMPersistKeyValueBackend, OIMPersist
3854
3752
  },
3855
3753
  }),
3856
3754
  }),
3857
- persistScheduler: new OIMPersistSchedulerFrameDriven({ channelSlot: db.defaultChannelSlot }),
3755
+ persistScheduler: new OIMPersistFrameScheduler({ channelSlot: db.defaultChannelSlot }),
3858
3756
  });
3859
- settingsPersistor.addPersistResource(new OIMPersistResourceSnapshot({
3860
- persistSnapshotSource: new OIMPersistSnapshotSourceSingleEntityDriven({
3757
+ settingsPersistor.addPersistResource(new OIMPersistSnapshotResource({
3758
+ persistSnapshotSource: new OIMPersistSingleEntitySnapshotSource({
3861
3759
  channelMap: db, singleSlotService: settingsSingleEntityService, entityStoreSlot: settingsEntityStoreSlot,
3862
3760
  }),
3863
- persistSnapshotStrategy: new OIMPersistKeyValueEntryStrategy<TOIMPersistVersionedShape>({ storageKey: 'settings' }),
3864
- persistCodec: new OIMPersistCodecVersioned<TSettings, TSettings>({
3761
+ persistSnapshotService: new OIMPersistKeyValueEntrySnapshotService<TOIMPersistVersionedShape>({ storageKey: 'settings' }),
3762
+ persistCodec: new OIMPersistVersionedCodec<TSettings, TSettings>({
3865
3763
  version: 2,
3866
3764
  migrations: { 2: data => ({ density: 'comfortable', ...(data as object) }) },
3867
- persistCodec: new OIMPersistCodecIdentity<TSettings>(),
3765
+ persistCodec: new OIMPersistIdentityCodec<TSettings>(),
3868
3766
  }),
3869
3767
  }));
3870
3768
  await settingsPersistor.hydrate();
@@ -3881,34 +3779,34 @@ await settingsPersistor.whenIdle();
3881
3779
 
3882
3780
  **Memory** (`@oimdb/persist-memory`). `new OIMPersistMemoryBackend()`. Snapshot values
3883
3781
  and records live in two separate maps, so a snapshot and a records resource may share
3884
- a `storageKey`. `OIMPersistMemoryRecordsStrategy<TKey, TEntity>({ storageKey })` keeps one
3782
+ a `storageKey`. `OIMPersistMemoryRecordsService<TKey, TEntity>({ storageKey })` keeps one
3885
3783
  `Map` of key to entity per `storageKey`: autosave sets and deletes keys, `persist()`
3886
- replaces the map (while the resource has unloaded keys, sets keys and deletes only the deletions the resource already heard), `clearPersisted()` deletes it, and a `load` looks each of its keys up in the map through `readRecordsOfKeys`. `OIMPersistMemorySnapshotStrategy<TSnapshot>({ storageKey })`
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 })`
3887
3785
  keeps one value. `readRecords(storageKey)` and `readSnapshotValue(storageKey)` read
3888
3786
  what was committed. `OIMPersistMemoryWriteBatch` queues every change and applies them in
3889
3787
  order on `commit()`.
3890
3788
 
3891
3789
  **JSON** (`@oimdb/persist-json`, depends on `@oimdb/persist-memory`).
3892
- `OIMPersistJsonBackend` extends `OIMPersistMemoryBackend` and takes the memory
3790
+ `OIMPersistJsonMemoryBackend` extends `OIMPersistMemoryBackend` and takes the memory
3893
3791
  strategies. `dehydrate()` returns a `TOIMPersistJsonDump`,
3894
3792
  `{ snapshotValues: Record<storageKey, value>, recordsByStorageKey: Record<storageKey, { key, entity }[]> }`;
3895
- `new OIMPersistJsonBackend({ initialPersistJsonDump })` writes such a dump straight into
3793
+ `new OIMPersistJsonMemoryBackend({ initialPersistJsonDump })` writes such a dump straight into
3896
3794
  its maps. The SSR flow: on the server, `await persistor.persist()` and then
3897
- `JSON.stringify(persistJsonBackend.dehydrate())`; on the client,
3898
- `new OIMPersistJsonBackend({ initialPersistJsonDump: JSON.parse(jsonText) })`, register
3795
+ `JSON.stringify(persistJsonMemoryBackend.dehydrate())`; on the client,
3796
+ `new OIMPersistJsonMemoryBackend({ initialPersistJsonDump: JSON.parse(jsonText) })`, register
3899
3797
  the same resources under the same `storageKey`s, `await persistor.hydrate()`. To merge
3900
3798
  a durable cache over the server's content, hydrate the JSON persistor first, then the
3901
3799
  durable persistor with `persistHydrateReconcile`, then `start()` the durable one.
3902
3800
 
3903
3801
  **IndexedDB** (`@oimdb/persist-idb`). `new OIMPersistIndexedDbBackend({ databaseName, databaseVersion?, indexedDb? })`;
3904
3802
  `indexedDb` defaults to `globalThis.indexedDB` and the constructor throws without
3905
- either. `OIMPersistIndexedDbRecordsStrategy<TKey, TEntity>({ tableName })` stores one value
3803
+ either. `OIMPersistIndexedDbRecordsService<TKey, TEntity>({ tableName })` stores one value
3906
3804
  per entity key in an object store: autosave puts and deletes per key, `persist()`
3907
3805
  clears the table and puts every entity (while the resource has unloaded keys, only
3908
3806
  puts plus a delete per deletion the resource already heard), `clearPersisted()` clears it,
3909
3807
  and a `load` reads its keys through `readRecordsOfKeys` in one `readonly` transaction,
3910
3808
  one `get` per key.
3911
- `OIMPersistIndexedDbSnapshotStrategy<TSnapshot>({ tableName, primaryKey })` stores one value
3809
+ `OIMPersistIndexedDbSnapshotService<TSnapshot>({ tableName, primaryKey })` stores one value
3912
3810
  at `primaryKey`. One commit is one `readwrite` transaction over every table the batch
3913
3811
  touches. A commit opens the database and creates missing tables by a version upgrade
3914
3812
  (retried when another connection upgrades first, up to five attempts; an upgrade
@@ -3920,7 +3818,7 @@ sorted property names, and is read back as that string.
3920
3818
  **Key-value** (`@oimdb/persist`). `new OIMPersistKeyValueBackend({ persistKeyValueStorage, persistKeyValueSerializer? })`
3921
3819
  stores serialized strings under string keys. `persistKeyValueStorage` is an
3922
3820
  `IOIMPersistKeyValueStorage` (`readItems(storageKeys)`, `writeItems(itemByStorageKey)`,
3923
- `removeItems(storageKeys)`); `OIMPersistKeyValueStorageItemDriven({ persistKeyValueItemStorage })`
3821
+ `removeItems(storageKeys)`); `OIMPersistItemKeyValueStorage({ persistKeyValueItemStorage })`
3924
3822
  adapts any `TOIMPersistKeyValueItemStorage` (`getItem`, `setItem`, `removeItem`,
3925
3823
  synchronous or returning promises) with one call per key. The serializer is a
3926
3824
  `TOIMPersistKeyValueSerializer` (`serialize`, `deserialize`) and defaults to
@@ -3928,15 +3826,15 @@ synchronous or returning promises) with one call per key. The serializer is a
3928
3826
 
3929
3827
  | strategy | stored as |
3930
3828
  |---|---|
3931
- | `OIMPersistKeyValueEntryStrategy<TSnapshot>({ storageKey })` | the whole snapshot under `storageKey` |
3932
- | `OIMPersistKeyValuePathStrategy<TSnapshot>({ storageKey, path })` | the snapshot at `path` (a `string[]`) inside an object under `storageKey`; several resources may share the key; the constructor throws on an empty `path` |
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` |
3933
3831
 
3934
3832
  `OIMPersistKeyValueWriteBatch` commits with at most one `readItems` (keys with path
3935
3833
  writes or path deletions and no whole-value write in the batch), one `writeItems` and
3936
3834
  one `removeItems`; it serializes every value before writing any. Path segments are
3937
3835
  own properties, so a stored `__proto__` is data.
3938
3836
 
3939
- - **localStorage** (`@oimdb/persist-localstorage`): `OIMPersistLocalStorageBackend`
3837
+ - **localStorage** (`@oimdb/persist-localstorage`): `OIMPersistLocalStorageKeyValueBackend`
3940
3838
  extends `OIMPersistKeyValueBackend` over a `TOIMPersistKeyValueItemStorage`
3941
3839
  (`getItem`, `setItem`, `removeItem`) given as `persistKeyValueItemStorage`, which
3942
3840
  defaults to `globalThis.localStorage`; the constructor throws without either. One `setItem` per written key.
@@ -3955,10 +3853,10 @@ own properties, so a stored `__proto__` is data.
3955
3853
  batch keeps them in: `addWriteBatchChange(change)`, `hasWriteBatchChanges`,
3956
3854
  `applyWriteBatchChanges(target)`. `oimWritePersistRecordsDelta(delta, putRecord, deleteRecord)`
3957
3855
  walks a `TOIMPersistRecordsDelta` in the one order every backend uses: upserts, then deletions.
3958
- - Snapshot strategy: `IOIMPersistSnapshotStrategy<TPersistBackend, TWriteBatch, TSnapshot>`
3856
+ - Snapshot strategy: `IOIMPersistSnapshotService<TPersistBackend, TWriteBatch, TSnapshot>`
3959
3857
  with `readSnapshot(persistBackend)`, `writeSnapshot(writeBatch, snapshot)`,
3960
3858
  `deleteSnapshot(writeBatch)`. Records strategy:
3961
- `IOIMPersistRecordsStrategy<TPersistBackend, TWriteBatch, TKey, TEntity>` with
3859
+ `IOIMPersistRecordsService<TPersistBackend, TWriteBatch, TKey, TEntity>` with
3962
3860
  `readRecords(persistBackend)`, `readRecordsOfKeys(persistBackend, keys)`,
3963
3861
  `writeRecords(writeBatch, persistRecords)`,
3964
3862
  `writeRecordsDelta(writeBatch, persistRecordsDelta)` (a `TOIMPersistRecordsDelta`,
@@ -3972,25 +3870,25 @@ own properties, so a stored `__proto__` is data.
3972
3870
  `loadRecords(persistRecords)`, `unloadKeys(keys)`.
3973
3871
  `markSourceChanges()` is required and returns an `IOIMPersistSourceChangeMark`
3974
3872
  (`isSourceChangedSinceMark()`, `dispose()`); build it with
3975
- `new OIMPersistSourceChangeMarkSlotDriven({ channelClock, slots })` or
3976
- `new OIMPersistSourceChangeMarkSubscriptionDriven({ subscribeSourceChanged })`.
3873
+ `new OIMPersistSlotSourceChangeMark({ channelClock, slots })` or
3874
+ `new OIMPersistSubscriptionSourceChangeMark({ subscribeSourceChanged })`.
3977
3875
  It must accept more than one subscription, each with its own `TOIMDisposer`.
3978
3876
  - Scheduler: `IOIMPersistScheduler`, above.
3979
3877
 
3980
3878
  ### Limits
3981
3879
 
3982
3880
  - **No key-value records strategy.** Over `OIMPersistKeyValueBackend` (localStorage,
3983
- async KV) an entity store slot or a bucket goes through `OIMPersistResourceSnapshot`: every
3881
+ async KV) an entity store slot or a bucket goes through `OIMPersistSnapshotResource`: every
3984
3882
  autosave walks every entity slot of the source and serializes the whole snapshot
3985
3883
  under one key.
3986
3884
  - **A bucket membership change walks the bucket.** Each change of the bucket slot
3987
3885
  re-reads every member and every watched slot, and the source keeps one effect per
3988
3886
  member entity slot.
3989
3887
  - **The memory and JSON backends keep values by reference.** With
3990
- `createOIMEntityWriterInPlace()` the stored entity is the live entity object, so an
3888
+ `createOIMInPlaceEntityWriter()` the stored entity is the live entity object, so an
3991
3889
  in-place write moves the stored value before any commit, and `dehydrate()` returns
3992
- the live objects. Use `createOIMEntityWriterReplacing()` with
3993
- `entityInsertStrategy: oimInsertEntityWithCopy` where the stored value must stay
3890
+ the live objects. Use `createOIMReplacingEntityWriter()` with
3891
+ `entityInserter: oimInsertEntityWithCopy` where the stored value must stay
3994
3892
  what was committed, and serialize a dump at once.
3995
3893
  - **Hydrate without a reconcile replaces.** The collection source deletes every
3996
3894
  entity the stored records do not name, and an existing but empty records table
@@ -4010,7 +3908,7 @@ only on the type-only `@exodra/reactivity-types`.
4010
3908
 
4011
3909
  ### One door: `exoBinding(channelMap, binding)`
4012
3910
 
4013
- A subscription here is one call — `OIMEffectService.INSTANCE.subscribeEffect(channelMap, sourceSlots, sourceComputeds, onChange)` —
3911
+ A subscription here is one call — `OIMEffectService.INSTANCE.subscribeEffect(channelMap, onChange, sourceSlots, sourceComputeds)` —
4014
3912
  and the value is read off the slots afterwards. So one adapter covers an entity, a
4015
3913
  column, a bucket's slots and a derivation:
4016
3914
 
@@ -4066,7 +3964,7 @@ resolved before its entity arrived, an entity since removed.
4066
3964
 
4067
3965
  - `exoSource(read, subscribe)` — the lazy, ref-counted base. For sources that are
4068
3966
  **not** this engine's.
4069
- - `exoCombine(sources, fn)` — fan-in across any `TOIMExoReadable`. Not glitch-free;
3967
+ - `exoCombine(fn, sources)` — fan-in across any `TOIMExoReadable`. Not glitch-free;
4070
3968
  keep heavy fan-in inside one computed.
4071
3969
  - `exoFollowKey(key, makeBindable)` — repoints as a selection moves. The factory
4072
3970
  must return a `TOIMExoDisposable`.
@@ -4079,7 +3977,7 @@ Who decided the order is the difference, and the core already forks on it.
4079
3977
 
4080
3978
  | | `exoDerivedList(channelMap, computedSlot, render, maxCommandsPerSlot?)` | `exoPositionalList(channelMap, slotContentBucketService, bucketSlot, render)` |
4081
3979
  |---|---|---|
4082
- | takes | a `TOIMComputedSlot<readonly TOIMEntitySlot[]>` | a bucket content service built with an `OIMBucketCommands` (the default of `createOIMBucketService`) and one of its bucket slots, from any of its store slots |
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 |
4083
3981
  | the script | **derived**, by `oimOrderedListCommandsOfTransition` | **recorded**, by `OIMBucketCommands`, read inside the frame |
4084
3982
  | per changed frame | one diff: two passes plus an LIS pass, `common − LIS` moves | one pass over the frame's own script |
4085
3983
 
@@ -4100,6 +3998,29 @@ memoised per list, so two lists showing one entity slot render it twice.
4100
3998
 
4101
3999
  ---
4102
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
+
4103
4024
  ## Performance
4104
4025
 
4105
4026
  **No figure may be quoted for this engine, and none is quoted anywhere in this
@@ -4130,9 +4051,9 @@ them.
4130
4051
 
4131
4052
  The one quantity that needs no measurement, because it is arithmetic rather than
4132
4053
  a reading: emptying a bucket one slot at a time on an array-driven bucket costs
4133
- n(n-1)/2 element moves — **499 500** for a thousand — which is why a bucket content service's `writeBucketMemberSlots`
4134
- and `clearBucketMemberSlots` go through one `drainSlots`. Counted, not timed, in
4135
- `packages/core/src/engine/bucket/array/tests/everyone-out-at-once-and-not-one-at-a-time.test.ts`, which
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
4136
4057
  also pins `drainSlots` at zero element writes.
4137
4058
 
4138
4059
  Fast properties are checked, not timed. Every slot kind is one object literal with