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