@oimdb/core 3.12.1 → 4.0.1

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.
Files changed (8) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +2083 -1581
  3. package/dist/index.cjs +5057 -6335
  4. package/dist/index.d.cts +1421 -3312
  5. package/dist/index.d.ts +1421 -3312
  6. package/dist/index.js +4991 -6242
  7. package/llms.txt +3909 -469
  8. package/package.json +69 -61
package/README.md CHANGED
@@ -1,1788 +1,2290 @@
1
1
  # @oimdb/core
2
2
 
3
- Core in-memory data library providing reactive collections, intelligent indexing, and configurable event processing.
4
-
5
- 📖 **[Full documentation](https://oimdb.org/)**
6
-
7
- ## For AI Assistants
8
-
9
- If you are an AI assistant working on a project that uses OIMDB, read the full API reference before writing any code. It covers all packages, key concepts, code patterns, and naming conventions — the API has specific patterns that are easy to get wrong without context.
10
-
11
- The file ships with this package. Read it at:
12
-
13
- ```
14
- node_modules/@oimdb/core/llms.txt
15
- ```
16
-
17
- Or fetch from CDN if working without local files:
18
-
19
- ```
20
- https://cdn.jsdelivr.net/npm/@oimdb/core/llms.txt
21
- ```
22
-
23
- If the project has `@oimdb/devtools` installed, also look for `debug.ts` or `*.debug.ts` files — they register the app's specific collections, indexes, and computeds by name and describe their relations. This is the fastest way to understand the specific data model you're working with.
24
-
25
- **Cursor users:** Cursor does not index `node_modules`, so it will not find this README automatically. Add a `.cursor/rules/oimdb.mdc` file to your project:
26
-
27
- ```
28
- Read node_modules/@oimdb/core/llms.txt before writing any OIMDB code.
29
- ```
30
-
31
- ## 🚀 Installation
32
-
33
- ```bash
34
- npm install @oimdb/core
35
- ```
36
-
37
- ## 📦 What's Included
38
-
39
- This package exports all the core classes, interfaces, and types needed to build reactive in-memory database solutions:
40
-
41
- ### Core Classes
42
- - **OIMReactiveCollection**: Reactive entity storage with automatic change notifications
43
- - **OIMCollectionIndexFactory**: Helper for creating collection-bound indexes and ordered lists next to a collection
44
- - **OIMReactiveIndexManualSetBased**: Reactive index with Set-based storage (efficient for incremental updates)
45
- - **OIMReactiveIndexManualArrayBased**: Reactive index with Array-based storage (efficient for full replacements)
46
- - **OIMReactiveCollectionIndexManualSetBased**: Collection-bound Set-based index with safe PK-oriented writes
47
- - **OIMReactiveCollectionIndexManualArrayBased**: Collection-bound Array-based index with safe PK-oriented writes
48
- - **OIMOrderedListCommandStream**: Slot-first ordered per-key lists with incremental commands for imperative consumers
49
- - **OIMCollectionOrderedListCommandStream**: Collection-bound ordered lists with PK writes and slot/entity command payloads
50
- - **OIMEventQueue**: Configurable event processing queue with scheduler integration
51
- - **OIMCollection**: Base collection with CRUD operations and event emission
52
-
53
- ### Event System
54
- - **OIMUpdateEventEmitter**: Key-based subscriptions with batching/deduplication (no buffering if there are no subscribers)
55
- - **OIMEventEmitter**: Generic type-safe event emitter
56
- - **Schedulers**: Multiple event processing strategies (microtask, timeout, animationFrame, immediate)
57
-
58
- ### Reactive Primitives
59
- - **OIMEffect**: Reactive effects that run when dependencies change
60
- - **OIMComputed**: Derived values that recompute when dependencies change
61
- - **OIMSelector**: Value watchers that deliver updates only when values actually change
62
- - `OIMCollectionByPkSelector`: Watch single entity from collection
63
- - `OIMCollectionByPksSelector`: Watch multiple entities from collection
64
- - `OIMObjectValueByKeySelector`: Watch single key from reactive object
65
- - `OIMEntitiesByIndexKey*Selector`: Watch entities by index key
66
-
67
- ### Storage & Indexing
68
- - **OIMCollectionStoreMapDriven**: Map-based storage backend
69
- - **OIMIndexManualSetBased**: Set-based manual index (stores slots, projects to `Set<TPk>`)
70
- - **OIMIndexManualArrayBased**: Array-based manual index (stores slots, projects to `TPk[]`)
71
- - **OIMIndexManualOrderedArrayBased**: Slot-first manual ordered Array-based index with `pushSlot`, `insertSlotAt`, `removeAt`, `move`, and `resetSlots`
72
- - **OIMIndexStoreMapDrivenSetBased**: Set-based slot index storage backend
73
- - **OIMIndexStoreMapDrivenArrayBased**: Array-based slot index storage backend
74
- - **OIMMap2Keys**: Two-key mapping utilities for complex indexing
75
-
76
- ### Abstract Classes & Interfaces
77
- - **OIMCollectionStore**: Storage backend interface
78
- - **OIMEventQueueScheduler**: Event processing scheduler interface
79
- - **OIMIndexSetBased**: Base Set-based index interface (slot-backed, projects to `Set<TPk>`)
80
- - **OIMIndexArrayBased**: Base Array-based index interface (slot-backed, projects to `TPk[]`)
81
- - **OIMReactiveIndexSetBased**: Reactive Set-based index interface
82
- - **OIMReactiveIndexArrayBased**: Reactive Array-based index interface
83
-
84
- ### Types & Enums
85
- - **TOIM\***: Generic types for collections, indices, events, and schedulers
86
- - **EOIM\***: Enums for event types and scheduler types
87
- - **IOIM\***: Interfaces for event handlers and scheduler events
88
-
89
- ## 🔧 Basic Usage
90
-
91
- ### DX Collection Model
92
-
93
- Use the `dx` factories when you want a concise entrypoint without changing the underlying model. The collection still owns entities, and relations still live next to it.
94
-
95
- ```typescript
3
+ The engine behind OIMDB: a wide toolkit at close to native cost. It gives you
4
+ signals (computed values and effects that recompute only when what they read has changed),
5
+ granular subscriptions (an entity, a column, a group, a group's column), memory management
6
+ (whatever nothing holds or watches is released under a policy you pick, which
7
+ general-purpose stores leave to you), entities by key, groups that stay up to date from a
8
+ column or are filled by hand, sorted views, and one delivery mechanism, the **frame**, that
9
+ connects all of it.
10
+
11
+ ---
12
+
13
+ ## Why OIMDB
14
+
15
+ **Take it when the application works with collections** (entities with ids that show
16
+ up in more than one place) and you do not want to build the machinery for them by
17
+ hand. Without it, teams give up and refetch, keep the store in a component's state
18
+ where it cannot be moved or reused, use a query cache that holds one copy of an
19
+ entity per query, or use a bare store and write the collection layer on top.
20
+
21
+ On a bare store such as zustand, that layer is yours:
22
+
23
+ 1. a group index (`idsByDeck`) maintained in every action that can move an entity;
24
+ 2. a re-sort of the whole group on every write, including one that changed a title;
25
+ 3. a copy of the whole table per write, because the store compares by reference;
26
+ 4. every `set` calling every listener, so one entity's write runs every mounted
27
+ selector;
28
+ 5. batching, as a second bulk action;
29
+ 6. selectors that must return stable values (`?? []` re-renders on every write);
30
+ 7. memory: a group nobody shows keeps its entities forever, unless you count references.
31
+
32
+ Here the index files entities by `keyOf`, a bucket keeps the order it was given, and a
33
+ sorted list over an index re-sorts only when its membership or one of its columns moves.
34
+ A write changes a slot in place and wakes only what moved, the frame batches, and a
35
+ bucket or an entity that nobody refers to goes under the release policy you picked.
36
+
37
+ **Why this shape.** A lookup by key on every read, write and subscription check costs
38
+ something, and waking every subscriber to ask "did you change?" costs a call each.
39
+ So a key is resolved to a slot once, and everything goes through the slot. Minting a
40
+ slot costs more than a map entry. The design bets that this pays back because
41
+ whatever is created has a consumer on a page. That does not mean updates always come out
42
+ cheaper than inserts: the measured cost of each task, insert or update, is in the
43
+ [benchmark results](https://oimdb.org/docs/benchmarks/results).
44
+
45
+ **The weight** is in [Bundle size](#bundle-size) below. OIMDB is heavier than a bare store,
46
+ because the engineering you would otherwise write is inside. The full argument, with
47
+ both versions of the same task side by side, is in [Why OIMDB](https://oimdb.org/docs/why-oimdb).
48
+
49
+ ---
50
+
51
+ ## Both formats ship, and one process may load only one
52
+
53
+ The build emits `dist/index.js` (ESM) and `dist/index.cjs` (CommonJS), and the
54
+ `exports` map offers both: `import` resolves to the first and `require` to the
55
+ second, with `dist/index.d.cts` handed to the `require` consumer so its
56
+ declarations and its runtime agree on the module shape. Plenty of consumers are
57
+ still on CommonJS, which is why both are here.
58
+
59
+ **If one process reaches the core both ways, the second load
60
+ throws.** `src/index.ts` registers its copy under `Symbol.for('@oimdb/core')` on
61
+ `globalThis`, and a second copy that finds another one there throws
62
+ `@oimdb/core is loaded twice in this process`. Without this check the split would be silent:
63
+ each copy keeps its own `OIMDefaults`, so a default set through one
64
+ (`OIMDefaults.ENTITY_WRITER.setValue(createOIMEntityWriterReplacing())`) never reaches a
65
+ service built by the other, which keeps writing in place. Objects built by
66
+ either copy carry their own methods, so a channel, a service and an effect
67
+ from different copies still deliver to each other; only the defaults diverge.
68
+
69
+ `@oimdb/react` loaded twice fails the opposite way: loudly, at the first render.
70
+ Every hook throws `useOIMEffectService: no <OIMDBProvider db={...}> or <OIMEffectServiceProvider
71
+ effectService={...}> above this component.` even though the provider is plainly above it. A React context's identity
72
+ IS its lookup key, so a provider from one copy and a `useContext` in the other
73
+ never meet. The error message names this as its second possible cause, because the
74
+ first sentence on its own points at the one thing that is not wrong.
75
+
76
+ The mechanism is one line: a runtime loads a package once per format, so
77
+ `import` in one file and `require` in another holds two complete copies of every
78
+ class and every module-level value, such as the `OIMDefaults` statics or a React context.
79
+
80
+ The rule is one line too: pick `import` or `require` for `@oimdb/core`, and use the
81
+ same one everywhere in the process, including inside every `@oimdb` package
82
+ that passes core objects around.
83
+
84
+ ---
85
+
86
+ ## Bundle size
87
+
88
+ What each consumer ships, bundled with esbuild (minified, tree-shaken, ESM).
89
+
90
+ <!-- bundle-size:begin -->
91
+ | Consumer | Minified | Minified + gzip |
92
+ |---|---:|---:|
93
+ | Everything `@oimdb/core` exports | 103361 B | 25288 B |
94
+ | One entity store slot: `OIMDB`, `createOIMEntityService`, `upsertEntity`, `getSlotByKey` | 41485 B | 10710 B |
95
+ | The same, plus one effect and `db.flush()` | 50131 B | 12876 B |
96
+ | The same, plus one index: `createOIMIndexService` | 71765 B | 17728 B |
97
+ | An ordered bucket with commands: `createOIMBucketService`, `OIMSlotContentBucketServiceArrayDriven`, `OIMBucketCommands` | 56072 B | 13880 B |
98
+ <!-- bundle-size:end -->
99
+
100
+ ---
101
+
102
+ ## The three layers
103
+
104
+ `src/index.ts` is the entire public surface, and it is divided in three:
105
+
106
+ | Layer | What is in it |
107
+ |---|---|
108
+ | `src/engine/` | The primitives, each a stateless service over a plain struct: `OIMChannelService.INSTANCE` over a `TOIMChannelSlot` (frames, the revision, subframes), `OIMEffectService.INSTANCE` (effects, handed the `db` on every call), `OIMSlotOwnerService` (the owner of every slot a collection filed: holds, release, emptiness, columns), the column owners (abstract `OIMColumnOwner`, `OIMColumnOwnerPushDriven` written by the collection, `OIMColumnOwnerPullDriven` derived by a computed), the content services (`OIMSlotContentEntityService` for entities, `OIMComputedValueService` for computeds), the key stores and key tree walks, the release policies, the collectors (`OIMUnusedSlotCollectorService`, `OIMReleaseSlotCollectorService*`) over the collector slot, `TOIMStoreSlot`, `TOIMComputedSlot`, `TOIMEffect`. |
109
+ | `src/facade/` | What a caller holds. The collection services `OIMSlotCollectionService` (keyed) and `OIMSingleSlotService` (one slot, no key), each with its `slotContentService`; the bucket content services `OIMSlotContentBucketServiceSetDriven`, `OIMSlotContentBucketServiceArrayDriven`, `OIMSlotContentBucketServiceSortedArrayDriven` and `OIMBucketCommands`; the auto-index `OIMIndexService` and `OIMIndexServiceSortedArrayDriven`; `OIMComputedService`, `OIMComputedServiceSorted`, `OIMComputedServiceBucketMemo`. |
110
+ | `src/factories/` | `OIMDB`, `OIMDefaults`, `OIMDefaultValue`, and the doors that build a service: the base ones, `createOIMSlotCollectionService`, `createOIMSingleSlotService` and `createOIMSlotContentEntityService`, and the convenience ones over them, `createOIMEntityService`, `createOIMSingleEntityService`, `createOIMBucketService`, `createOIMSingleBucketService`, `createOIMIndexService`, `createOIMIndexServiceByCompositeKey`, `createOIMIndexServiceSortedArrayDriven`, `createOIMComputedService`, `createOIMComputedServiceSorted`, `createOIMComputedServiceBucketMemo`, `createOIMEntityWriterInPlace`, `createOIMEntityWriterReplacing`. |
111
+
112
+ **A service holds no data; a store slot is one instance.** A service is built once per
113
+ type, and `cardService.createStoreSlot(db)` returns a `TOIMStoreSlot`, a plain object whose
114
+ `store` is the keyed store and whose `channelSlot` is the channel it reports to. A collection
115
+ service has two halves. Its own doors read and mint slots, and take the store slot first:
116
+ `cardService.getSlotByKey(cards, pk)`, `cardService.holdSlotByKey(cards, pk)`,
117
+ `cardService.findSlotByKey(cards, pk)`, `cardService.getSlots(cards)`,
118
+ `cardService.getSlotCount(cards)`, plus the column doors (`getColumn`, `holdColumn`, …). Its
119
+ `slotContentService` holds the content and every write: `cardService.slotContentService.upsertEntity(cards, card)`
120
+ takes the store slot, and a door handed a slot takes only the slot, because the slot carries its
121
+ own store slot (`slot.storeSlot`): `cardService.slotContentService.updateEntity(slot, patch)`.
122
+ The owner of the slots is reached through `slot.owner` (an `IOIMSlotOwner`: hold, unhold,
123
+ release, subscribe), never through the service.
124
+
125
+ ---
126
+
127
+ ## The shortest thing that delivers
128
+
129
+ ```typescript setup
96
130
  import {
97
- createOIMCollectionKit,
98
- OIMEventQueue,
99
- OIMEventQueueSchedulerFactory
131
+ OIMBucketCommands,
132
+ OIMBucketArrayMembershipUnique,
133
+ OIMChannelService,
134
+ OIMDB,
135
+ OIMDefaults,
136
+ OIMEffectService,
137
+ OIMErrorPolicyLogAndContinue,
138
+ OIMSlotContentBucketServiceArrayDriven,
139
+ OIMSlotContentBucketServiceSetDriven,
140
+ OIMSlotReleasePolicyAfterIdle,
141
+ OIMSlotReleasePolicyAfterWrite,
142
+ OIMSlotReleasePolicyEarliest,
143
+ OIMSlotStoreServiceMapDriven,
144
+ OIMSlotStoreServiceSingleSlotDriven,
145
+ OIMSlotStoreServiceTreeDriven,
146
+ OIMUnusedSlotCollectorService,
147
+ createOIMUnusedSlotCollectorSlot,
148
+ oimInsertEntityWithCopy,
149
+ oimIsSlotReleased,
150
+ createOIMBucketService,
151
+ createOIMComputedServiceBucketMemo,
152
+ createOIMComputedService,
153
+ createOIMComputedServiceSorted,
154
+ createOIMEntityService,
155
+ createOIMEntityWriterInPlace,
156
+ createOIMIndexService,
157
+ createOIMIndexServiceSortedArrayDriven,
158
+ createOIMSingleEntityService,
159
+ createOIMSlotCollectionService,
160
+ createOIMSlotContentEntityService,
161
+ oimOrderedListCommandsOfTransition,
162
+ oimSameSlotSequence,
163
+ oimSameValueZeroGuard,
164
+ type TOIMComputedSlot,
165
+ type TOIMBucketSlotsArrayUniqueStore,
166
+ type TOIMBucketSlotsArrayStore,
167
+ type TOIMBucketSlot,
168
+ type TOIMBucketSlotsSetStore,
169
+ type TOIMDisposer,
170
+ type TOIMEntityColumnName,
171
+ type TOIMEntitySlot,
172
+ type TOIMOrderedListCommand,
173
+ type TOIMSingleSlotStore,
174
+ type TOIMSlotStoreTree,
175
+ type TOIMStoreKey,
100
176
  } from '@oimdb/core';
101
177
 
102
- type User = {
103
- id: string;
104
- name: string;
105
- teamId: string;
106
- };
107
-
108
- const queue = new OIMEventQueue({
109
- scheduler: OIMEventQueueSchedulerFactory.createMicrotask()
110
- });
111
- const users = createOIMCollectionKit<User, string>(queue, {
112
- selectPk: user => user.id,
113
- });
114
-
115
- const usersByTeam = users.indexFactory.derivedSetIndex(user => user.teamId);
116
- const teamUsers = users.select.entitiesBySetIndexKey(usersByTeam, 'team1');
117
-
118
- users.collection.upsertMany([
119
- { id: 'u1', name: 'Alice', teamId: 'team1' },
120
- { id: 'u2', name: 'Bob', teamId: 'team1' },
121
- ]);
178
+ type Card = { id: string; deck: string; title: string; rank: number };
179
+ type CardSlot = TOIMEntitySlot<Card>;
122
180
 
123
- teamUsers.watch(value => {
124
- console.log(value); // Alice, Bob
125
- });
181
+ const db = new OIMDB();
182
+ const effectService = OIMEffectService.INSTANCE;
183
+ const cardService = createOIMEntityService<Card>(db, { keyOf: (card) => card.id });
184
+ const cards = cardService.createStoreSlot(db);
185
+ const slot = cardService.getSlotByKey(cards, 'c1');
186
+ const byRank = (leftBucketMemberSlot: TOIMEntitySlot<Card>, rightBucketMemberSlot: TOIMEntitySlot<Card>): number =>
187
+ (leftBucketMemberSlot.entity?.rank ?? 0) - (rightBucketMemberSlot.entity?.rank ?? 0);
126
188
  ```
127
189
 
128
- ### Creating a Reactive Collection
129
-
130
190
  ```typescript
131
- import {
132
- OIMReactiveCollection,
133
- OIMEventQueue,
134
- OIMEventQueueSchedulerFactory
135
- } from '@oimdb/core';
136
-
137
- interface User {
138
- id: string;
139
- name: string;
140
- email: string;
141
- }
191
+ cardService.slotContentService.upsertEntity(cards, { id: 'c1', deck: 'spades', title: 'Ace', rank: 1 });
142
192
 
143
- // Create event queue with microtask scheduler
144
- const queue = new OIMEventQueue({
145
- scheduler: OIMEventQueueSchedulerFactory.createMicrotask()
193
+ let seen = '';
194
+ const effect = effectService.createEffect(db, [slot], [], () => {
195
+ seen = slot.entity?.title ?? '';
146
196
  });
147
197
 
148
- // Create reactive collection
149
- const users = new OIMReactiveCollection<User, string>(queue, {
150
- selectPk: (user) => user.id
151
- });
152
-
153
- // Subscribe to key-specific updates
154
- users.updateEventEmitter.subscribeOnKey('user1', () => {
155
- console.log('User1 changed!');
156
- });
198
+ cardService.slotContentService.updateEntity(slot, { title: 'Ace of Spades' });
199
+ db.flush(); // seen === 'Ace of Spades'
157
200
 
158
- // Subscribe to multiple keys. The handler is coalesced: it fires at most ONCE
159
- // per flush, no matter how many of the subscribed keys changed in that flush.
160
- users.updateEventEmitter.subscribeOnKeys(['user1', 'user2'], () => {
161
- console.log('Users changed!');
162
- });
163
-
164
- // CRUD operations return canonical slots.
165
- const user1Slot = users.upsertOne({
166
- id: 'user1',
167
- name: 'John Doe',
168
- email: 'john@example.com'
169
- });
170
- const otherSlots = users.upsertMany([
171
- { id: 'user2', name: 'Jane Smith', email: 'jane@example.com' },
172
- { id: 'user3', name: 'Bob Wilson', email: 'bob@example.com' }
173
- ]);
174
-
175
- // Updating an entity keeps the same slot object and updates slot.item.
176
- const updatedUser1Slot = users.upsertOne({
177
- id: 'user1',
178
- name: 'John Smith',
179
- email: 'john@example.com'
180
- });
181
- console.log(user1Slot === updatedUser1Slot); // true
182
-
183
- // Query operations
184
- const user = users.getOneByPk('user1');
185
- const multipleUsers = users.getManyByPks(['user1', 'user2']);
186
- const userSlot = users.getSlotByPk('user1');
201
+ effectService.disposeEffect(effect);
187
202
  ```
188
203
 
189
- #### Composite primary key
190
-
191
- Instead of a primitive PK, a collection can be keyed by a **composite key path** — an arbitrary-length tuple of primitive segments. Pass a trie-backed store:
192
-
193
- ```typescript
194
- import { OIMReactiveCollection, OIMCollectionStoreTrieDriven, TOIMKeyPath } from '@oimdb/core';
195
-
196
- const memberships = new OIMReactiveCollection<Membership, TOIMKeyPath>(queue, {
197
- selectPk: (m) => [m.userId, m.projectId],
198
- store: new OIMCollectionStoreTrieDriven<Membership>(),
199
- });
200
-
201
- memberships.getOneByPk([1, 10]); // matched by content — a fresh array works
202
- memberships.subscribeOnKey([1, 10], render);
203
- memberships.removeOneByPk([1, 10]);
204
+ `OIMDB` is the shared state and nothing else: one `clock` (`{ revision, epoch }`), one
205
+ `defaultChannelSlot` (a `TOIMChannelSlot`, the struct a channel's state lives in), one
206
+ `collectorSlot` (the unused-slot collector's slot and one slot per release collector), and the
207
+ `entityWriter`, `slotStamp`, `slotReleasePolicy` and `indexKeysService` its constructor is handed,
208
+ which it keeps as the default that every door below reads. It is an `IOIMChannelMap`, so
209
+ anything that takes a `channelMap` takes the `db`. `flush()` is
210
+ `OIMChannelService.INSTANCE.flush(db.defaultChannelSlot)`: it runs the pending frame, then the
211
+ unused-slot collector, then the after-each-flush handlers, in that order. It builds nothing.
212
+
213
+ There is no channel object and no effect service instance per channel. `OIMChannelService.INSTANCE`
214
+ and `OIMEffectService.INSTANCE` are stateless; every call is handed the channel slot (or the `db`
215
+ whose `defaultChannelSlot` it is). A store slot carries its own channel slot as
216
+ `storeSlot.channelSlot`, and an effect as `effect.channelSlot`.
217
+
218
+ There is no default scheduler, on purpose. A test calls `db.flush()`; an
219
+ application gives that call to the orchestrator. With cnstra that is one line:
220
+ a drain listener that calls `db.flush()` at the end of every synchronous turn of every
221
+ stimulation. Each turn is then one frame, and a neuron that awaits a request
222
+ delivers its loading state first and the data in a later frame with no extra code.
223
+ Every mutation goes through a domain neuron. See
224
+ [With cnstra](https://oimdb.org/docs/guides/with-cnstra).
225
+
226
+ The doors are free functions, one module each, and each builds a **service**, once per
227
+ type; an instance is the store slot that service creates. A door over a collection takes
228
+ `db` first and ONE options object second. On the bucket door the object is optional while the
229
+ key is a `TOIMStoreKey`. With an array key the type requires it, and it must include a
230
+ `bucketSlotStoreService` (`OIMSlotStoreServiceTreeDriven`), because a map-driven store compares
231
+ arrays by reference; that argument list is `TOIMBucketCollectionFactoryArguments`. A collection
232
+ under no key at all is a single service: `createOIMSingleEntityService` /
233
+ `createOIMSingleBucketService`; see [The six shapes](#the-six-shapes). An index takes a scalar key
234
+ through `createOIMIndexService` and an array key (several columns) through
235
+ `createOIMIndexServiceByCompositeKey`, which sets the tree-driven store itself. The computed doors
236
+ take no `db`: a computed reaches its channel through the store slot it derives from, or through
237
+ the `db` handed to `createComputedStoreSlot`.
238
+
239
+ ```typescript signature
240
+ createOIMEntityService<Card, TColumnName?, TKey?, TStore?>(db, { keyOf, entityWriter?, slotEmptiness?, slotReleasePolicy?, columnSlotStoreService? | columnNames?, entitySlotStoreService? })
241
+ createOIMSingleEntityService<Card, TColumnName?>(db, { entityWriter?, slotEmptiness?, slotReleasePolicy?, columnSlotStoreService? | columnNames? }?)
242
+ createOIMBucketService<TBucketMemberSlot?, TKey?, TSlots?, TSlotContentService?, TStore?>(db, { slotContentService?, bucketSlotStoreService?, maxCommandsPerSlot?, slotReleasePolicy?, columnSlotStoreService? | columnNames? }?)
243
+ createOIMSingleBucketService<TBucketMemberSlot?, TSlots?, TSlotContentService?>(db, { slotContentService?, maxCommandsPerSlot?, slotReleasePolicy?, columnSlotStoreService? | columnNames? }?)
244
+ createOIMIndexService(db, { entityService, columns | keyOf, slotContentService?, bucketSlotStoreService?, slotReleasePolicy?, columnSlotStoreService? | columnNames? })
245
+ createOIMIndexServiceByCompositeKey(db, { entityService, columns, slotContentService?, slotReleasePolicy?, columnSlotStoreService? | columnNames? })
246
+ createOIMIndexServiceSortedArrayDriven(db, { entityService, sortColumns, columns | keyOf, bucketSlotStoreService?, slotReleasePolicy?, columnSlotStoreService? | columnNames? })
247
+ createOIMComputedService<TKey, TValue, TComputedStore?>({ createSources?, derive?, sameValueGuard?, initialValue?, slotReleasePolicy?, computedSlotStoreService?, columnSlotStoreService? | columnNames? }) // a non-Map store: computedSlotStoreService required
248
+ createOIMComputedServiceSorted({ indexService, sortColumns?, comparator?, slotReleasePolicy?, computedSlotStoreService?, columnSlotStoreService? })
249
+ createOIMComputedServiceBucketMemo({ bucketCollectionService, slotReleasePolicy?, computedSlotStoreService?, columnSlotStoreService? })
250
+
251
+ cardService.createStoreSlot(db) // an entity or bucket store slot, reporting to db.defaultChannelSlot
252
+ indexService.createIndexStoreSlot(cards) // the index's bucket store slot over one entity store slot
253
+ sortedService.createSortedStoreSlot(indexStoreSlot) // a sorted store slot over one index store slot
254
+ orderService.createBucketMemoStoreSlot(bucketStoreSlot) // one derivation per bucket of a bucket store slot
255
+ computedService.createComputedStoreSlot(db, derive?, createSources?)
204
256
  ```
205
257
 
206
- Key paths match **by content** (the store interns each logical PK to one canonical `slot.pk`). Indexes work over such a collection too (`indexFactory.setBasedIndex()` indexes composite PKs). Primitive-PK collections keep the native-`Map` store untouched. At serialization boundaries a composite PK needs an `IOIMPkCodec` (`OIMPkCodecKeyPath`) — `@oimdb/persist`/`@oimdb/snapshot-manager` need none; `@oimdb/redux-adapter` takes it.
207
-
208
- ### Creating a Reactive Index
209
-
210
- OIMDB provides two types of indexes optimized for different use cases:
211
-
212
- Indexes are slot-backed internally and expose PK projections through `getPksByKey` (`Set<TPk>` for SetBased, `TPk[]` for ArrayBased). Raw indexes can be used as standalone PK/slot structures. When an index belongs to a collection, prefer `OIMReactiveCollectionIndexManual*`; it binds the collection at construction time so PK writes resolve canonical collection slots without a later lifecycle setter.
213
-
214
- For most app-level entity relations, start with `createOIMCollectionKit(...).indexFactory.derivedSetIndex(...)` or `.derivedArrayIndex(...)`. The manual indexes below are the advanced path for externally maintained memberships such as search results, permissions, or server-provided order.
215
-
216
- #### SetBased Indexes (for incremental updates)
258
+ **The convenience doors sit on two base ones.** `createOIMEntityService(db, options)` is
259
+ `createOIMSlotCollectionService({ slotContentService: createOIMSlotContentEntityService(db, options) })`,
260
+ and `createOIMSingleEntityService` is the same over `createOIMSingleSlotService`. The content
261
+ service is where everything per type is fixed, once: the `keyOf`, the entity writer, the
262
+ emptiness rule, the store service, and the slot owner built from `db.slotStamp`, the release
263
+ policy and the column options. The collection service only lends that content service's owner
264
+ and store service its doors. Call the base doors when the content service is built elsewhere and
265
+ shared, or when you want the content service in hand before the collection:
217
266
 
218
267
  ```typescript
219
- import {
220
- OIMReactiveCollection,
221
- OIMReactiveCollectionIndexManualSetBased,
222
- OIMEventQueue
223
- } from '@oimdb/core';
224
-
225
- type User = { id: string; role: string };
226
-
227
- // Create Set-based reactive index for user roles
228
- const queue = new OIMEventQueue({
229
- scheduler: OIMEventQueueSchedulerFactory.createMicrotask()
230
- });
231
-
232
- const users = new OIMReactiveCollection<User, string>(queue, {
233
- selectPk: (user) => user.id
234
- });
235
- const userRoleIndex =
236
- new OIMReactiveCollectionIndexManualSetBased<string, string, User>(
237
- queue,
238
- { collection: users }
239
- );
240
-
241
- // Subscribe to specific index key changes
242
- userRoleIndex.updateEventEmitter.subscribeOnKey('admin', () => {
243
- console.log('Admin users changed:', userRoleIndex.getPksByKey('admin')); // Set<string>
244
- });
245
-
246
- // Build the index manually
247
- userRoleIndex.setPks('admin', ['user1']);
248
- userRoleIndex.setPks('user', ['user2', 'user3']);
249
-
250
- // Add more users to existing roles (efficient for Set-based)
251
- userRoleIndex.addPks('admin', ['user2']);
252
-
253
- // Query the index - returns Set
254
- const adminUsers = userRoleIndex.index.getPksByKey('admin'); // Set(['user1', 'user2'])
255
- const regularUsers = userRoleIndex.index.getPksByKey('user'); // Set(['user2', 'user3'])
256
-
257
- // Remove users from roles (efficient for Set-based)
258
- userRoleIndex.removePks('admin', ['user1']);
268
+ const cardContentService = createOIMSlotContentEntityService<Card>(db, { keyOf: (card) => card.id });
269
+ const sameShapeCardService = createOIMSlotCollectionService({ slotContentService: cardContentService });
270
+ const sameShapeCards = sameShapeCardService.createStoreSlot(db);
259
271
  ```
260
272
 
261
- #### ArrayBased Indexes (for full replacements)
273
+ The bucket doors take the type of the bucket member slots, `TBucketMemberSlot extends TOIMSlot`
274
+ (default `TOIMEntitySlot`). A bucket holds bucket member slots of any slot type (entity slots,
275
+ bucket slots, computeds), and an index's `getEntitiesByKey` is there because its members are
276
+ entity slots.
277
+
278
+ Each door reads the globals that the class it builds would otherwise fall back to and
279
+ **always passes them**, so there is one answer to each question and no class
280
+ reaches for a default of its own.
281
+
282
+ The doors are free functions, not methods on `OIMDB`, so a consumer who wires
283
+ nothing does not import the bucket machinery. This is pinned by
284
+ `tests/what-an-import-retains.test.ts` (the root retains no door) and by
285
+ `src/factories/tests/which-answer-a-collection-the-root-mints-gets.test.ts`.
286
+
287
+ **A bucket's content service decides how it holds its slots.** `createOIMBucketService` takes it
288
+ as `slotContentService`. With none, it builds an `OIMSlotContentBucketServiceArrayDriven`
289
+ (insertion order, never re-decided) with an `OIMBucketCommands` that records what each position
290
+ write did; `maxCommandsPerSlot` caps that record. Pass
291
+ `new OIMSlotContentBucketServiceSetDriven<TMember>()` for a bucket that keeps its slots in a set,
292
+ and `new OIMSlotContentBucketServiceSortedArrayDriven({ compareBucketMemberSlots })` for one kept in
293
+ a comparator's order. A bucket's reads and writes are on that content service:
294
+ `bucketService.slotContentService.writeBucketMemberSlots(bucketSlot, slots)`.
295
+
296
+ **A bucket never sorts, except a sorted one.** The order of an array-driven bucket is what the
297
+ caller put in: `addBucketMemberSlot` appends, `insertBucketMemberSlotAt` inserts, a move moves.
298
+ A sorted list is a computed over an index.
299
+ See [A sorted list is a computed over an index](#a-sorted-list-is-a-computed-over-an-index);
300
+ for the index whose buckets sort themselves, see [A sorted index: sorted at write](#a-sorted-index-sorted-at-write).
301
+
302
+ Every property of a door's options is a choice made once at construction: **where identities
303
+ live** (`entitySlotStoreService`, the strategy every store slot of the service files its slots
304
+ with), **how a key comes out of an entity** (`keyOf`), **how a write lands** (`entityWriter`),
305
+ **what counts as empty** (`slotEmptiness`), **when a slot goes** (`slotReleasePolicy`), and
306
+ **where column slots live** (`columnSlotStoreService`, or `columnNames` for the ordinal store; the
307
+ slot owner makes its own column owner over it). Everything but `keyOf` falls back to the `db` or
308
+ to a lazy `OIMDefaultValue` on [`OIMDefaults`](#every-optional-dependency-has-a-default).
309
+
310
+ ---
311
+
312
+ ## A collection always reports to a channel
313
+
314
+ There is no argument that turns delivery off. A store slot is created on a `db` and carries
315
+ `db.defaultChannelSlot` as its `channelSlot`; every write door reports to that channel, and the
316
+ unused-slot collector a read hands its mint to is that channel slot's `collectorSlot`.
262
317
 
263
318
  ```typescript
264
- import {
265
- OIMReactiveCollection,
266
- OIMReactiveCollectionIndexManualArrayBased,
267
- OIMEventQueue
268
- } from '@oimdb/core';
269
-
270
- type Card = { id: string; deckId: string };
271
-
272
- // Create Array-based reactive index for deck cards
273
- const queue = new OIMEventQueue({
274
- scheduler: OIMEventQueueSchedulerFactory.createMicrotask()
275
- });
276
-
277
- const cards = new OIMReactiveCollection<Card, string>(queue, {
278
- selectPk: (card) => card.id
279
- });
280
- const cardsByDeckIndex =
281
- new OIMReactiveCollectionIndexManualArrayBased<string, string, Card>(
282
- queue,
283
- { collection: cards }
284
- );
285
-
286
- // Subscribe to specific index key changes
287
- cardsByDeckIndex.updateEventEmitter.subscribeOnKey('deck1', () => {
288
- console.log('Deck cards changed:', cardsByDeckIndex.getPksByKey('deck1')); // string[]
319
+ const deliveringCardService = createOIMEntityService<Card>(db, {
320
+ keyOf: (card) => card.id,
321
+ entitySlotStoreService: new OIMSlotStoreServiceMapDriven<TOIMEntitySlot<Card, TOIMStoreKey>>(),
322
+ columnSlotStoreService: OIMDefaults.COLUMN_SLOT_STORE_SERVICE.getValue(),
289
323
  });
290
-
291
- // Build the index manually - set full array
292
- cardsByDeckIndex.setPks('deck1', ['card1', 'card2', 'card3']);
293
-
294
- // Query the index - returns Array
295
- const deckCards = cardsByDeckIndex.index.getPksByKey('deck1'); // ['card1', 'card2', 'card3']
296
-
297
- // For Array-based indexes, prefer setPks for updates (addPks/removePks are available but less efficient)
298
- cardsByDeckIndex.setPks('deck1', ['card1', 'card2', 'card4']); // Full replacement (recommended)
299
- // cardsByDeckIndex.addPks('deck1', ['card5']); // Works but less efficient than SetBased
324
+ const deliveringCards = deliveringCardService.createStoreSlot(db);
300
325
  ```
301
326
 
302
- **When to use which:**
303
- - **SetBased**: Use when you frequently add/remove individual items (`addPks`/`removePks` are efficient) and order doesn't matter
304
- - **ArrayBased**: Use when you typically replace the entire array (`setPks` is more efficient, no diff computation needed) or when you need to preserve element order/sorting
305
-
306
- #### Ordered List Command Streams
307
-
308
- Use `OIMCollectionOrderedListCommandStream` when you need an ordered list per key, PK-oriented writes, canonical collection slots, and incremental commands for an imperative renderer or external store. Use `OIMOrderedListCommandStream` directly only when you already manage slots yourself.
327
+ The entity collection overrides nothing. `createOIMSlotContentEntityService` builds an
328
+ `OIMSlotContentEntityService`, whose constructor builds the type's `OIMSlotOwnerService` and hands
329
+ it a private content strategy (`createSlot`, `isSlotEmpty`, `releaseSlotContent`,
330
+ `hasReference`), which is how the owner asks the content about a slot without the content's write
331
+ doors being reachable from the owner. `createOIMSlotCollectionService` then wraps that content
332
+ service's owner and store service in an `OIMSlotCollectionService`.
333
+
334
+ `keyOf` is the one required option. `entityWriter` falls back to `db.entityWriter`, then to
335
+ `OIMDefaults.ENTITY_WRITER`; `slotEmptiness` to `OIMDefaults.ENTITY_SLOT_EMPTINESS`;
336
+ `slotReleasePolicy` to `db.slotReleasePolicy`, then to nothing, which is the default of "a slot
337
+ with a value stays for good" described under **The injected seams**; the column store to
338
+ `OIMDefaults.COLUMN_SLOT_STORE_SERVICE`.
339
+
340
+ A single entity is the same content service under a `void` key, built by
341
+ `createOIMSingleEntityService`, and it reports the same way.
342
+
343
+ ---
344
+
345
+ ## Every optional dependency has a default
346
+
347
+ `OIMDefaults` is where a default that would otherwise be an object lives. It is a
348
+ class of static `OIMDefaultValue` fields and nothing else; you never construct one.
349
+
350
+ | Accessor | Type | What it hands back |
351
+ |---|---|---|
352
+ | `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` |
353
+ | `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` |
354
+ | `OIMDefaults.KEY_TREE_WALK_SCALAR` | `OIMKeyTreeWalkScalar` | the chain-of-values walk |
355
+ | `OIMDefaults.KEY_TREE_WALK_COMPOSITE` | `OIMKeyTreeWalkComposite` | the chain-of-identities walk |
356
+ | `OIMDefaults.STANDALONE_SLOT_OWNER` | `OIMSlotOwner` | the owner of a slot nobody filed |
357
+ | `OIMDefaults.ENTITY_WRITER` | `IOIMEntityWriter` | `createOIMEntityWriterInPlace()` |
358
+ | `OIMDefaults.ENTITY_SLOT_EMPTINESS` | `IOIMSlotEmptiness<TOIMEntitySlot>` | "empty means `slot.entity === undefined`" |
359
+ | `OIMDefaults.INDEX_KEYS_SERVICE` | `IOIMIndexKeysService` | an `OIMIndexKeysServiceAppending`; see [Index keys strategy](#index-keys-strategy) |
360
+ | `OIMDefaults.STORE_SLOT_LISTENER` | `IOIMStoreSlotListener` | `OIMStoreSlotListenerNone`, which hears nothing. It is not a constructor option: every service reads it when built and calls `onStoreSlotCreated(storeSlot)` for each store slot it creates. Set your own with `setValue` before the first service is built; afterwards `setValue` throws |
361
+
362
+ **Nothing is constructed at module load.** Every entry is an `OIMDefaultValue`
363
+ whose `getValue()` mints on its first call (`??=`) and hands back the same instance
364
+ for the rest of the process, so a consumer that never reaches for a key tree walk
365
+ never builds one.
366
+
367
+ Each has a matching `setValue(value)`, which is how a process replaces a default
368
+ everywhere at once. **`setValue` throws once `getValue` has handed the first one
369
+ out**, naming the property: `OIMDefaults.ENTITY_WRITER has already been handed
370
+ out`. Whoever took the default keeps it, so a late replacement would leave two
371
+ implementations live in one process, with the older one inside every object built
372
+ before the call.
373
+
374
+ Setting a default before the first read works, and so does setting it twice before then. Each
375
+ property is tracked on its own, so calling `OIMDefaults.COLUMN_SLOT_STORE_SERVICE.getValue()` does not close
376
+ `OIMDefaults.KEY_TREE_WALK_SCALAR`. Set every default you mean to replace at startup, before
377
+ any `OIMDB` or service exists.
378
+ `src/factories/tests/what-a-default-replaced-too-late-does.test.ts` pins this.
379
+
380
+ **One module names every default, so calling one `getValue()` retains all of them.** A
381
+ service drags the map-driven store service and both key tree walks into a
382
+ bundle even when it calls neither. That is the cost of the single door, and a
383
+ bundler cannot drop a strategy that nothing named.
384
+ `tests/what-an-import-retains.test.ts` pins the shape: *"the defaults module is one door to every arm behind it"*.
385
+
386
+ ---
387
+
388
+ ## The four doors that mint an identity
389
+
390
+ **Writing a value, subscribing, taking a hold, and reading a key that has none.**
391
+ Each is admitted because the act **leaves a reference behind** on what it produced
392
+ (a value, an effect, a count, or a tracked mint), so nothing can unfile the
393
+ identity under the caller who just got it.
309
394
 
310
395
  ```typescript
311
- import {
312
- OIMEventQueue,
313
- OIMReactiveCollection,
314
- OIMCollectionOrderedListCommandStream
315
- } from '@oimdb/core';
316
-
317
- type Card = { id: string; title: string };
318
-
319
- const queue = new OIMEventQueue();
320
- const cards = new OIMReactiveCollection<Card, string>(queue, {
321
- selectPk: card => card.id,
322
- });
323
- const cardsByDeck = new OIMCollectionOrderedListCommandStream<
324
- string,
325
- string,
326
- Card
327
- >(queue, { collection: cards });
328
-
329
- cards.upsertMany([
330
- { id: 'card1', title: 'Intro' },
331
- { id: 'card2', title: 'Details' },
332
- { id: 'card3', title: 'Summary' },
333
- ]);
334
-
335
- cardsByDeck.commandsEventEmitter.subscribeOnKey('deck1', () => {
336
- const commands = cardsByDeck.consumeCommands('deck1');
337
-
338
- for (const command of commands) {
339
- switch (command.type) {
340
- case 'insert':
341
- // Insert command.item (the slot; entity is command.item.item)
342
- // at command.index
343
- break;
344
- case 'remove':
345
- // Remove command.count ?? 1 elements starting at command.index
346
- break;
347
- case 'move':
348
- // Move command.count ?? 1 elements from command.from to command.to
349
- break;
350
- case 'set':
351
- // Replace the single element at command.index with command.item
352
- break;
353
- case 'reset':
354
- // Replace the whole list with command.items (slots)
355
- break;
356
- }
357
- }
358
- });
359
-
360
- cardsByDeck.set('deck1', ['card1', 'card2']);
361
- cardsByDeck.move('deck1', 1, 0);
362
- cardsByDeck.push('deck1', 'card3');
363
-
364
- queue.flush(); // Delivers one batched command notification for deck1
396
+ const card: Card = { id: 'c1', deck: 'spades', title: 'Ace', rank: 1 };
397
+ const key = 'c1';
398
+ const body = (): void => {};
399
+
400
+ cardService.slotContentService.upsertEntity(cards, card); // write: the value is the reference
401
+ cardService.slotContentService.insertEntity(cards, key, card); // write: the same door, by key
402
+ const entitySlot = cardService.holdSlotByKey(cards, key); // hold: a counted reference; give it back with entitySlot.owner.unholdSlot(entitySlot)
403
+ effectService.createEffect(db, [entitySlot], [], body); // subscribe: the effect is the reference
404
+ const rankColumn = cardService.holdColumn(entitySlot, 'rank'); // hold, on a column
405
+ cardService.unholdColumn(rankColumn); // give the column back
406
+ cardService.getSlotByKey(cards, key); // read: the collector's hold is the reference
407
+ ```
365
408
 
366
- console.log(cardsByDeck.getPksByKey('deck1')); // ['card2', 'card1', 'card3']
367
- console.log(cardsByDeck.getEntitiesByKey('deck1')); // [{ id: 'card2', ... }, ...]
409
+ **The read that does not mint is `findSlotByKey(storeSlot, key)`.** It answers `undefined`
410
+ for a key with nothing filed, which is how a caller changes or removes a key without minting
411
+ it: `const s = cardService.findSlotByKey(cards, key); if (s !== undefined) cardService.slotContentService.deleteEntity(s);`.
412
+ It hands back only what is already filed and leaves no reference behind, so it is not a door
413
+ that mints. `cardService.slotContentService.findEntityByKey(storeSlot, key)` and
414
+ `cardService.hasSlotContentByKey(storeSlot, key)` mint nothing either.
415
+
416
+ **`getSlotByKey(storeSlot, key)` is the fourth door, and the collector is what admits it.** It
417
+ mints what is missing and hands the slot to the unused-slot collector of the store slot's channel
418
+ (`OIMUnusedSlotCollectorService.INSTANCE.holdSlotUntilFlush(storeSlot.channelSlot.collectorSlot.unusedSlotCollectorSlot, slot)`),
419
+ which takes the hold on the caller's behalf and gives it back at the next `flush()`.
420
+ The reference is still left behind, but the collector owns it instead of the
421
+ caller, which makes it safe to use and safe to forget. `insertEntity` relies on the same
422
+ window when the content it wrote landed empty: the
423
+ mint is tracked, so the identity it handed back stands until the collector gives
424
+ it back, and goes then if nothing took it.
425
+
426
+ **The write door also does not file a key whose content is refused.** On a miss,
427
+ `insertEntity` mints, writes inside a `try`, and on a throw runs
428
+ the release guard on what it just minted before re-raising, so the caller's error
429
+ is the only thing that leaves. *Filing first and writing second is the obvious
430
+ alternative, and it is wrong. One `upsertEntity` of an unseen key from inside a
431
+ subscriber body (the service's write opens with the frame guard)
432
+ would leave the key filed holding nothing, with nothing in a position to let it go.
433
+ `getSlotByKey` would answer a slot with no value, `insertEntities` would refuse for the life of the
434
+ store slot, and nothing would throw except the refusal the caller had asked for.* The
435
+ unwind is the release guard rather than a `store.remove` because the write may
436
+ have half landed, and the hit path never mints and stays outside the guard.
437
+
438
+ **Refused is not the same as unchanged**, and only a refusal unwinds anything.
439
+ A **refusal** means no content ever arrived: the write did not happen, nothing
440
+ keeps the key, and that identity is unwound. **Unchanged** means the write *did*
441
+ happen and the content was already there. The door completed and the caller is
442
+ handed the identity. Whether an identity that nothing keeps is worth keeping is
443
+ the owner's question, asked through its release guard.
444
+
445
+ So `insertEntity` is **total**: it always hands back the identity it resolved or
446
+ minted, whatever it decided about the content, because a door that mints must
447
+ leave a reference behind.
448
+
449
+ `fillEntities(storeSlot, keys, entities)` is the companion and does not carry that rule, because it returns
450
+ `void` and hands out no reference at all. On a throw it runs the release guard
451
+ over everything it filed and keeps only what something holds. This is bounded by the
452
+ precondition that the store slot was empty when the call began.
453
+
454
+ A fifth door is admitted by this test and by nothing else: the act has to leave
455
+ a reference behind that nothing can take away under its caller. A helper over an
456
+ existing door does not qualify. It is a **second spelling** of one, and two
457
+ spellings of a mint is how the rule stops being checkable.
458
+
459
+ ### A slot goes when nothing refers to it
460
+
461
+ A reference is given back by whoever took it. The terms are:
462
+
463
+ | Term | Given back by |
464
+ |---|---|
465
+ | `holdCount` | `owner.unholdSlot(slot)`, or `unholdColumn(columnSlot)` for a column; the unused-slot collector's `flush` for a `getSlotByKey`; `OIMEffectService.INSTANCE.disposeEffect(effect)`, for the hold an effect takes on each root the caller named |
466
+ | `effects` | `OIMEffectService.INSTANCE.disposeEffect(effect)` |
467
+ | a held column | the last column effect being disposed, through the column's owner (`slot.owner.unsubscribeFromSlot`) |
468
+ | the value itself | `entityService.slotContentService.deleteEntity(slot)` |
469
+ | a slot in a bucket | `bucketService.slotContentService.clearBucketMemberSlots` / `writeBucketMemberSlots` / a positional removal |
470
+ | a composite-key edge | `OIMKeyTreeWalkComposite.deleteKey` unlinking that composite key part |
471
+
472
+ When the last of them goes, the identity's **owner** unfiles it. There is no
473
+ release API and no owner that a caller assigns. `releaseSlotIfUnheld(slot)` is **public**
474
+ on `IOIMSlotOwner` because the release policy's queue reaches it through `slot.owner`, but
475
+ it is a guard, not a
476
+ command. It returns having done nothing unless the slot is unheld, unreferenced,
477
+ still owned by the object asked and out of grace, and it returns **silently** on
478
+ a slot that something else already released. A slot in a bucket is a counted
479
+ reference, so a slot in a bucket you never emptied stays for as long as that
480
+ bucket does.
481
+
482
+ **Where a service was given an idle window, the last subscriber's departure is
483
+ not the removal.** It is the moment that window opens. See [Releasing an
484
+ identity on a clock](#releasing-an-identity-on-a-clock--opt-in-and-off-it-costs-nothing).
485
+
486
+ That is per identity. Ending the **whole** channel is the level above and has its
487
+ own call. See [Ending the channel whole](#ending-the-channel-whole).
488
+
489
+ ---
490
+
491
+
492
+ ### A service composes
493
+
494
+ Every collection is three parts: a **store service** (`IOIMSlotStoreService`: the slots by
495
+ key, in the store each store slot carries; `OIMSlotStoreServiceMapDriven`,
496
+ `OIMSlotStoreServiceTreeDriven`, `OIMSlotStoreServiceSingleSlotDriven`), a **slot owner**
497
+ (`OIMSlotOwnerService`, an `OIMSlotOwner`: the owner of every slot it filed, with holds, release,
498
+ emptiness, the channel's subframes and the column owner) and a **content service**
499
+ (`OIMSlotContentEntityService`, an `OIMSlotContentBucketService*`, or a computed's
500
+ `OIMComputedValueService`), with the collection service (`OIMSlotCollectionService` /
501
+ `OIMSingleSlotService`) lending the first two their by-key doors. The owner asks the content
502
+ about a slot through a content strategy the content service hands it, never through an override:
503
+
504
+ | Strategy member | Called by the owner when |
505
+ |---|---|
506
+ | `createSlot(key, storeSlot, slotOwnerService)` | a key is minted |
507
+ | `isSlotEmpty(slot)` | emptiness is settled after a write |
508
+ | `releaseSlotContent(slot, slotOwnerService)` | the identity is going. Give back what its content held |
509
+ | `hasReference(slot)` | something outside `holdCount` and `effects` may keep it |
510
+
511
+ The strategy is private to the content service: the owner holds it, the caller never sees it.
512
+ An entity content service builds its owner itself, in its constructor, so the owner and the
513
+ content are fixed together once per type; a bucket content service hands a factory
514
+ (`createOIMBucketService`) its owner through `createSlotOwnerService`.
515
+
516
+ The entity content service is the example to follow in the package: it supplies the content
517
+ answers, the owner unfiles through the store service, and every write door opens the channel's
518
+ content subframe around it.
519
+
520
+ ### The injected seams
521
+
522
+ ```typescript signature
523
+ interface IOIMSlotEmptiness<TSlot> { isEmpty(slot: TSlot): boolean; }
524
+
525
+ interface IOIMSlotStamp<TSlot> { stamp(slotEditable: TOIMSlotEditable<TSlot>): void; }
368
526
  ```
369
527
 
370
- If you mutate `cardsByDeck.index` directly, the stream cannot know the exact operation and emits a `set` command so consumers can resync.
528
+ **`IOIMSlotEmptiness`** decides what counts as empty. The answer is written once
529
+ per write into the EMPTY flag of `slot.slotFlags` and never recomputed at a read. An entity content
530
+ service defaults to "the entity is `undefined`" (`OIMDefaults.ENTITY_SLOT_EMPTINESS`); a bucket
531
+ content service answers "`getBucketMemberSlotCount(bucketSlot) === 0`"; a computed answers
532
+ `false`.
533
+
534
+ Emptiness decides lifetime, never the write path. An `upsertEntity` onto an entity slot that
535
+ still carries an entity merges into it even when emptiness calls it empty (a soft
536
+ delete, a seed), so a column that the new entity drops is woken and an unchanged one is
537
+ not. Only a slot with no entity at all is filled whole.
538
+
539
+ **`IOIMSlotStamp`** writes onto the slot at every write, just before emptiness is
540
+ settled, and it chains: each stamp takes the `next` one and calls it first. Use it
541
+ for stamps that have nothing to do with release; release has its own policy.
542
+
543
+ **`IOIMSlotReleasePolicy`** decides whether and when a slot goes, and does no
544
+ work itself. A door and `OIMDB` take ONE through `slotReleasePolicy`; a door given none takes
545
+ `db.slotReleasePolicy`. With neither, the slot owner is built with `OIMSlotReleasePolicyAtEmpty`:
546
+ a slot with a value stays for good. A computed service defaults to
547
+ `OIMSlotReleasePolicyAtUnhold`. Two readonly flags on the policy, `releasesWrittenSlots` and
548
+ `schedulesReleaseOnWrite`, are read once by the owner at construction, so a policy that never
549
+ releases on a write costs a write nothing.
550
+
551
+ **TWO RELEASE MARKERS, ONE PER POLICY.** A slot carries two deadlines, and each
552
+ policy moves only its own. `-1` means unset.
553
+
554
+ | Marker | Moved by | Not moved by |
555
+ |---|---|---|
556
+ | `releaseDueAfterWriteMs` | every write, under `OIMSlotReleasePolicyAfterWrite` | a subscriber arriving or leaving |
557
+ | `releaseDueAfterIdleMs` | the first content of a slot with no subscriber, and the departure of the last subscriber, under `OIMSlotReleasePolicyAfterIdle` | a later write |
558
+ | `releaseDueAfterUnholdMs` | every last unhold, under `OIMSlotReleasePolicyAfterUnhold` | a write |
559
+
560
+ A slot goes on the SOONEST of those that are set, and a hold outranks them all.
561
+ **One number cannot carry both.** A write arriving after the subscriber left
562
+ would overwrite the idle policy's verdict, and the last subscriber leaving after a
563
+ write would overwrite the write policy's. Both directions are pinned by
564
+ `src/engine/collector/tests/which-release-marker-each-collector-may-move.test.ts`.
565
+
566
+ **The POLICY answers `isSlotReleasable(slot)`**, and only for a slot that is not
567
+ empty and has nothing left keeping it (no holds, no references). `true` releases
568
+ the slot now. `false` keeps it, and a policy with a finite deadline has already
569
+ filed the slot into its own release queue; a slot whose marker is `-1` is kept
570
+ with nothing scheduled. `OIMSlotReleasePolicyAtUnhold` always answers `true`, and
571
+ `OIMSlotReleasePolicyEarliest` asks every member and answers `true` if any member does.
572
+ An **empty** slot is asked about through `isEmptySlotReleasable(slot)`: every
573
+ policy except `OIMSlotReleasePolicyNever` answers `true`. A service given no
574
+ policy is built with `OIMSlotReleasePolicyAtEmpty`: it keeps a non-empty slot and
575
+ lets an empty one go, and no path branches on a missing policy. The owner has no
576
+ "how long is left" question.
577
+
578
+ ### Releasing an identity on a clock — opt-in, and off it costs nothing
579
+
580
+ Four policies ship, plus `OIMSlotReleasePolicyEarliest`, which combines them. The difference between
581
+ them is WHEN a slot goes. There is no flag, and no branch on the sweep path.
582
+
583
+ | Policy | A slot goes |
584
+ |---|---|
585
+ | `new OIMSlotReleasePolicyAfterWrite({ keepAfterWriteMs })` | once its last write is older than the window |
586
+ | `new OIMSlotReleasePolicyAfterIdle({ keepAfterIdleMs })` | once nothing has watched it for the window |
587
+ | `new OIMSlotReleasePolicyAfterUnhold({ keepAfterUnholdMs })` | once the window after its LAST unhold has passed; a hold taken again inside it keeps the slot |
588
+ | `OIMSlotReleasePolicyAtUnhold.INSTANCE` | at the last unhold, even with a value |
589
+ | `OIMSlotReleasePolicyAtEmpty.INSTANCE` | once it has no value and nothing holds it; a computed, never empty, stays while its owner lives |
590
+ | `OIMSlotReleasePolicyNever.INSTANCE` | never, empty or not; a deleted key's slot stays filed |
591
+ | `new OIMSlotReleasePolicyEarliest([...])` | when the earliest of several deadlines falls due — a `min` over deadlines |
592
+
593
+ A policy holds only its window. The clock, the timer and the queues are the `db`'s: each `OIMDB`
594
+ has a `collectorSlot` with one release collector slot per windowed policy
595
+ (`afterWriteReleaseSlotCollectorSlot`, `afterIdleReleaseSlotCollectorSlot`,
596
+ `afterUnholdReleaseSlotCollectorSlot`), built from `new OIMDB({ getNowMs, releaseTimer, errorPolicy })`.
597
+ A policy reaches the one for a slot through the slot's channel
598
+ (`slot.storeSlot.channelSlot.collectorSlot`). To control time in tests, pass `getNowMs` and
599
+ `releaseTimer` to the `OIMDB`. `OIMSlotReleasePolicyAtUnhold` is the zero window and touches no
600
+ collector.
601
+
602
+ ```typescript signature
603
+ interface IOIMSlotReleasePolicy {
604
+ readonly releasesWrittenSlots: boolean;
605
+ readonly schedulesReleaseOnWrite: boolean;
606
+ onSlotWritten(slot: TOIMSlot): void;
607
+ onSlotIdle(slot: TOIMSlot): boolean;
608
+ onSlotUnheld(slot: TOIMSlot): void;
609
+ isSlotReleasable(slot: TOIMSlot): boolean;
610
+ isEmptySlotReleasable(slot: TOIMSlot): boolean;
611
+ cancelSlotRelease(slot: TOIMSlot): void;
612
+ }
613
+ ```
371
614
 
372
- ### Event Queue and Schedulers
615
+ A zero window never touches a release queue or a timer: when a slot goes idle with
616
+ `onSlotIdle` answering `true`, it is released at once if nothing holds it, and
617
+ a freshly minted slot that is already due goes to the unused-slot collector and
618
+ leaves at the end of the frame. A window above zero is filed in that policy's own
619
+ queue.
373
620
 
374
621
  ```typescript
375
- import {
376
- OIMEventQueue,
377
- OIMEventQueueSchedulerFactory,
378
- TOIMSchedulerType
379
- } from '@oimdb/core';
380
-
381
- // Create event queues with different schedulers
382
- const microtaskQueue = new OIMEventQueue({
383
- scheduler: OIMEventQueueSchedulerFactory.create('microtask')
384
- });
385
-
386
- const timeoutQueue = new OIMEventQueue({
387
- scheduler: OIMEventQueueSchedulerFactory.create('timeout', { delay: 100 })
388
- });
389
-
390
- const animationFrameQueue = new OIMEventQueue({
391
- scheduler: OIMEventQueueSchedulerFactory.create('animationFrame')
622
+ const releasingDb = new OIMDB({
623
+ slotReleasePolicy: new OIMSlotReleasePolicyEarliest([
624
+ new OIMSlotReleasePolicyAfterWrite({ keepAfterWriteMs: 60_000 }),
625
+ new OIMSlotReleasePolicyAfterIdle({ keepAfterIdleMs: 30_000 }),
626
+ ]),
392
627
  });
393
-
394
- const immediateQueue = new OIMEventQueue({
395
- scheduler: OIMEventQueueSchedulerFactory.create('immediate')
396
- });
397
-
398
- // Manual queue operations
399
- const manualQueue = new OIMEventQueue(); // No scheduler
400
-
401
- manualQueue.enqueue(() => console.log('Task 1'));
402
- manualQueue.enqueue(() => console.log('Task 2'));
403
-
404
- // Manually flush when ready
405
- manualQueue.flush();
406
-
407
- // Queue introspection
408
- console.log('Queue length:', manualQueue.length);
409
- console.log('Is empty:', manualQueue.isEmpty);
410
628
  ```
411
629
 
412
- ## 🏗️ Advanced Usage
630
+ - **NEITHER READS `slot.effects`.** An effect takes a **hold** on every root it
631
+ reaches. `OIMEffectService.createEffect` calls `root.owner.subscribeToSlot(root, …)`, which holds each slot
632
+ in `allRootSlots` (the roots the caller named plus the ones
633
+ reached through the derivations it was given), and holds each of those
634
+ derivations too. `disposeEffect(effect)` gives them all back. The hold is the whole
635
+ signal, so a release policy has no list to count.
636
+ - **RUNNING BOTH SHARES NOTHING.** Each windowed policy has its own release collector slot on
637
+ the `db`'s `collectorSlot`, and each slot carries its own queue and its own timer: two queues,
638
+ two timers.
639
+ Whichever deadline falls due first takes the slot, and nothing has to be
640
+ reconciled. `OIMSlotReleasePolicyEarliest` answers a `min` over its members'
641
+ deadlines, so a sweep of either queue reaches the same verdict.
642
+
643
+ ### When a window opens, and when the clock is not running at all
644
+
645
+ A window opens in **exactly two places**:
646
+
647
+ 1. **when the slot first gets content and nobody subscribes to it**: the write that
648
+ takes it from empty to filled, in the owner's `settleEmptiness`. A slot minted empty by a read
649
+ that found nothing is not on any clock;
650
+ 2. **when the LAST SUBSCRIBER leaves**: `OIMSlotOwner.unsubscribeFromSlot` takes the
651
+ last entry out of `slot.effects` and runs the idle step, which opens a window.
652
+
653
+ Both ask the policy's `onSlotIdle(slot)`. Under `OIMSlotReleasePolicyAfterIdle`
654
+ it stamps `releaseDueAfterIdleMs = getNowMs() + keepAfterIdleMs` and files the slot
655
+ into the policy's queue. An **empty** slot gets no window, and the release guard
656
+ takes it without asking a clock.
657
+
658
+ **A HELD SLOT IS OFF THE CLOCK.** A sweep that meets one decides nothing and
659
+ does not re-file it. Two consequences follow:
660
+
661
+ - **A hold defers the verdict; it does not restart the clock.** `unholdSlot` reaches
662
+ the release guard, not an idle window, so an entity whose window ran out while
663
+ it was held goes at the give-back itself.
664
+
665
+ ```typescript
666
+ const entitySlot = cardService.holdSlotByKey(cards, 'c1');
667
+ // …the window passes, sweeps run, nothing happens to this slot…
668
+ entitySlot.owner.unholdSlot(entitySlot);
669
+ oimIsSlotReleased(entitySlot); // true — the give-back settles what the sweep could not
670
+ ```
671
+
672
+ A **subscriber** leaving is the other case: `unsubscribeFromSlot` stamps a whole fresh
673
+ window first, so the last `dispose()` grants a full one.
674
+
675
+ - **A cascade takes one sweep, not one window per level.** A stale index goes when
676
+ its own window runs out, and giving back the hold it had on each entity is a departure
677
+ that those entities' own expired windows already answer for, so the entities go in the
678
+ same sweep.
679
+
680
+ Pinned by `src/engine/collector/tests/what-a-stale-index-takes-with-it.test.ts`,
681
+ `…/what-an-identity-nobody-subscribed-to-costs-after-a-while.test.ts` and
682
+ `…/what-a-subscribed-row-costs-the-release-clock.test.ts`.
683
+
684
+ **The grace is consulted only on a slot that is NOT empty.** The release guard
685
+ checks `holdCount`, then `oimIsSlotReleased(slot)`, then `slot.owner !== this`, then
686
+ `hasReference`, and only then, on a slot that is not empty, asks the policy's
687
+ `isSlotReleasable`, which is where the idle window is reached. So an idle window never keeps an
688
+ identity that holds nothing, and a held one never reaches the question at all.
689
+
690
+ ### The collectors
691
+
692
+ A release policy decides; the collector under it does the work of waiting and
693
+ sweeping. A collector is a stateless service over a collector slot, and every `OIMDB` builds its
694
+ slots once, in `db.collectorSlot`:
695
+
696
+ ```typescript signature
697
+ new OIMDB({ getNowMs?, releaseTimer?, errorPolicy? }) // getNowMs defaults to Date.now, releaseTimer to an unref'd setTimeout
698
+ db.collectorSlot.unusedSlotCollectorSlot // OIMUnusedSlotCollectorService.INSTANCE
699
+ db.collectorSlot.afterWriteReleaseSlotCollectorSlot // OIMReleaseSlotCollectorServiceAfterWrite.INSTANCE
700
+ db.collectorSlot.afterIdleReleaseSlotCollectorSlot // OIMReleaseSlotCollectorServiceAfterIdle.INSTANCE
701
+ db.collectorSlot.afterUnholdReleaseSlotCollectorSlot // OIMReleaseSlotCollectorServiceAfterUnhold.INSTANCE
702
+ ```
413
703
 
414
- ### Collections with Bound Indexes
704
+ A release that throws inside a `flush(releaseSlotCollectorSlot)` called by hand throws to that
705
+ caller. A release that throws on the collector's own timer tick has no caller, so it goes to the
706
+ collector slot's `errorPolicy`, which is the `errorPolicy` the `OIMDB` was built with.
707
+
708
+ Every collector extends `OIMFlushSchedulerService`, which owns the schedule and cancel logic around
709
+ a `TOIMTimer = (run: () => void, inMs: number) => TOIMDisposer`, keeping the standing run on the
710
+ slot it schedules. Protected `schedule(slot, delayMs)` does nothing when a scheduled run is already
711
+ standing. A callback that is no longer the one the base handed the timer (a run the host could not
712
+ cancel, still queued after a manual flush) returns without touching anything, so it neither
713
+ flushes early nor lets the next request arm a second wake. The release collectors read the clock
714
+ once per schedule, and cancel and re-schedule only when a shorter window arrives. Pass your own
715
+ `releaseTimer` to the `OIMDB` to drive the collectors off a frame callback, a worker message or a
716
+ test clock.
717
+
718
+ **`OIMReleaseSlotCollectorService`** is the abstract clock the three windowed collectors extend.
719
+ Its slot (`TOIMReleaseSlotCollectorSlot`) carries the queue, `getNowMs` and the `errorPolicy`. It
720
+ sweeps the queue on `flush(slot)` and re-schedules to the next deadline;
721
+ `cancelAllSlotReleases(slot)` cancels and empties the queue, and the next scheduled release
722
+ schedules it again. The one thing a subclass supplies is `getReleaseDueMs(slot)`, the marker its
723
+ policy answers for. The default timer calls `unref` where the platform has it, so an idle process
724
+ is never held open.
725
+
726
+ **`OIMReleaseSlotQueueService`** files slots into **time slices**, not one entry each.
727
+ `oimSliceOfMs(ms)` is `Math.floor(ms / OIM_SLICE_LENGTH_MS)` and `OIM_SLICE_LENGTH_MS`
728
+ is `1024`, so every slot due inside the same ~1s bucket shares one `Set` and one
729
+ timer. Its doors take the release collector slot and the collector whose marker they read, so
730
+ two queues over two markers stay independent: `scheduleSlotRelease`, `cancelSlotRelease`,
731
+ `isSlotReleaseScheduled`, `getNextReleaseSliceNumber`, `releaseDueUpToSliceNumber` and
732
+ `cancelAllSlotReleases`. `scheduleSlotRelease` reads the marker and files at it, so a filing can
733
+ neither shorten nor extend a deadline, and a marker of `-1` files nothing.
734
+
735
+ **`releaseDueUpToSliceNumber` is where the release decision is taken, and it belongs to
736
+ the queue, not the owner.** For each slot in a due slice it skips a released one,
737
+ reads its own marker, drops the slot on `-1`, **re-files it itself** into
738
+ `oimSliceOfMs(getReleaseDueMs)` when that moment is still ahead, and otherwise calls
739
+ `slot.owner.releaseSlotIfUnheld(slot)`, which asks the policy's `isSlotReleasable`
740
+ and so declines while the other policy still keeps the slot. The
741
+ owner files nothing from inside a sweep; it opens a window only on its own events:
742
+ a mint, and the departure of the last subscriber. The sweep raises a floor for its
743
+ duration, so any re-file lands in the next slice and cannot loop.
744
+
745
+ **`OIMUnusedSlotCollectorService`** has nothing to do with time. It is what makes
746
+ `getSlotByKey` safe: `holdSlotUntilFlush(unusedSlotCollectorSlot, slot)` takes a hold, and
747
+ `flush(unusedSlotCollectorSlot)` gives every tracked hold back. `open(slot)` / `close(slot)` nest
748
+ a scope and `run(slot, body)` is the `try/finally` form; `flush` does nothing while a scope is
749
+ open. `close` with no open scope throws by name. `db.flush()` flushes the channel, and the
750
+ channel's flush ends by flushing `db.collectorSlot.unusedSlotCollectorSlot`.
751
+
752
+ ### An arena: force-caching a derivation
753
+
754
+ The `db`'s unused-slot collector slot is the **default**, not the only one.
755
+ `createOIMUnusedSlotCollectorSlot()` is exported, so a caller who wants a derivation kept past the
756
+ turn that read it can build an arena of their own and end it when they choose:
415
757
 
416
758
  ```typescript
417
- import {
418
- OIMReactiveCollection,
419
- OIMReactiveCollectionIndexManualSetBased,
420
- OIMReactiveCollectionIndexManualArrayBased,
421
- OIMEventQueue,
422
- OIMEventQueueSchedulerFactory
423
- } from '@oimdb/core';
759
+ const arena = createOIMUnusedSlotCollectorSlot();
760
+ OIMUnusedSlotCollectorService.INSTANCE.holdSlotUntilFlush(arena, ranksService.computedCollectionService.getSlotByKey(ranks, 'c1')); // held until THIS arena says otherwise
761
+ db.flush(); // the db's collector lets its own go; this one does not
762
+ // …the derivation is still there, and still the same object…
763
+ OIMUnusedSlotCollectorService.INSTANCE.flush(arena); // now it goes, and with it everything only it held
764
+ ```
424
765
 
425
- interface User {
426
- id: string;
427
- name: string;
428
- email: string;
429
- teamId: string;
430
- role: 'admin' | 'user';
431
- }
766
+ **Tracking one derivation keeps its whole subtree, and nobody walks a tree to get
767
+ that.** A computed takes a hold on each of its own direct sources at the mint and
768
+ gives them back when released, so the arena holds the top and the chain holds
769
+ itself. Use one arena per screen, per component or per job: a scope's cache pins what
770
+ that scope read and nothing else.
771
+
772
+ Pinned by `src/engine/collector/tests/what-a-named-arena-keeps-cached.test.ts`.
773
+
774
+ Neither policy means *unread*: no read is reported to the clock. A subscriber
775
+ never loses data mid-read, because an effect holds every root it reaches, so the
776
+ release is refused while one is there. **What happens at the last dispose differs
777
+ by policy, and that is the point of shipping two.** The unsubscribe stamps a whole
778
+ fresh `releaseDueAfterIdleMs`, so the AFTER-IDLE collector grants a full window.
779
+ It does not touch `releaseDueAfterWriteMs`, so an entity that is already stale goes on that
780
+ same call through the `min`. A hold blocks the release either way, and blocks clearing the value
781
+ as well as removing the identity.
782
+
783
+ A bucket runs its own window, given to its own service. An index that nothing
784
+ holds goes when that window runs out, and the hold it gives back on each entity is
785
+ a departure that those entities' own windows answer for, so an entity whose window has also
786
+ run out goes in the same sweep. An
787
+ index that something holds keeps every entity in it, whatever the entities' own age.
788
+
789
+
790
+ ## The six shapes
791
+
792
+ There are three kinds of slot: an **entity**, a **bucket**, and a derived
793
+ **value**. Each has one service, and each service gives two shapes: a single one,
794
+ a store slot that holds one slot under no key, or many, a store slot that files them by key.
795
+
796
+ | kind | single | collection |
797
+ |---|---|---|
798
+ | entity | `createOIMSingleEntityService<TEntity>(db, …)`, then `createStoreSlot(db)` | `createOIMEntityService<TEntity>(db, { keyOf, … })`, then `createStoreSlot(db)` |
799
+ | bucket | `createOIMSingleBucketService<TMember>(db, { slotContentService?, … })`, then `createStoreSlot(db)` | `createOIMBucketService<TMember>(db, { slotContentService?, … })`, then `createStoreSlot(db)` |
800
+ | computed | `createOIMComputedService<void, TValue, TOIMSingleSlotStore<TOIMComputedSlot<TValue, void>>>({ computedSlotStoreService: new OIMSlotStoreServiceSingleSlotDriven<…>(), … })`, then `createComputedStoreSlot(db, derive?, createSources?)` | `createOIMComputedService<TKey, TValue>({ … })`, then `createComputedStoreSlot(db, derive?, createSources?)` |
801
+
802
+ A single is not a second content API. Its content service is the same class with `TKey = void`,
803
+ over the slot store service `OIMSlotStoreServiceSingleSlotDriven`, whose store,
804
+ `TOIMSingleSlotStore<TSlot>`, is `{ slot: TSlot | undefined }`; its store slot is a
805
+ `TOIMSingleSlotStoreSlot`. The collection half is `OIMSingleSlotService` instead of
806
+ `OIMSlotCollectionService`, and it has no key in any door: `getSingleSlot(storeSlot)`,
807
+ `holdSingleSlot(storeSlot)` (given back with `slot.owner.unholdSlot(slot)`),
808
+ `findSingleSlot(storeSlot)`, which mints nothing, `hasSingleSlotContent(storeSlot)`, and the same
809
+ column doors as a collection. The content doors are the collection's:
810
+ `singleService.slotContentService.upsertEntity(storeSlot, entity)`,
811
+ `singleService.slotContentService.findEntityByKey(storeSlot, undefined)`. The single entity
812
+ door builds its content service with `keyOf: () => undefined` and the single-slot store.
813
+
814
+ A single behaves as a collection of at most one slot, and invents nothing of its own.
815
+ `createStoreSlot(db)` mints no slot: the first get or hold mints it, exactly as in a
816
+ collection. An entity single's first value arrives through `upsertEntity`, an ordinary write
817
+ that is announced to whoever subscribed. Its slot is released like any slot of its service,
818
+ under the service's release policy, and the next get mints a fresh one. A store slot is not
819
+ a slot: it has no owner, no holds and no effects, so never hold or subscribe it, and never
820
+ read `storeSlot.store` yourself.
821
+
822
+ Both computed shapes own `TOIMComputedSlot`, the plain data type
823
+ minted from `createSources`. A computed slot dies when nothing holds it; the service and
824
+ its store slot outlive it, and the service mints a fresh one on the next read or hold, by key
825
+ for the collection and under the key `undefined` for the single. A computed service's slots
826
+ are reached through its `computedCollectionService` (an `OIMSlotCollectionService`):
827
+ `computedService.computedCollectionService.getSlotByKey(storeSlot, key)` /
828
+ `holdSlotByKey(storeSlot, key)`. The value is read with
829
+ `slot.computedValueService.getValueForSlot(slot)`, and a hold is given back with
830
+ `slot.owner.unholdSlot(slot)`.
831
+
832
+ A single bucket is built like a keyed one, with `createOIMSingleBucketService`.
833
+ `singleBucketService.getSingleSlot(storeSlot)` is the `TOIMBucketSlot` to subscribe effects to
834
+ or hand to a reader. The member doors live on its `slotContentService` and take the bucket slot
835
+ first: `getBucketMemberSlots(bucketSlot)`, `getBucketMemberSlotCount(bucketSlot)`,
836
+ `hasBucketMemberSlot(bucketSlot, bucketMemberSlot)`,
837
+ `addBucketMemberSlot(s)(bucketSlot, …)`, `removeBucketMemberSlot(s)(bucketSlot, …)`,
838
+ `writeBucketMemberSlots(bucketSlot, bucketMemberSlots): boolean`,
839
+ `clearBucketMemberSlots(bucketSlot)`, `fillBucketMemberSlots`, `markContentChanged(bucketSlot)` and
840
+ `markBucketColumnsChanged(bucketSlot, patch, changedColumns)`. The array-driven content service
841
+ adds `getBucketMemberSlotAt(bucketSlot, index)`,
842
+ `getBucketMemberSlotPosition(bucketSlot, bucketMemberSlot)`, `insertBucketMemberSlotAt`,
843
+ `removeBucketMemberSlotAt`, `removeBucketMemberSlotRange`,
844
+ `moveBucketMemberSlotToPosition` and `moveBucketMemberSlotRangeToPosition`, each with the
845
+ same leading `bucketSlot`. A single bucket that is emptied and unheld is released like any
846
+ other, and the next `getSingleSlot(storeSlot)` mints a fresh, empty one.
847
+
848
+ `createOIMComputedService({ … })` takes no `db`: it fills the computed slot store service and
849
+ the column store, and the channel comes from the `db` handed to `createComputedStoreSlot`.
850
+
851
+ ---
852
+
853
+ ## Keys, and where identities live
854
+
855
+ The store knows how a key is taken apart, and it is a constructor
856
+ argument, not a subclass.
857
+
858
+ ```typescript signature
859
+ // A scalar key: string | number.
860
+ new OIMSlotStoreServiceMapDriven<TOIMEntitySlot<Card, TOIMStoreKey>>()
861
+
862
+ // A composite key of VALUES, descended one composite key part at a time.
863
+ new OIMSlotStoreServiceTreeDriven<TOIMStoreKey, TOIMKeyedSlot<readonly TOIMStoreKey[]>>(OIMDefaults.KEY_TREE_WALK_SCALAR.getValue())
864
+
865
+ // A composite key of IDENTITIES, descended the same way — and holding each.
866
+ new OIMSlotStoreServiceTreeDriven<TOIMEntitySlot, TOIMKeyedSlot<readonly TOIMEntitySlot[]>>(OIMDefaults.KEY_TREE_WALK_COMPOSITE.getValue())
867
+ ```
432
868
 
433
- // Create event queue
434
- const queue = new OIMEventQueue({
435
- scheduler: OIMEventQueueSchedulerFactory.createMicrotask()
436
- });
869
+ There are two stores, one per shape of address, and no store is ever asked at run time
870
+ which one it was handed. The chain store's two strategies are one class that takes its
871
+ descent at construction. Both strategies descend one `Map` lookup per composite key part,
872
+ keep `[a]` and `[a, b]` apart, and each composite key part keeps its own type, so `1` and
873
+ `'1'` are two composite key parts. There is no encoding step, and there must not be one.
437
874
 
438
- // Collections own entities.
439
- const users = new OIMReactiveCollection<User, string>(queue, {
440
- selectPk: (user: User) => user.id
441
- });
875
+ ### The two descents are a fork, and the choice is about lifetime
442
876
 
443
- // Indexes live next to collections and bind to them at construction time.
444
- const indexes = {
445
- usersByTeam: new OIMReactiveCollectionIndexManualSetBased<
446
- string,
447
- string,
448
- User
449
- >(queue, { collection: users }),
450
- usersByRole: new OIMReactiveCollectionIndexManualArrayBased<
451
- string,
452
- string,
453
- User
454
- >(queue, { collection: users })
455
- };
877
+ **A chain of values**, such as `['deck-1', 'red']`. Nothing is held, because there is
878
+ nothing to hold: nobody files `'red'`, nobody can release it, and a level's `Map`
879
+ answers for a rebuilt `'red'` exactly as it answered for the first. A branch
880
+ lives as long as the subtree under it, and goes the instant nothing is left
881
+ below it.
456
882
 
457
- // Subscribe to index changes
458
- indexes.usersByTeam.updateEventEmitter.subscribeOnKey('engineering', () => {
459
- console.log('Engineering team changed:', indexes.usersByTeam.getPksByKey('engineering'));
883
+ ```typescript
884
+ type TScalarChain = readonly TOIMStoreKey[];
885
+ type Tally = { deck: string; day: string; count: number };
886
+ type TTallyStore = TOIMSlotStoreTree<TOIMEntitySlot<Tally, TScalarChain>, TOIMStoreKey>;
887
+
888
+ const tallyService = createOIMEntityService<Tally, TOIMEntityColumnName<Tally>, TScalarChain, TTallyStore>(db, {
889
+ entitySlotStoreService:
890
+ new OIMSlotStoreServiceTreeDriven<TOIMStoreKey, TOIMEntitySlot<Tally, TScalarChain>>(
891
+ OIMDefaults.KEY_TREE_WALK_SCALAR.getValue(),
892
+ ),
893
+ keyOf: (t) => [t.deck, t.day], // the two names, in order
460
894
  });
461
-
462
- // Add users and update indexes
463
- users.upsertMany([
464
- { id: 'u1', name: 'John', email: 'john@test.com', teamId: 'engineering', role: 'admin' },
465
- { id: 'u2', name: 'Jane', email: 'jane@test.com', teamId: 'engineering', role: 'user' }
466
- ]);
467
-
468
- // Update indexes manually
469
- indexes.usersByTeam.setPks('engineering', ['u1', 'u2']);
470
- indexes.usersByRole.setPks('admin', ['u1']);
895
+ const tallies = tallyService.createStoreSlot(db);
896
+ tallyService.slotContentService.upsertEntity(tallies, { deck: 'spades', day: 'mon', count: 1 });
897
+ tallyService.getSlotByKey(tallies, ['spades', 'mon']);
471
898
  ```
472
899
 
473
- ### Collection Relations Helper
474
-
475
- Use `createOIMCollectionIndexFactory` when you want the same clean model with less constructor noise. It does not store indexes inside the collection; it only keeps the shared `queue + collection` binding for related structures that live next to the collection.
900
+ **A chain of identities**: each composite key part is a slot, and each edge takes a counted
901
+ hold on the identity it is keyed by. So an entity keyed by `[user, card]` keeps that
902
+ user's identity resolvable for as long as the entity is addressable, which a chain of names
903
+ cannot do.
476
904
 
477
905
  ```typescript
478
- import {
479
- createOIMCollectionIndexFactory,
480
- OIMReactiveCollection,
481
- OIMEventQueue
482
- } from '@oimdb/core';
483
-
484
- type User = {
485
- id: string;
486
- name: string;
487
- teamId: string;
906
+ type User = { id: string; name: string };
907
+ type Group = { id: string; name: string };
908
+ type Enrolment = {
909
+ user: TOIMEntitySlot<User, TOIMStoreKey>;
910
+ group: TOIMEntitySlot<Group, TOIMStoreKey>;
911
+ since: number;
488
912
  };
489
-
490
- type Card = {
491
- id: string;
492
- deckId: string;
493
- position: number;
494
- };
495
-
496
- const queue = new OIMEventQueue();
497
- const users = new OIMReactiveCollection<User, string>(queue, {
498
- selectPk: user => user.id,
499
- });
500
- const userRelations = createOIMCollectionIndexFactory(queue, users);
501
- const cards = new OIMReactiveCollection<Card, string>(queue, {
502
- selectPk: card => card.id,
913
+ type TChain = readonly TOIMEntitySlot[];
914
+
915
+ const userService = createOIMEntityService<User>(db, { keyOf: (user) => user.id });
916
+ const users = userService.createStoreSlot(db);
917
+ const groupService = createOIMEntityService<Group>(db, { keyOf: (group) => group.id });
918
+ const groups = groupService.createStoreSlot(db);
919
+ const alice = userService.slotContentService.upsertEntity(users, { id: 'alice', name: 'Alice' });
920
+ const admins = groupService.slotContentService.upsertEntity(groups, { id: 'admins', name: 'Admins' });
921
+
922
+ type TEnrolmentStore = TOIMSlotStoreTree<TOIMEntitySlot<Enrolment, TChain>, TOIMEntitySlot>;
923
+
924
+ const enrolmentService = createOIMEntityService<Enrolment, TOIMEntityColumnName<Enrolment>, TChain, TEnrolmentStore>(db, {
925
+ entitySlotStoreService:
926
+ new OIMSlotStoreServiceTreeDriven<TOIMEntitySlot, TOIMEntitySlot<Enrolment, TChain>>(
927
+ OIMDefaults.KEY_TREE_WALK_COMPOSITE.getValue(),
928
+ ),
929
+ keyOf: (m) => [m.user, m.group], // the two identities, in order
503
930
  });
504
- const cardRelations = createOIMCollectionIndexFactory(queue, cards);
505
-
506
- // Derived indexes update themselves from collection writes.
507
- const usersByTeam = userRelations.derivedSetIndex(user => user.teamId);
508
- const cardsByDeck = cardRelations.derivedArrayIndex(
509
- card => card.deckId,
510
- { orderBy: card => card.position }
511
- );
512
-
513
- // Manual relations are still available when membership comes from outside.
514
- const searchResults = userRelations.arrayBasedIndex<string>();
515
- const orderedUsers = userRelations.orderedList<string>();
516
-
517
- users.upsertMany([
518
- { id: 'u1', name: 'Alice', teamId: 'team1' },
519
- { id: 'u2', name: 'Bob', teamId: 'team1' },
520
- ]);
521
- cards.upsertMany([
522
- { id: 'c1', deckId: 'deck1', position: 2 },
523
- { id: 'c2', deckId: 'deck1', position: 1 },
524
- ]);
525
-
526
- searchResults.setPks('query:alice', ['u1']);
527
- orderedUsers.set('visible', ['u1', 'u2']);
528
- queue.flush();
529
-
530
- console.log(usersByTeam.getEntitiesByKey('team1')); // Alice, Bob
531
- console.log(cardsByDeck.getPksByKey('deck1')); // ['c2', 'c1']
532
- console.log(orderedUsers.getPksByKey('visible')); // ['u1', 'u2']
931
+ const enrolments = enrolmentService.createStoreSlot(db);
932
+ enrolmentService.slotContentService.upsertEntity(enrolments, { user: alice, group: admins, since: 2026 });
933
+ enrolmentService.getSlotByKey(enrolments, [alice, admins]);
533
934
  ```
534
935
 
535
- ### Custom Entity Updater
936
+ The two descents are `OIMKeyTreeWalkScalar` and `OIMKeyTreeWalkComposite`, and
937
+ `OIMDefaults` holds one stateless instance of each. The composite walk
938
+ is the one whose `holdCompositeKeyPart` runs `entitySlot.owner.holdSlot(entitySlot)` when `mintNodeAt` creates an edge, and
939
+ whose `unholdCompositeKeyPart` runs `entitySlot.owner.unholdSlot(entitySlot)` (unless `oimIsSlotReleased(entitySlot)`) on exactly the edges `deleteKey` unlinked; the scalar walk holds nothing.
940
+ `deleteKey(rootCompositeKeyNode, compositeKey)` takes the value filed at the key in one
941
+ descent (one `Map.get` per key part, no array of the path) and unlinks the nodes that the
942
+ delete left empty. A node that still leads to a longer key stays.
943
+ Both walks throw `an empty key is not a key` on `[]`.
944
+
945
+ **A scalar is not a composite key part of a composite-walk key.** It is a compile
946
+ error, and past a cast it throws inside the mint (`Cannot create property
947
+ 'holdCount' on string`) with the edge already in the tree. Hand the store
948
+ `OIMDefaults.KEY_TREE_WALK_SCALAR.getValue()` instead of using a cast.
949
+
950
+ **Compare a chain with the strategy that matches it.** `oimSameSlotSequence` walks identities by
951
+ reference. Using it on a chain of values is the `NaN` defect: it answers
952
+ "a different address" for the address the tree itself is looking at. A chain or list of values
953
+ goes to `oimSameKeySequence`, which compares each key by SameValueZero, the way a `Map` files it.
954
+
955
+ ---
956
+
957
+ ## Writing
958
+
959
+ Every write on an entity collection is on its `slotContentService`, and is stated in terms of an
960
+ **entity** or of an **identity you already hold**. There is no write by key: a key is resolved
961
+ to a slot once, with `findSlotByKey` (mints nothing) or `getSlotByKey` / `holdSlotByKey` on the
962
+ collection, and the write goes through the slot. A door by key takes the store slot first; a door
963
+ on a slot takes only the slot.
964
+
965
+ | Call on `entityService.slotContentService` | What it does |
966
+ |---|---|
967
+ | `upsertEntity(storeSlot, entity, changedEntitySlots?)` | Write a whole value. A key with no value gets one and every column counts as new; a key that has one is brought to the value given, and columns the new value does not carry are taken away. **The identity always comes back**, whatever the write changed. |
968
+ | `upsertEntities(storeSlot, entities, changedEntitySlots?)` | The loop. Correct at any moment. **Returns the slot of every entity, in input order**, whether or not its write changed anything — so `bucketService.slotContentService.writeBucketMemberSlots(bucketSlot, entityService.slotContentService.upsertEntities(storeSlot, entities))` files every entity. |
969
+ | `insertEntity(storeSlot, key, entity, changedEntitySlots?)` | `upsertEntity` under a key you name instead of `keyOf`'s. |
970
+ | `insertEntities(storeSlot, entities)` / `fillEntities(storeSlot, keys, entities)` | A **first load only** — refuses unless the store slot is empty, so it checks emptiness once instead of per entity. No `changed`: a first load changes every entity. |
971
+ | `writeEntity(slot, entity)` | A whole value through an identity you hold; `false` when nothing changed. |
972
+ | `updateEntity(slot, patch)` | Patch through an identity you hold. Only the columns the patch actually moved are routed. |
973
+ | `updateEntityColumn(slot, columnName, value)` | One column through an identity you hold, with no patch object to build and walk. |
974
+ | `deleteEntityColumns(slot, columnNames)` | Take those columns off the value. |
975
+ | `deleteEntity(slot)` | The **value** leaves; the identity stays for as long as anything still refers to it. |
976
+ | `deleteEntities(storeSlot, slots)` | The batch form. One change span for the whole batch, like `upsertEntities` — the store slot is named to the channel once for the batch, not once per entity. |
977
+ | `clearAllEntities(storeSlot)` | Every value out. Identities something still holds survive without one. |
978
+ | `findEntityByKey(storeSlot, key)` | The entity itself rather than the slot. Mints nothing. |
979
+ | `markEntityChanged(slot, changedColumns?)` | Report a change you made to `slot.entity` by hand. |
980
+
981
+ By key, compose the two halves: `const s = entityService.findSlotByKey(storeSlot, key); if (s !== undefined) entityService.slotContentService.updateEntity(s, patch);`.
982
+
983
+ **Who owns the entity is a strategy you pick, and there are two strategies.** The
984
+ writer takes an `entityInsertStrategy` through `TOIMEntityWriterOptions`, so the choice is
985
+ made once at construction and costs nothing per write:
986
+
987
+ | strategy | what lands in the slot | what the caller keeps |
988
+ |---|---|---|
989
+ | `oimInsertEntityWithoutCopy` — the default | the object you handed in, no copy | the slot's content itself, so your later edits ARE the slot's — report them with `markEntityChanged`; with the in-place writer a later update edits your object |
990
+ | `oimInsertEntityWithCopy` | `{ ...entity }`, a **one-level** copy | nothing the engine can see; mutating your object afterwards does nothing |
991
+ | `oimInsertEntityWithSanitizedCopy` | a one-level copy with an own `__proto__` key removed | the same; pick it when you insert untrusted JSON (`JSON.parse` of other users' data) |
536
992
 
537
993
  ```typescript
538
- import {
539
- TOIMEntityUpdater,
540
- OIMReactiveCollection,
541
- OIMEventQueue
542
- } from '@oimdb/core';
543
-
544
- // Custom deep merge updater
545
- const deepMergeUpdater: TOIMEntityUpdater<User> = (newEntity, oldEntity) => {
546
- const result = { ...oldEntity };
547
-
548
- for (const [key, value] of Object.entries(newEntity)) {
549
- if (value !== undefined) {
550
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
551
- result[key] = deepMergeUpdater(value, result[key] || {});
552
- } else {
553
- result[key] = value;
554
- }
555
- }
556
- }
557
-
558
- return result;
559
- };
560
-
561
- // Use custom updater with reactive collection
562
- const queue = new OIMEventQueue();
563
- const users = new OIMReactiveCollection<User, string>(queue, {
564
- selectPk: (user) => user.id,
565
- updateEntity: deepMergeUpdater
994
+ const copiedCardService = createOIMEntityService<Card>(db, {
995
+ keyOf: (card) => card.id,
996
+ entityWriter: createOIMEntityWriterInPlace({ entityInsertStrategy: oimInsertEntityWithCopy }),
566
997
  });
567
-
568
- // Now updates will use deep merge logic
569
- users.upsertOne({ id: 'user1', name: 'John' });
570
- users.upsertOne({ id: 'user1', email: 'john@example.com' }); // Merges with existing
571
998
  ```
572
999
 
573
- #### Built-in updaters
1000
+ **A copy is one level deep, and no deeper.** A nested object or
1001
+ array inside the entity is the SAME reference in the slot as in the object you
1002
+ handed in, under every strategy, so a caller who edits `handed.tags.push(...)` is
1003
+ editing the slot either way. The copying strategies buy exactly one thing: the top
1004
+ level of keys cannot be re-pointed behind the engine's back.
574
1005
 
575
- Two updater strategies ship as factories — pass the result as `updateEntity`:
1006
+ Either way the stored entity is reachable as `slot.entity`, and editing **it** is
1007
+ a supported write, provided you say so afterwards. That is
1008
+ `markEntityChanged`, below.
576
1009
 
577
- ```typescript
578
- import { createMergeEntityUpdater, createInPlaceEntityUpdater } from '@oimdb/core';
1010
+ ### `markEntityChanged`: you moved the columns, so you supply the list
579
1011
 
580
- // Default: immutable shallow merge — `{ ...prev, ...draft }`. Each update produces
581
- // a NEW entity object (required for React's Object.is / useSyncExternalStore).
582
- const a = new OIMReactiveCollection<User, string>(queue, {
583
- selectPk: (u) => u.id,
584
- updateEntity: createMergeEntityUpdater(),
585
- });
586
-
587
- // In-place: `Object.assign(prev, draft)` — mutates the existing object, no
588
- // per-update allocation. The entity reference is STABLE across changes, so it
589
- // only works with subscription-driven readers (e.g. the `*Signal` hooks in
590
- // @oimdb/react), not with Object.is/React.memo diffing. Fastest on the data
591
- // layer for update-heavy workloads.
592
- const b = new OIMReactiveCollection<User, string>(queue, {
593
- selectPk: (u) => u.id,
594
- updateEntity: createInPlaceEntityUpdater(),
595
- });
1012
+ ```typescript signature
1013
+ markEntityChanged(
1014
+ slot: TOIMEntitySlot<TEntity, TKey>,
1015
+ changedColumns?: readonly (keyof TEntity & string)[],
1016
+ ): void
596
1017
  ```
597
1018
 
598
- ### Event Coalescing and Update Subscriptions
1019
+ `upsertEntity` and `updateEntity` work out what moved: the writer walks the incoming
1020
+ value (`oimPatchEntityColumns`, `oimPutEntityColumns`) and puts every column through `sameValueGuard`. A caller who edited the stored object
1021
+ already knows what moved, and this door takes that answer instead of deriving
1022
+ it, so there is no patch object, no copy and no per-column comparison.
599
1023
 
600
1024
  ```typescript
601
- import {
602
- OIMReactiveCollection,
603
- OIMEventQueue,
604
- OIMEventQueueSchedulerFactory
605
- } from '@oimdb/core';
606
-
607
- // Create collection with microtask scheduler for coalescing
608
- const queue = new OIMEventQueue({
609
- scheduler: OIMEventQueueSchedulerFactory.createMicrotask()
610
- });
1025
+ slot.entity!.title = 'two';
1026
+ cardService.slotContentService.markEntityChanged(slot, ['title']);
1027
+ ```
611
1028
 
612
- const users = new OIMReactiveCollection<User, string>(queue);
1029
+ **The hand mutation needs no cast.** `TOIMEntitySlot` declares
1030
+ `readonly entity: TEntity | undefined`, and `readonly` forbids replacing the
1031
+ object, not writing its columns. You still have to narrow the `| undefined`.
1032
+ `TOIMSlotEditable<TSlot>` is the type that strips `readonly` from the field
1033
+ itself, which is what assigning `slot.entity` needs.
613
1034
 
614
- // Subscribe to coalesced updates for specific keys
615
- users.updateEventEmitter.subscribeOnKey('user1', () => {
616
- console.log('User1 updated (coalesced)');
617
- });
1035
+ **The list is the whole point, and leaving it out gives it back.** Named columns
1036
+ wake and the rest are left alone. With no list, the column owner walks
1037
+ the slot's entire column store. A column slot lives there only while
1038
+ something holds it or subscribes to it, since the collection's column owner
1039
+ drops it the moment neither does, so "watched" is exactly what the store
1040
+ contains. The convenient overload is therefore the one that brings back a
1041
+ per-column walk.
618
1042
 
619
- // Multiple rapid updates to same key will be coalesced
620
- users.upsertOne({ id: 'user1', name: 'John' });
621
- users.upsertOne({ id: 'user1', name: 'John Doe' });
622
- users.upsertOne({ id: 'user1', email: 'john@example.com' });
1043
+ **An empty list wakes nothing at all** (not the columns, not the entity, not the
1044
+ store slot), because `[]` is the caller saying that nothing moved, and the call returns
1045
+ before the subframe opens.
623
1046
 
624
- // Only one notification will fire (in next microtask)
1047
+ **It re-stamps emptiness** itself when the list is left out, when the entity is gone, or when
1048
+ the service recomputes emptiness on a column write. A hand
1049
+ mutation that emptied the entity would otherwise leave `oimIsSlotEmpty(slot)` saying the
1050
+ slot has content, and the identity would outlast its value.
625
1051
 
626
- // (No separate "coalescer" object exists: batching/deduplication is handled inside OIMUpdateEventEmitter.)
627
- ```
1052
+ **It refuses inside a flush** (`assertWritable` → `OIMChannelService.INSTANCE.assertNotFlushing(channelSlot)`,
1053
+ the same gate as every other write) and throws on a released identity. Both of
1054
+ the reporting forms also wake the entity slot and the store slot, so an effect on the
1055
+ slot sees the change as well as one on a column.
628
1056
 
629
- ## 🔄 Reactive Architecture
1057
+ A misspelt column name, a bare runtime `string`, and a slot of a different entity
1058
+ type are each a compile error, because the parameter is `readonly (keyof TEntity & string)[]`.
630
1059
 
631
- ### Event-Driven Updates
1060
+ **A removal leaves the buckets standing.** A slot does not know which buckets it
1061
+ is in, so `deleteEntity` cannot take it out of them. A bucket's slots belong to the caller: the
1062
+ caller who put the slot in a bucket takes it out.
632
1063
 
633
- OIMDB core uses a reactive architecture where changes automatically trigger notifications to subscribers:
1064
+ ### Which entities actually changed: the `changed` array
634
1065
 
635
1066
  ```typescript
636
- // Collection updates trigger events through the event queue
637
- collection.upsertOne(entity) → updateEventEmitter → event queue → subscribers
638
-
639
- // Key-specific subscriptions only notify when relevant data changes
640
- updateEventEmitter.subscribeOnKey('user1', callback) // Only fires for user1 changes
1067
+ const payload: Card[] = [];
1068
+ const reindex = (changedSlot: TOIMEntitySlot<Card, TOIMStoreKey>): void => {};
1069
+ const changed: TOIMEntitySlot<Card, TOIMStoreKey>[] = [];
1070
+ cardService.slotContentService.upsertEntities(cards, payload, changed);
1071
+ for (const changedSlot of changed) reindex(changedSlot); // the re-sent entities are not in here
1072
+ changed.length = 0;
641
1073
  ```
642
1074
 
643
- ### Event Coalescing
644
-
645
- Multiple rapid changes to the same entity are automatically coalesced:
646
-
647
- ```typescript
648
- // These three updates...
649
- users.upsertOne({ id: 'user1', name: 'John' });
650
- users.upsertOne({ id: 'user1', email: 'john@test.com' });
651
- users.upsertOne({ id: 'user1', role: 'admin' });
1075
+ The array is **optional, yours, and appended to**. The engine never reads it
1076
+ and never clears it. A slot is pushed exactly when the write put in content different
1077
+ from what that slot already held, so one array reused across a whole batch comes
1078
+ out as the list of entities that a bucket pass owes work for. The same parameter
1079
+ stated by key is `cardService.slotContentService.insertEntity(cards, key, entity, changed)`.
1080
+
1081
+ It exists because `sameValueGuard` already refused those writes column by column,
1082
+ and without this parameter the verdict would stay inside the engine. A consumer that owns a
1083
+ bucket this engine does not maintain would then re-report every entity of every
1084
+ re-sent payload. It is an out-parameter rather than a return because the return is taken
1085
+ by the rule above, and a `{ slot, changed }` record would allocate once per
1086
+ write on the path this parameter exists to make cheaper. A caller who does not want it pays
1087
+ one `undefined` compare per write.
1088
+
1089
+ One layer down, the slot doors that write a whole content (`writeEntity(slot, entity)`,
1090
+ `writeBucketMemberSlots(bucketSlot, bucketMemberSlots)`) compute it, and their boolean is **a fact about the content and says nothing about the
1091
+ identity**: `false` does not mean "this slot may go". That question is answered by `oimIsSlotEmpty(slot)`
1092
+ and the grace left on the idle window, asked by the release guard.
1093
+
1094
+ ### The writer is a fork, and both strategies ship
1095
+
1096
+ ```typescript signature
1097
+ interface IOIMEntityWriter {
1098
+ readonly fillEntity: (slot: TOIMEntitySlot, entity: object) => void;
1099
+ readonly putEntity: (slot: TOIMEntitySlot, entity: object, changedColumnNames: string[] | undefined) => boolean;
1100
+ readonly patchEntity: (slot: TOIMEntitySlot, patch: object, changedColumnNames: string[] | undefined) => boolean;
1101
+ readonly patchColumn: (
1102
+ slot: TOIMEntitySlot, columnName: string, value: unknown, changedColumnNames: string[] | undefined,
1103
+ ) => boolean;
1104
+ readonly deleteColumns: (slot: TOIMEntitySlot, names: readonly string[]) => readonly string[];
1105
+ readonly clearEntity: (slot: TOIMEntitySlot) => void;
1106
+ }
652
1107
 
653
- // ...result in only one notification with the final state
654
- // This prevents unnecessary re-renders and improves performance
1108
+ createOIMEntityWriterInPlace(options?) // the stored object keeps its identity
1109
+ createOIMEntityWriterReplacing(options?) // every write that changed something produces a new object
1110
+ // TOIMEntityWriterOptions = { sameValueGuard?: TOIMSameValueGuard; entityInsertStrategy?: TOIMEntityInsertStrategy; putEntityColumns?: typeof oimPutEntityColumns }
655
1111
  ```
656
1112
 
657
- ### Disposing everything at once — `OIMDisposeScope`
658
-
659
- Teardown is LIFO: dispose dependents before what they depend on (indexes / streams / selectors → collection → queue). Instead of tracking that order by hand, register into an `OIMDisposeScope` as you build and tear it all down in one call:
1113
+ A writer moves an entity's columns and does nothing else: it reaches no channel,
1114
+ takes no holds, and knows nothing about lifetime. `deleteColumns` hands back **the
1115
+ names that actually moved**, or a frozen empty array when nothing did. A
1116
+ service routes these names to column subscribers, which is why a write that changes
1117
+ nothing wakes nobody. `putEntity` and `patchEntity` answer whether anything moved
1118
+ and name the moved columns only into the array they are handed. The content service
1119
+ hands one only when the slot has changed-column readers (it has column slots, or an
1120
+ index listens), so a write of an entity that nobody reads by column allocates no names, and
1121
+ an index wakes a bucket column exactly when a member's value of it changed or left. `sameValueGuard` defaults
1122
+ to `oimSameValueZeroGuard` and is the per-column comparison that decides "moved".
1123
+
1124
+ A writer of your own can reuse the column walks that both shipped writers run,
1125
+ `oimPatchEntityColumns`, `oimPatchEntityColumn` (one column, behind `patchColumn`) and
1126
+ `oimPutEntityColumns`. The whole-entity put is itself the writers' `putEntityColumns` option:
1127
+ `oimPutEntityColumns` is the default and skips neither `__proto__` keys nor inherited stored
1128
+ columns; `oimPutEntityColumnsGuarded` keeps both guards, at a measured cost on every put, for
1129
+ values that can carry a `__proto__` key or are class instances
1130
+ (`createOIMEntityWriterInPlace({ putEntityColumns: oimPutEntityColumnsGuarded })`). Each returns the object
1131
+ `targetOf` handed back with the moved columns written into it, or `undefined`
1132
+ when nothing moved, so the writer decides only where the result goes. The
1133
+ [docs](https://oimdb.org/docs/core/writing#writing-your-own-writer) build one that
1134
+ stores every value frozen.
1135
+
1136
+ `putEntity` is PUT and `patchEntity` is PATCH, as in HTTP. PUT makes the stored
1137
+ entity the value handed in: columns that changed are written and columns the value
1138
+ does not have are cleared to `undefined`, in one call, and the names it returns cover both. PATCH writes
1139
+ only the columns the patch names. `upsertEntity` of a key that has a value and
1140
+ `writeEntity(slot, entity)` are PUT, so a whole-entity write that drops a column writes
1141
+ the slot to the channel once, and the replacing writer builds one new object, not
1142
+ two. This is counted in `src/facade/entity/tests/what-a-put-that-drops-a-column-costs.test.ts`.
1143
+
1144
+ Neither writer `delete`s. A column that a PUT drops or `deleteColumns` takes away is
1145
+ written `undefined`, into the stored object in place or into a spread copy when
1146
+ replacing, so the entity keeps its hidden class: the key stays in
1147
+ `Object.keys` and `in`, and the column reads `undefined`. A column holding
1148
+ `undefined` counts as absent: a later write that leaves it out
1149
+ does not report it, and taking the entity away does not wake its readers.
1150
+
1151
+ The choice is made once, when the entity content service is built (`entityWriter`, else
1152
+ `db.entityWriter`), so there is no `if (inPlace)` anywhere on any write path; the object already
1153
+ is the answer. The default is `createOIMEntityWriterInPlace()`. Pass `createOIMEntityWriterReplacing()` when
1154
+ something downstream compares by reference.
1155
+
1156
+ ---
1157
+
1158
+ ## Frames, channels and effects
1159
+
1160
+ A channel is the runtime: the frame counter, the revision, and the delivery pass. Its state is a
1161
+ `TOIMChannelSlot` (the `OIMDB` builds one, `db.defaultChannelSlot`), and its operations are on
1162
+ `OIMChannelService.INSTANCE`, each handed the channel slot. The options go to the `OIMDB`:
660
1163
 
661
1164
  ```typescript
662
- import { OIMDisposeScope, OIMEventQueue, OIMReactiveCollection } from '@oimdb/core';
663
-
664
- const scope = new OIMDisposeScope();
665
- const queue = scope.add(new OIMEventQueue());
666
- const users = scope.add(new OIMReactiveCollection(queue, { selectPk: u => u.id }));
667
- const byTeam = scope.add(usersIndexFactory.setBasedIndex());
668
- scope.add(selector.watch(render)); // bare unsubscribe fn works too
669
-
670
- scope.destroy(); // disposes byTeam → users → queue (reverse order), no bookkeeping
1165
+ const sentReports: unknown[] = [];
1166
+ const scheduledDb = new OIMDB({
1167
+ channelScheduler: (flush) => { void Promise.resolve().then(flush); },
1168
+ errorPolicy: new OIMErrorPolicyLogAndContinue({ onError: (error) => { sentReports.push(error); } }),
1169
+ onBeforeFlush: () => { /* the flush has begun; read only, no writes */ },
1170
+ });
671
1171
  ```
672
1172
 
673
- `add(x)` returns `x` for inline capture. Accepts both `{ destroy(): void }` objects and bare `() => void` unsubscribe functions (selectors, per-key subscriptions and scheduler tasks expose only the latter). Idempotent; every item is disposed even if one throws (first error rethrown after); `child()` nests a sub-scope. Factory: `createOIMDisposeScope()`. Related exports: `IOIMDisposable`, `TOIMDisposable`.
674
-
675
- ### Effects, Computed, and the Event Lifecycle
676
-
677
- OIMDB uses a single-pass flush boundary: `queue.flush()` executes the current batch of pending work.
678
-
679
- Effects and computed values are scheduled through `OIMComputeRuntime`, which is backed by the same queue. This keeps the public API simple and avoids a multi-phase flush model.
680
-
681
- #### What is an Effect?
682
-
683
- `OIMEffect` is the base reactive primitive: it subscribes to dependencies and calls `run()` when those dependencies change. It coalesces multiple invalidations during the same flush into a single run.
684
-
685
- **Basic example with reactive object:**
1173
+ With no `channelScheduler`, nothing is ever delivered until you call `db.flush()`
1174
+ yourself. A second independent channel is a second `OIMDB`.
1175
+
1176
+ **Errors thrown by effects and handlers go to the error policy.** The default,
1177
+ `OIMErrorPolicyLogAndContinue`, logs each one with `console.error`, hands it to the
1178
+ optional `onError` (Sentry, the devtools registry), and finishes the rest of the
1179
+ flush. `OIMErrorPolicyThrowAtOnce` instead throws the first one out of `flush()`;
1180
+ effects the frame had not reached stay queued for the next flush. An effect whose
1181
+ body or cleanup threw has not taken the change it woke for: it is stamped only
1182
+ after the body returns, so its next wake runs that change again. It is never
1183
+ re-run inside the frame it threw in, so a body that always throws costs one run
1184
+ and one caught error per wake, not a loop. A cleanup that disposes its own effect
1185
+ stops the body that would have run after it. A computed is stamped fresh only after
1186
+ its derive and its `sameValueGuard` have returned, so a throw in either derives again at
1187
+ the next read; a computed with no sources derives on its first read. A throw never stops the channel from asking
1188
+ for frames: the next write after an aborted frame asks for one. A throw in one
1189
+ release of a sweep leaves the rest of that slice on the release clock for the next
1190
+ sweep. A release that falls inside a flush (an effect body disposing another
1191
+ effect) waits: the slot goes to the unused-slot collector and is released once the
1192
+ flush is over. Code that keeps
1193
+ derived state (an index's `keyOf`, an emptiness check) is outside the
1194
+ policy: a throw there leaves state consistent and is thrown in either mode. An
1195
+ index whose `keyOf` throws on an entity files that entity under no key, finishes the rest
1196
+ of the write or batch, then rethrows; the next write it accepts files the entity again.
1197
+
1198
+ **Subscribing is building an effect.** There is no `subscribe` helper over this,
1199
+ and there must not be one. An effect is stamped with the channel clock at birth,
1200
+ so `effectService.createEffect` hears only what moves after it and never delivers a first
1201
+ value. `effectService.createEffectImmediate(db, roots, computeds, body)` is the door that runs
1202
+ the body once at creation. An effect is a plain `TOIMEffect` object with no methods (its
1203
+ channel is `effect.channelSlot`); `OIMEffectService.INSTANCE` does everything to it. There is
1204
+ one effect service for the whole process, and every door is handed the `db` (any
1205
+ `IOIMChannelMap`) whose `defaultChannelSlot` the effect runs on.
686
1206
 
687
1207
  ```typescript
688
- import {
689
- OIMEffect,
690
- OIMComputeRuntime,
691
- OIMEventQueue,
692
- OIMReactiveObject,
693
- OIMEffectDependencyKeyedObject,
694
- } from '@oimdb/core';
695
-
696
- type TKey = 'a';
697
-
698
- const queue = new OIMEventQueue();
699
- const runtime = new OIMComputeRuntime(queue);
700
- const obj = new OIMReactiveObject<TKey, number>(queue);
701
-
702
- const effect = new OIMEffect(runtime, {
703
- deps: [new OIMEffectDependencyKeyedObject(obj, 'a')],
704
- run: () => {
705
- console.log('obj.a changed');
706
- },
707
- });
708
-
709
- obj.setProperty('a', 1);
710
- queue.flush();
711
-
712
- effect.destroy();
713
- obj.destroy();
714
- queue.destroy();
1208
+ // Sources are named in two lists: root slots, and derivations.
1209
+ const effect = effectService.createEffect(db, [slot], [totalService.computedCollectionService.getSlotByKey(total, undefined)], () => {});
1210
+ effectService.disposeEffect(effect);
1211
+ // The second list is typed `readonly TOIMComputedSlot[]` — pass slots taken from
1212
+ // a computed service's `computedCollectionService` straight in.
1213
+
1214
+ // One column of one slot, woken when that named column moves and by nothing else.
1215
+ // It takes the COLUMN SLOT, which the column owner mints and holds.
1216
+ const onRank = effectService.createEffect(db, [cardService.holdColumn(slot, 'rank')], [], () => {});
715
1217
  ```
716
1218
 
717
- **Effect with collection dependency:**
1219
+ The body's type is `TOIMEffectBody`, which is `() => (() => void) | void`. **A returned
1220
+ function is the undo**: it runs before the next run of that body and on `disposeEffect`,
1221
+ so a body that opened something on each run has somewhere to close it.
718
1222
 
719
1223
  ```typescript
720
- import {
721
- OIMEffect,
722
- OIMComputeRuntime,
723
- OIMEventQueue,
724
- OIMReactiveCollection,
725
- OIMEffectDependencyKeyedCollection,
726
- } from '@oimdb/core';
727
-
728
- interface User {
729
- id: string;
730
- name: string;
731
- }
1224
+ const poll = (): void => {};
732
1225
 
733
- const queue = new OIMEventQueue();
734
- const runtime = new OIMComputeRuntime(queue);
735
- const users = new OIMReactiveCollection<User, string>(queue, {
736
- selectPk: (u) => u.id,
1226
+ effectService.createEffect(db, [slot], [], () => {
1227
+ const timer = setInterval(poll, 1000);
1228
+ return () => clearInterval(timer);
737
1229
  });
738
-
739
- const effect = new OIMEffect(runtime, {
740
- deps: [new OIMEffectDependencyKeyedCollection(users, 'user1')],
741
- run: () => {
742
- const user = users.getOneByPk('user1');
743
- console.log('User1 changed:', user);
744
- },
745
- });
746
-
747
- users.upsertOne({ id: 'user1', name: 'John' });
748
- queue.flush();
749
-
750
- effect.destroy();
751
- users.destroy();
752
- queue.destroy();
753
1230
  ```
754
1231
 
755
- **Effect with index dependency:**
1232
+ The return is checked with `typeof` rather than for truthiness, because an
1233
+ expression-bodied arrow hands back whatever the expression evaluated to
1234
+ (`() => set.add(key)` returns the `Set`), and anything that is not a function is
1235
+ discarded.
756
1236
 
757
- ```typescript
758
- import {
759
- OIMEffect,
760
- OIMComputeRuntime,
761
- OIMEventQueue,
762
- OIMReactiveCollection,
763
- OIMReactiveCollectionIndexManualSetBased,
764
- OIMEffectDependencyKeyedIndex,
765
- } from '@oimdb/core';
766
-
767
- interface User {
768
- id: string;
769
- name: string;
770
- }
1237
+ **An effect can also find its sources as it reads.** `createEffectWithSourceTracking(db, body)`
1238
+ takes no source lists: the body gets an `OIMEffectSourceTracker`, and what it reads through
1239
+ `readSourceSlot(slot)` (which files the slot and returns it) and
1240
+ `readSourceComputedValue(computedSlot)` (which files the derivation and returns its value)
1241
+ is what that run depends on. The body's type is `TOIMEffectBodyWithSourceTracking`.
771
1242
 
772
- const queue = new OIMEventQueue();
773
- const runtime = new OIMComputeRuntime(queue);
774
- const users = new OIMReactiveCollection<User, string>(queue, {
775
- selectPk: (u) => u.id,
776
- });
777
- const roleIndex = new OIMReactiveCollectionIndexManualSetBased<string, string, User>(
778
- queue,
779
- { collection: users }
780
- );
781
-
782
- const effect = new OIMEffect(runtime, {
783
- deps: [new OIMEffectDependencyKeyedIndex(roleIndex, 'admin')],
784
- run: () => {
785
- const adminPks = roleIndex.getPksByKey('admin');
786
- console.log('Admin users changed:', Array.from(adminPks));
787
- },
1243
+ ```typescript
1244
+ effectService.createEffectWithSourceTracking(db, effectSourceTracker => {
1245
+ const isFirst = effectSourceTracker.readSourceSlot(slot).entity?.rank === 1;
1246
+ console.log(isFirst ? effectSourceTracker.readSourceSlot(slot).entity?.title : 'not first');
788
1247
  });
789
-
790
- users.upsertMany([
791
- { id: 'user1', name: 'Alice' },
792
- { id: 'user2', name: 'Bob' },
793
- ]);
794
- roleIndex.setPks('admin', ['user1', 'user2']);
795
- queue.flush();
796
-
797
- effect.destroy();
798
- roleIndex.destroy();
799
- users.destroy();
800
- queue.destroy();
801
1248
  ```
802
1249
 
803
- **Effect with multiple dependencies:**
1250
+ The body runs once at creation, since that run finds the sources. After every run the
1251
+ effect is subscribed to exactly what it read: a branch not taken is unsubscribed, and a
1252
+ slot read twice is subscribed once. A run that reads the same sources as the run before
1253
+ compares each read with the previous one and changes no subscription. `createEffect` is
1254
+ unchanged; you pick one or the other per effect, at creation.
1255
+
1256
+ Here is how an effect is filed:
1257
+
1258
+ - **The derivation list is deduplicated.** If you name one computed slot twice in the
1259
+ second argument, it gets one edge, not two. Nothing is allocated for
1260
+ a list of one or none, and a list that turns out to be distinct keeps the
1261
+ caller's array.
1262
+ - **`dispose` unfiles by a remembered position instead of by scanning.** Each
1263
+ effect records where it sits in every list it is on; a removal swaps in the tail
1264
+ and tells the moved effect where it now is. *Scanning and splicing instead would make
1265
+ taking N subscribers off one identity quadratic in N.* The visible
1266
+ consequence is that **a slot's `effects` is not in subscription order.**
1267
+ - **The moved effect is told by edge, not found by search.** Beside `effects`, a slot
1268
+ keeps `effectEdges`: for each effect, which of that effect's sources this slot is.
1269
+ The swap carries both, so the moved effect updates one number and never scans its
1270
+ own sources. Taking one of two effects off the same N slots reads the other's
1271
+ source list zero times, where a search would read it N(N+1)/2 times. Only the slot's
1272
+ owner writes either array, in `subscribeToSlot` and `unsubscribeFromSlot`.
1273
+ - **A subscription is a hold.** `subscribeToSlot` takes one through `holdSlot` and
1274
+ `unsubscribeFromSlot` gives it back through `unholdSlot`, so a subscribed slot
1275
+ cannot be released and no caller can subscribe without holding. An effect makes
1276
+ one owner call per source, not two.
1277
+ - **A slot's key is written by its owner.** The slot owner mints a slot with
1278
+ `slotStoreService.ownKey(key)` (the key itself from the map-driven store, a copy
1279
+ of the composite key from the tree-driven one), and the store never writes a slot.
1280
+
1281
+ `holdColumn` has a pair: `unholdColumn(columnSlot)` gives the hold back, and
1282
+ `getColumn(slot, columnName)` reads the same column slot without a hold (a mint is held by
1283
+ the unused-slot collector until the next flush ends). **The column doors are on the collection
1284
+ service** (`OIMSlotCollectionService` and `OIMSingleSlotService`), and they reach the slot owner's
1285
+ column owner (`slotOwnerService.columnOwner`), which holds the column slot store and the column
1286
+ groups. With `columnNames` named the store is an array by ordinal, otherwise a Map. The column
1287
+ owner is abstract and has two strategies, chosen by the door that builds the owner:
1288
+ write-driven column slots (`OIMColumnOwnerPushDriven`), woken by the marks a write makes (entity
1289
+ and bucket collections, keyed or single), and derive-driven column slots
1290
+ (`OIMColumnOwnerPullDriven`), each a computed slot over its carrier (`OIMComputedService`, so
1291
+ `OIMComputedServiceSorted` and `OIMComputedServiceBucketMemo` too, keyed or single; see
1292
+ [Computed columns](#computed-columns)). A write-driven column slot is a
1293
+ `TOIMColumnSlot` — `{ carrierSlot, key }` plus the slot fields — and its carrier is
1294
+ whatever slot it hangs off: an entity slot, or a bucket slot (see
1295
+ [Bucket column slots](#bucket-column-slots)). Every collection service also has
1296
+ `getAnyColumn(slot)` / `holdAnyColumn(slot)`, a column slot that wakes on any column write to its carrier.
1297
+
1298
+ **The column name is checked against the collection's column names.** An entity collection is
1299
+ typed on `TOIMEntityColumnName<TEntity>` (or the names given as `columnNames`), so `holdColumn`
1300
+ and `getColumn` take only the entity's own columns; a bucket collection takes any `string`.
1301
+ `deleteEntityColumns(slot, columnNames)` on the entity content service is typed the same way.
1302
+
1303
+ **A column group is one column slot for several columns.** `cardService.getColumnGroup(['rank', 'title'])`
1304
+ returns a `TOIMColumnGroup` — `{ key, columnNames }`, the names deduplicated and sorted, one
1305
+ frozen object per set of names on that service; get it once per view, not per entity.
1306
+ `cardService.holdColumnGroup(slot, columnGroup)` mints and holds the group's column slot on `slot`,
1307
+ and `unholdColumn(columnSlot)` gives it back. A write wakes it once if it changes any of the
1308
+ group's columns and not at all if it changes only others.
1309
+
1310
+ **`columnNames` picks the column slot store.** `createOIMEntityService<Card, 'rank' | 'title'>(db, { keyOf, columnNames: ['rank', 'title'] })`
1311
+ keeps each entity's column slots in an array by column number instead of a `Map` per entity,
1312
+ which makes an entity's column store cheaper to create and give back; that is what a scrolling view pays.
1313
+ `holdColumn` and `getColumnGroup` are typed on the named columns, and a slot for any other
1314
+ column throws. `columnNames` is on the options of every door — entity, bucket, index, computed —
1315
+ keyed or single. Without `columnNames` the store is the `columnSlotStoreService` option, and the
1316
+ doors default it to the shared map-driven `OIMDefaults.COLUMN_SLOT_STORE_SERVICE.getValue()`.
1317
+
1318
+ ### Frame rules
1319
+
1320
+ - **Writes are refused for the whole of a flush**, not only while the frame body
1321
+ is running. A subscriber is told *during* the frame, on a half-built world, so
1322
+ a write from there would land in a frame that is no longer collecting.
1323
+ `onBeforeFlush` and the after-each-frame tail are inside the same flush and
1324
+ are refused too. `OIMChannelService.INSTANCE.assertNotFlushing(channelSlot)` is the guard. It
1325
+ throws on the channel slot's `isFlushing` or `frame.isRunning`, and its message names all three
1326
+ phases.
1327
+ - **A refused write keeps its change pending.** `addBucketMemberSlots` and `removeBucketMemberSlots`
1328
+ record a bucket change that waits for the outer batch to close. If something
1329
+ delivers that bucket while a frame is running (an entity's subscriber handing the
1330
+ list back, which takes `unholdSlot` into `releaseSlotIfUnheld`), the write is refused
1331
+ and the bucket stays pending, so the change is delivered when the batch
1332
+ closes instead of being lost.
1333
+ - **A door asks once and then writes without asking again.** `OIMChannelService.write` is
1334
+ `assertNotFlushing` plus `writeWithoutFlushCheck`, and a content service door
1335
+ that opened with `assertWritable` uses the second directly. The per-entity ask
1336
+ cannot be removed: user code (`keyOf`, an index's `keyOf`, `slotEmptiness.isEmpty`, an
1337
+ `IOIMEntityWriter`) runs inside the door and can call `flush()`, so each entity is
1338
+ a fresh question. `tests/who-may-write-without-asking-the-channel.test.ts` pins
1339
+ who may use the door that skips the check.
1340
+ - **`flush()` inside a frame is refused** for the same reason: a frame inside a
1341
+ frame is not nested; it takes the outer one apart. **`flush()` from the tail
1342
+ of a flush** is neither refused nor deferred. It returns having done
1343
+ nothing, and one flush spends exactly one frame.
1344
+ - **No door writes from inside a flush.** The handlers of
1345
+ `OIMChannelService.INSTANCE.subscribeAfterEachFrame(channelSlot, fn)` and
1346
+ `subscribeOnceAfterFrame(channelSlot, fn)`, and `onBeforeFlush`, all run inside it. A
1347
+ write in response to a change happens after `flush()` has returned: a tail
1348
+ handler hands the work to a microtask or a later callback instead of writing
1349
+ itself. A refusal raised from a tail handler goes to the error policy, not out of
1350
+ the write call.
1351
+ - **The frame cannot close while a subframe is open.** A subframe is one logical
1352
+ change inside the frame. `OIMChannelService.INSTANCE.openSubframe(channelSlot)` and
1353
+ `closeSubframe(channelSlot)` bracket it, and a write that asks for a frame while one is open is owed it by
1354
+ the `closeSubframe` that brings the count to zero. **`flush()` called while a
1355
+ subframe is open throws** (`a subframe is open`), whether it comes from a `keyOf` or a
1356
+ listener that a write runs, or from between a caller's own open and close. Subframes are counted,
1357
+ because a subframe is routinely inside another one, and closed from a `finally`,
1358
+ so a subframe that threw part-way still delivers what landed. It is not the frame
1359
+ guard seen from the other side: that guard refuses a *write* made while a frame closes, while this
1360
+ refuses a *frame* asked for while a subframe is open. A content service's write doors open
1361
+ a content subframe, which opens the channel's subframe and delivers the content-change
1362
+ listeners at its outermost close.
1363
+ - **Every bucket door wakes the bucket the same way.** Each bucket content service
1364
+ door that changed the bucket (adds, removes, positional inserts and removes, both moves,
1365
+ `writeBucketMemberSlots`, `clearBucketMemberSlots`)
1366
+ files it once and sets the bucket's EMPTY flag at once. The outermost subframe close
1367
+ stamps it once, tells the release policy `onSlotWritten` once, then asks whether
1368
+ it may go. So `hasSlotContentByKey` is accurate inside an open batch, a batch that mixes
1369
+ doors stamps the bucket once, a move renews a write deadline, and a door that
1370
+ changed nothing stamps nothing and tells the policy nothing. Commands are still
1371
+ recorded at the door, where the position is true.
1372
+ - **A released identity is refused before the bucket changes.** Every door that
1373
+ takes a member checks first whether what it was handed is released (`oimIsSlotReleased`), and throws, so a
1374
+ refused door leaves the members, their holds and the EMPTY flag as they were. The check costs one
1375
+ field read per member handed in.
1376
+ - **The span belongs to the door.** `writeBucketMemberSlots`, `clearBucketMemberSlots`, `insertEntities`, `fillEntities`,
1377
+ `updateEntity` (when it is more than one channel write) and `markEntityChanged` each open exactly one; the entity writer's `fillEntity`,
1378
+ `putEntity`, `patchEntity`, `deleteColumns` and `clearEntity` open none. So one `upsertEntities` opens
1379
+ one, and one `insertEntities` opens one however many entities it carries. A write that is exactly
1380
+ one channel write opens none, because one write cannot be seen half-done and there is nobody to batch for:
1381
+ `upsertEntity` of a new key, `updateEntity`/`updateEntityColumn`/`writeEntity` and `deleteEntity`
1382
+ when the store slot has no content listener, no column of the
1383
+ entity is read and emptiness is not recomputed on a column write. `deleteEntityColumns`
1384
+ always opens one, because it always names the columns it took. The content service's
1385
+ `releaseSlotContent` is the exception and keeps its own, because `releaseSlotIfUnheld`
1386
+ reaches it from the release queue through no door at all.
1387
+
1388
+ ### `subscribeOnceAfterFrame` — one frame, and the caller picks which
804
1389
 
805
1390
  ```typescript
806
- import {
807
- OIMEffect,
808
- OIMComputeRuntime,
809
- OIMEventQueue,
810
- OIMReactiveObject,
811
- OIMReactiveCollection,
812
- OIMEffectDependencyKeyedObject,
813
- OIMEffectDependencyKeyedCollection,
814
- } from '@oimdb/core';
1391
+ const paint = (card: Card | undefined): void => {};
1392
+ const measureAndScroll = (): void => {};
815
1393
 
816
- const queue = new OIMEventQueue();
817
- const runtime = new OIMComputeRuntime(queue);
818
- const settings = new OIMReactiveObject<'theme' | 'lang', string>(queue);
819
- const users = new OIMReactiveCollection<User, string>(queue, {
820
- selectPk: (u) => u.id,
1394
+ effectService.createEffect(db, [slot], [], () => {
1395
+ paint(slot.entity);
1396
+ OIMChannelService.INSTANCE.subscribeOnceAfterFrame(db.defaultChannelSlot, () => { measureAndScroll(); });
821
1397
  });
822
-
823
- const effect = new OIMEffect(runtime, {
824
- deps: [
825
- new OIMEffectDependencyKeyedObject(settings, ['theme', 'lang']),
826
- new OIMEffectDependencyKeyedCollection(users, 'currentUser'),
827
- ],
828
- run: () => {
829
- const theme = settings.get('theme');
830
- const user = users.getOneByPk('currentUser');
831
- console.log('Settings or user changed:', { theme, user });
832
- },
833
- });
834
-
835
- settings.setProperty('theme', 'dark');
836
- users.upsertOne({ id: 'currentUser', name: 'John' });
837
- queue.flush(); // Effect runs once, even though multiple deps changed
838
-
839
- effect.destroy();
840
- settings.destroy();
841
- users.destroy();
842
- queue.destroy();
843
1398
  ```
844
1399
 
845
- #### What is a Computed?
846
-
847
- `OIMComputed<T>` is built on top of `OIMEffect`: it recomputes a derived value and emits `update` when the value changes.
848
-
849
- **Basic example:**
1400
+ The handler is told after **one** frame, the first that ends after the call, and is then gone.
1401
+ **The place where you ask decides which frame that is.** Registered from inside an
1402
+ effect body, it runs in the tail of that very frame, after every other effect of
1403
+ it: the after-each-frame phase settles who it owes when the phase *begins*, and
1404
+ the drain is before that. So a consumer whose work needs the whole settled screen
1405
+ (a measurement, a scroll to an entity that moved, a size written back) asks from
1406
+ inside its effect and is answered at the end of that same frame.
1407
+
1408
+ It is **not** `subscribeAfterEachFrame` plus a disposal. The effect runs again
1409
+ next frame and registers again, so a standing handler accumulates one more per run:
1410
+ `src/engine/channel/tests/a-handler-that-wants-one-frame-and-not-every-frame.test.ts` measures the
1411
+ one-shot at 1, 2, 3 calls over three frames against the standing handler's 1, 3,
1412
+ 6. The one-shot handler also comes off *before* its body runs, so a handler that throws is not left
1413
+ standing for the next frame.
1414
+
1415
+ If you register it from the after-each-frame phase itself, it sits that frame out and is
1416
+ told after the next one, because the phase had already settled who it owed. There is no
1417
+ second list: one-shot and standing handlers share the set and are called in
1418
+ registration order.
1419
+ - **Delivery is coalesced per identity per frame.** However many writes touched a
1420
+ slot, its effects run once.
1421
+ - **A derivation is asked, never woken.** Nothing schedules a computed slot;
1422
+ it settles when whoever wants its value asks for it.
1423
+
1424
+ ### Ending the channel whole
850
1425
 
851
1426
  ```typescript
852
- import {
853
- OIMComputed,
854
- OIMEventQueue,
855
- OIMComputeRuntime,
856
- OIMReactiveObject,
857
- OIMEffectDependencyKeyedObject,
858
- } from '@oimdb/core';
859
-
860
- type TKey = 'a';
861
- const queue = new OIMEventQueue();
862
- const runtime = new OIMComputeRuntime(queue);
863
- const obj = new OIMReactiveObject<TKey, number>(queue);
864
-
865
- const doubled = new OIMComputed<number>(runtime, {
866
- compute: () => (obj.get('a') ?? 0) * 2,
867
- deps: [new OIMEffectDependencyKeyedObject(obj, 'a')],
868
- });
869
-
870
- obj.setProperty('a', 10);
871
- queue.flush(); // run scheduled work
872
-
873
- console.log(doubled.get()); // 20
874
-
875
- // If you also subscribe to computed updates, delivery happens in the same drain flush:
876
- let calls = 0;
877
- doubled.updateEventEmitter.subscribeOnKey('value', () => {
878
- calls++;
879
- });
880
- obj.setProperty('a', 11);
881
- queue.flush(); // run scheduled work
882
- console.log(calls); // 1
883
-
884
- doubled.destroy();
885
- obj.destroy();
886
- queue.destroy();
1427
+ OIMChannelService.INSTANCE.dropChannelSlot(db.defaultChannelSlot); // then drop your own references
887
1428
  ```
888
1429
 
889
- **Computed with collection and index dependencies:**
1430
+ **The precondition is the caller's: nothing may be subscribed to anything in this
1431
+ channel.** The engine does not check it, and cannot do so cheaply: checking means
1432
+ asking every slot for its `effects` list, which is the walk the call exists to
1433
+ skip. The check *is* the cost.
1434
+
1435
+ It sets the channel slot's `dropped` flag, cancels a scheduled frame, flushes the
1436
+ unused-slot collector (unless a flush is running), empties the queued effects, the
1437
+ after-each-frame handlers and the after-each-flush handlers, and returns. **Nothing walks a slot to ask who
1438
+ is subscribed to it.** Every slot, store and derivation under the channel becomes
1439
+ garbage together once your references go. The collector is the exception because
1440
+ it is the channel's own debt: a getter that minted an identity filed a hold there,
1441
+ `flush()`'s `finally` is the only thing that gives those back, and a dropped
1442
+ channel returns on the flag before reaching it. A drop that skipped the
1443
+ collector would pin those identities for as long as the collector lives. The
1444
+ flag is there because `TOIMChannelScheduler` hands back no handle, so a frame asked for
1445
+ before the call still arrives, and `flush()` returns on the flag instead of
1446
+ delivering a torn-down screen.
1447
+
1448
+ **The flag is read in two places.** `flush()` returns on it, and so does the
1449
+ private `requestFlush`. `write` reaches `requestFlush` at the end of every write that got
1450
+ past the per-frame coalescing guard with at least one dependent to schedule, and
1451
+ `scheduleEffectRun` reaches it when `createEffect` finds a source that
1452
+ already moved in the open batch. That second read is what stops a dropped screen
1453
+ from scheduling work on its host for as long as anything still writes to it.
1454
+
1455
+ So a write after the call is **neither refused nor unnoticed**: it
1456
+ advances the clock, stamps the slot and schedules the dependents it finds, and then
1457
+ asks for no frame, so nothing it scheduled is ever delivered.
1458
+
1459
+ If you call it with subscribers still standing, their bodies never run again, with no throw
1460
+ and no report; that is the bargain the name states. There is no checked sibling,
1461
+ and checking is not an option on this one. It is **not** a manual release of a slot:
1462
+ that stays refused, and nothing may ask for one slot to go.
1463
+
1464
+ ---
1465
+
1466
+ ## Buckets: the slots are the caller's
1467
+
1468
+ A **bucket** is an identity whose content is other identities. A
1469
+ **bucket collection** (`createOIMBucketService`) files buckets by key, in each store slot it
1470
+ creates, and its `slotContentService` holds each bucket's member slots. It is **manual by default**, and that
1471
+ is a claim about what a bucket's slots ARE; it is not a missing feature. They are
1472
+ a fact about the **response** a caller is composing (a search result, a page, a
1473
+ window), and no column can express absence.
1474
+
1475
+ A bucket holds bucket member slots of any slot type (entity slots, bucket slots,
1476
+ computeds), and the bucket's type parameter is that slot type,
1477
+ `TBucketMemberSlot extends TOIMSlot` (default `TOIMEntitySlot`). An auto-index's
1478
+ `getEntitiesByKey` is there because its members are entity slots.
890
1479
 
891
1480
  ```typescript
892
- import {
893
- OIMComputed,
894
- OIMEventQueue,
895
- OIMComputeRuntime,
896
- OIMReactiveCollection,
897
- OIMReactiveCollectionIndexManualSetBased,
898
- OIMEffectDependencyKeyedCollection,
899
- OIMEffectDependencyKeyedIndex,
900
- } from '@oimdb/core';
1481
+ const byDeckService = createOIMBucketService<
1482
+ CardSlot, TOIMStoreKey, TOIMBucketSlotsSetStore<CardSlot>, OIMSlotContentBucketServiceSetDriven<CardSlot>
1483
+ >(db, { slotContentService: new OIMSlotContentBucketServiceSetDriven<CardSlot>() });
1484
+ const byDeck = byDeckService.createStoreSlot(db);
901
1485
 
902
- interface User {
903
- id: string;
904
- name: string;
905
- role: string;
906
- }
1486
+ const aceSlot = cardService.slotContentService.upsertEntity(cards, { id: 'c1', deck: 'red', title: 'Ace', rank: 1 });
1487
+ const kingSlot = cardService.slotContentService.upsertEntity(cards, { id: 'c2', deck: 'red', title: 'King', rank: 13 });
907
1488
 
908
- const queue = new OIMEventQueue();
909
- const runtime = new OIMComputeRuntime(queue);
910
- const users = new OIMReactiveCollection<User, string>(queue, {
911
- selectPk: (u) => u.id,
912
- });
913
- const roleIndex = new OIMReactiveCollectionIndexManualSetBased<string, string, User>(
914
- queue,
915
- { collection: users }
916
- );
1489
+ const red = byDeckService.holdSlotByKey(byDeck, 'red');
1490
+ const deckContentService = byDeckService.slotContentService;
1491
+ deckContentService.writeBucketMemberSlots(red, [aceSlot, kingSlot]);
1492
+ db.flush();
917
1493
 
918
- // Computed that counts admin users
919
- const adminCount = new OIMComputed<number>(runtime, {
920
- compute: () => {
921
- const adminPks = roleIndex.getPksByKey('admin');
922
- return adminPks.size;
923
- },
924
- deps: [new OIMEffectDependencyKeyedIndex(roleIndex, 'admin')],
925
- });
1494
+ for (const cardSlot of deckContentService.getBucketMemberSlots(red)) { console.log(cardSlot.entity?.title); }
1495
+ deckContentService.getBucketMemberSlotCount(red);
926
1496
 
927
- // Computed that gets admin user names
928
- const adminNames = new OIMComputed<string[]>(runtime, {
929
- compute: () => {
930
- const adminPks = Array.from(roleIndex.getPksByKey('admin'));
931
- return adminPks
932
- .map((pk) => users.getOneByPk(pk)?.name)
933
- .filter((name): name is string => name !== undefined);
934
- },
935
- deps: [
936
- new OIMEffectDependencyKeyedIndex(roleIndex, 'admin'),
937
- new OIMEffectDependencyKeyedCollection(users, Array.from(roleIndex.getPksByKey('admin'))),
938
- ],
939
- });
940
-
941
- users.upsertMany([
942
- { id: 'u1', name: 'Alice', role: 'admin' },
943
- { id: 'u2', name: 'Bob', role: 'user' },
944
- ]);
945
- roleIndex.setPks('admin', ['u1']);
946
- queue.flush();
947
-
948
- console.log(adminCount.get()); // 1
949
- console.log(adminNames.get()); // ['Alice']
950
-
951
- adminNames.destroy();
952
- adminCount.destroy();
953
- roleIndex.destroy();
954
- users.destroy();
955
- queue.destroy();
1497
+ deckContentService.clearBucketMemberSlots(red);
1498
+ red.owner.unholdSlot(red);
956
1499
  ```
957
1500
 
958
- #### Computed-to-Computed dependencies
959
-
960
- For computed-to-computed dependencies you can use `OIMEffectDependencyComputed`.
1501
+ The store slot carries the keyed store: there is no `buckets` property to reach
1502
+ through, and `holdSlotByKey(byDeck, key)` hands back the bucket itself instead of a
1503
+ `{ slot, release }` record. The door's optional `slotReleasePolicy` decides whether and when
1504
+ its buckets go; `OIMSlotReleasePolicyAtUnhold.INSTANCE` drops a bucket at the last give-back.
1505
+
1506
+ `writeBucketMemberSlots` is idempotent: when you re-send an unchanged list, the
1507
+ content service compares it (it is the only object that knows whether the caller's order is part
1508
+ of what it holds at all) and returns without evicting anything.
1509
+
1510
+ ### Which door: you hold the answer, or you hold the delta
1511
+
1512
+ `writeBucketMemberSlots` is for a caller who has **the list that should be there** and does not
1513
+ know what changed: a search result, a page from a server, a filter re-run. Its
1514
+ cost is proportional to **what the bucket holds**, not to the change. The
1515
+ difference builds a map over the whole incoming list and walks the standing one,
1516
+ so writing ten entities into a bucket of two hundred pays for two hundred and ten.
1517
+
1518
+ `addBucketMemberSlots(bucketSlot, entitySlots)` and `removeBucketMemberSlots(bucketSlot, entitySlots)` are for a caller who
1519
+ **knows the delta**: an entity arrived over a socket, a card was dragged in, a page
1520
+ was appended, an entity was deleted. The standing slots keep the holds they already
1521
+ have. Each wakes subscribers once, and neither wakes anybody when nothing joined
1522
+ or left.
1523
+
1524
+ Adding costs what you name. Removing k slots from an ordered bucket of n is one
1525
+ pass, n + k, through the content service's protected `removeSlots(bucketSlot, slots, removed,
1526
+ removedAt)`. This is the erase–remove idiom, as in Java's ArrayList.removeAll; k separate
1527
+ removals cost up to k·n. The `remove` commands come in list order, each at its
1528
+ position after the earlier ones were applied, so replaying them draws the same list. A
1529
+ set-driven bucket removes in the order you name.
1530
+
1531
+ **`addBucketMemberSlots` removes nothing.** If the authoritative list shrank and you call
1532
+ `addBucketMemberSlots`, the entities that left stay in the bucket, held. Not knowing what left is
1533
+ exactly when `writeBucketMemberSlots` is the right door, and its cost is the price of not
1534
+ knowing.
1535
+
1536
+ The content service commits the list through its protected
1537
+ `reconcileSlots(bucketSlot, slots, arrived, departed)`, which is the
1538
+ reconciliation pattern (diff, then patch). Empty `arrived` and `departed` mean
1539
+ nothing changed, and the write wakes nobody. The array-driven content service compares the
1540
+ sequence first: the same slots in the same order change nothing, and any other
1541
+ sequence, a reordered one included, is drained and re-added whole. The
1542
+ set-driven content service takes the set difference and never rebuilds. The two queues are the
1543
+ channel's, so a steady frame allocates nothing for the answer.
1544
+
1545
+ **The frame is not a parameter anywhere.** `reconcileSlots` and `drainSlots(bucketSlot, departed)`
1546
+ declare no frame argument.
1547
+
1548
+ No frame number reaches a bucket content service, and none is stamped on a render script.
1549
+ `OIMBucketCommands` records onto the bucket slot (`bucket.commands`) as the writes land and
1550
+ empties it once the flush has fully ended, so a script can only ever describe the
1551
+ frame a consumer was woken for. A bucket written after that frame (from an
1552
+ after-each-frame handler) keeps its script for the next one: the reset clears only a script
1553
+ whose `commandsStartedAtRevision`/`commandsStartedAtEpoch` is older than the reading the
1554
+ channel slot's last frame opened at. `clearBucketMemberSlots` drains the bucket first and
1555
+ marks it changed only when the drain removed something, so an already-empty bucket records
1556
+ nothing for a call that reports nothing.
1557
+
1558
+ The **channel slot** is what a bucket collection tells, and it is required: every store slot
1559
+ is created on a `db` and reaches its channel slot through `storeSlot.channelSlot`, so there is no
1560
+ strategy that tells nobody. `OIMChannelService.INSTANCE.write` carries the frame guard, which is
1561
+ why a whole-bucket write cannot report from inside a flush.
1562
+
1563
+ ### How a bucket holds its slots is a fork with two strategies
1564
+
1565
+ | Content service | Storage | One `addBucketMemberSlot` records |
1566
+ |---|---|---|
1567
+ | `OIMSlotContentBucketServiceSetDriven` | `Set` | one per slot |
1568
+ | `OIMSlotContentBucketServiceArrayDriven({ bucketArrayMembership?, bucketCommands? })` | list, plus whatever the list adds | the ordered list's answer |
1569
+ | `OIMSlotContentBucketServiceSortedArrayDriven({ compareBucketMemberSlots })` | list ordered by the comparator (`sortedCount` marks the sorted prefix) | one per mention |
1570
+
1571
+ The positions are the caller's. No read re-decides the order, and the position
1572
+ doors below are the only way the list moves.
1573
+
1574
+ ### And what naming a slot RECORDS is the array-driven service's own fork
1575
+
1576
+ | Ordered list | Storage it builds | One `addBucketMemberSlot` counts |
1577
+ |---|---|---|
1578
+ | `OIMBucketArrayMembershipNonUnique.INSTANCE` (the default) | `TOIMBucketSlotsArrayStore` | one per *mention* — a slot named twice is two |
1579
+ | `new OIMBucketArrayMembershipUnique()` | `TOIMBucketSlotsArrayUniqueStore` (list + `Set`) | one per slot, order kept |
961
1580
 
962
1581
  ```typescript
963
- import {
964
- OIMComputed,
965
- OIMEffect,
966
- OIMComputeRuntime,
967
- OIMEffectDependencyComputed,
968
- OIMEventQueue,
969
- OIMReactiveObject,
970
- OIMEffectDependencyKeyedObject,
971
- } from '@oimdb/core';
972
-
973
- type TKey = 'a';
974
- const queue = new OIMEventQueue();
975
- const runtime = new OIMComputeRuntime(queue);
976
- const obj = new OIMReactiveObject<TKey, number>(queue);
977
-
978
- const A = new OIMComputed<number>(runtime, {
979
- compute: () => (obj.get('a') ?? 0) + 1,
980
- deps: [new OIMEffectDependencyKeyedObject(obj, 'a')],
981
- });
982
-
983
- const B = new OIMComputed<number>(runtime, {
984
- compute: () => A.get() * 2,
985
- deps: [new OIMEffectDependencyComputed({ emitter: A.emitter, updateEventEmitter: A.updateEventEmitter })],
1582
+ const dedupedContentService = new OIMSlotContentBucketServiceArrayDriven<
1583
+ CardSlot, TOIMBucketSlotsArrayUniqueStore<CardSlot>
1584
+ >({
1585
+ bucketArrayMembership: new OIMBucketArrayMembershipUnique<CardSlot>(),
986
1586
  });
987
-
988
- const effect = new OIMEffect(runtime, {
989
- deps: [new OIMEffectDependencyComputed({ emitter: B.emitter, updateEventEmitter: B.updateEventEmitter })],
990
- run: () => console.log('B changed'),
991
- });
992
-
993
- obj.setProperty('a', 1);
994
- queue.flush(); // run scheduled work
995
-
996
- effect.destroy();
997
- B.destroy();
998
- A.destroy();
999
- obj.destroy();
1000
- queue.destroy();
1001
1587
  ```
1002
1588
 
1003
- #### What are Selectors?
1004
-
1005
- Selectors provide a convenient way to watch and react to changes in collections, objects, and indexes. They automatically handle subscription management and deliver updates only when values actually change.
1006
-
1007
- **Collection selector:**
1589
+ `IOIMBucketArrayMembership` is `createStore`, `addSlot`, `removeSlot`, `hasSlot`, `clearSlots`,
1590
+ `containsTheSameSequence`. Only the array-driven content services take one. Their
1591
+ `hasBucketMemberSlot` asks the membership's `hasSlot`: `OIMBucketArrayMembershipUnique` answers
1592
+ from its `Set`, `OIMBucketArrayMembershipNonUnique` walks the array.
1593
+
1594
+ ### Positions the caller decides, and the commands that record them
1595
+
1596
+ `createOIMBucketService<CardSlot>(db)` with no `slotContentService` is the bucket collection to
1597
+ take when the positions are yours: its default content service is
1598
+ `OIMSlotContentBucketServiceArrayDriven` with an `OIMBucketCommands` recording over it.
1599
+
1600
+ The position doors follow; a position they return is valid only until the next write. The reads
1601
+ `getBucketMemberSlotAt(bucketSlot, index)` and `getBucketMemberSlotPosition(bucketSlot, entitySlot)`
1602
+ belong to the array-based content services, and the five writes to
1603
+ `OIMSlotContentBucketServiceArrayDriven` (`OIMSlotContentBucketServiceSetDriven` has none of
1604
+ them, because a `Set` has no place to name, so asking one does not compile):
1605
+ `insertBucketMemberSlotAt(bucketSlot, at, entitySlot)`, `removeBucketMemberSlotAt(bucketSlot, at)`,
1606
+ `removeBucketMemberSlotRange(bucketSlot, at, count)`, `moveBucketMemberSlotToPosition(bucketSlot, from, to)` and
1607
+ `moveBucketMemberSlotRangeToPosition(bucketSlot, from, count, to)`. Each runs inside
1608
+ the frame guard, takes the holds and records the report. `insertBucketMemberSlotAt` clamps `at` to the ends
1609
+ of the list; `moveBucketMemberSlotToPosition` refuses `from === to` and either end out of
1610
+ range; `moveBucketMemberSlotRangeToPosition` reads its landing in the list **without** the run
1611
+ in it. A position or count that is `NaN` or a fraction throws before anything
1612
+ moves; `±Infinity` clamps. The default ordered slot list records one place per *mention*, so
1613
+ inserting a slot the bucket already holds gives it a second position. Hand the
1614
+ content service an `OIMBucketArrayMembershipUnique` when a position must name one entity; a
1615
+ repeat insert into it changes nothing and records nothing.
1616
+
1617
+ The content service's protected `removeSlot(bucketSlot, slot)` hands back a `number`: WHERE
1618
+ the slot was. A removal already has to find the slot, so nothing asks
1619
+ `getBucketMemberSlotPosition` for an index that the removal already computed. Two of the
1620
+ answers are not positions, and both are exported:
1621
+
1622
+ | returns | means |
1623
+ |---|---|
1624
+ | `0` and up | the index the slot occupied, now vacated |
1625
+ | `OIM_NOTHING_WAS_REMOVED` (`-1`) | the bucket did not hold it; nothing changed |
1626
+ | `OIM_REMOVED_WITHOUT_A_POSITION` (`-2`) | removed from a store that keeps no positions, which is the set-driven service |
1627
+
1628
+ `removeBucketMemberSlot` turns the third into `recordWholeList`: a set has no index to
1629
+ patch, so the consumer is told to re-read rather than handed a fabricated one.
1630
+
1631
+ `insertBucketMemberSlotAt` records the index the slot LANDED on (`at` clamped to the ends of
1632
+ the list). **The landing is not `getBucketMemberSlotPosition(bucketSlot, slot)`**: the default
1633
+ ordered list keeps one place per *mention*, so inserting a slot the bucket already holds gives
1634
+ it a second position, and the position read names the first. A script built from that read
1635
+ there tells the consumer to patch where the slot is not, and the consumer's list diverges
1636
+ from the bucket's for good.
1637
+
1638
+ `OIMBucketCommands` records onto the bucket slot what those writes DID, so a
1639
+ consumer patches instead of rebuilding:
1008
1640
 
1009
1641
  ```typescript
1010
- import {
1011
- OIMCollectionByPkSelector,
1012
- OIMCollectionByPksSelector,
1013
- OIMComputeRuntime,
1014
- OIMEventQueue,
1015
- OIMReactiveCollection,
1016
- } from '@oimdb/core';
1642
+ const pageService = createOIMBucketService<CardSlot>(db);
1643
+ const page = pageService.createStoreSlot(db);
1644
+ const bucket = pageService.holdSlotByKey(page, 'p1');
1017
1645
 
1018
- interface User {
1019
- id: string;
1020
- name: string;
1021
- }
1022
-
1023
- const queue = new OIMEventQueue();
1024
- const runtime = new OIMComputeRuntime(queue);
1025
- const users = new OIMReactiveCollection<User, string>(queue, {
1026
- selectPk: (u) => u.id,
1027
- });
1028
-
1029
- // Watch a single user
1030
- const userSelector = new OIMCollectionByPkSelector(runtime, users, 'user1');
1031
- const unwatch = userSelector.watch((user) => {
1032
- console.log('User1 changed:', user);
1033
- });
1034
-
1035
- users.upsertOne({ id: 'user1', name: 'John' });
1036
- queue.flush(); // Callback fires with { id: 'user1', name: 'John' }
1646
+ const rebuildFrom = (slots: Iterable<CardSlot>): void => {};
1647
+ const applyToTheRenderedList = (commands: readonly TOIMOrderedListCommand[]): void => {};
1037
1648
 
1038
- // Watch multiple users
1039
- const usersSelector = new OIMCollectionByPksSelector(runtime, users, ['user1', 'user2']);
1040
- usersSelector.watch((users) => {
1041
- console.log('Users changed:', users);
1649
+ effectService.createEffect(db, [bucket], [], () => {
1650
+ if (bucket.isWholeListRecorded) rebuildFrom(pageService.slotContentService.getBucketMemberSlots(bucket));
1651
+ else applyToTheRenderedList(bucket.commands ?? []);
1042
1652
  });
1043
-
1044
- users.upsertMany([
1045
- { id: 'user1', name: 'John Doe' },
1046
- { id: 'user2', name: 'Jane Smith' },
1047
- ]);
1048
- queue.flush(); // Callback fires with array of users
1049
-
1050
- unwatch(); // Stop watching
1051
- usersSelector.watch(() => {}); // Get unsubscribe function
1052
- users.destroy();
1053
- queue.destroy();
1054
1653
  ```
1055
1654
 
1056
- **Selector with index (entities by index key):**
1655
+ The content service already writes the bucket slot on every mutation, so the consumer
1656
+ subscribes to the bucket. `TOIMBucketSlot` carries two fields that a consumer reads:
1657
+ `commands`, a `readonly TOIMOrderedListCommand[]` (or `undefined` before the first
1658
+ write) of what this frame's writes did, in order, and
1659
+ `isWholeListRecorded`, which says the script was abandoned and the bucket's slots
1660
+ are the answer. **Both are reset once the flush has fully ended**, so they are
1661
+ read inside the frame (from an effect body or a `subscribeAfterEachFrame`
1662
+ handler), and there is no "commands since frame N": a consumer that did not read
1663
+ this frame's script reads the list instead. A script carries the clock reading it
1664
+ started at (`commandsStartedAtRevision`/`commandsStartedAtEpoch`) and is stale once
1665
+ a frame has opened after it (`channelSlot.frame.openedAtRevision`/`openedAtEpoch`). The
1666
+ recorder clears a stale script before recording into it, so a write that lands after a
1667
+ flush, ahead of the recorder's reset, starts a new script instead of extending one
1668
+ that was already read. `isWholeListRecorded` is set by a write
1669
+ of the whole bucket (`writeBucketMemberSlots`, `clearBucketMemberSlots`, `moveBucketMemberSlotRangeToPosition`), by a
1670
+ removal from a bucket whose content service keeps no positions and so has no index to
1671
+ patch, and by
1672
+ the script outgrowing the list it describes. `maxCommandsPerSlot` on
1673
+ `TOIMBucketCommandsOptions` (and on `createOIMBucketService`'s options) is that ratio. It defaults to
1674
+ `OIM_MAX_COMMANDS_PER_SLOT`, which is 1, the same ratio
1675
+ `oimOrderedListCommandsOfTransition` takes as `maxCommandsPerSlot`. A consumer that animates moves cannot
1676
+ use a whole-list answer, because it costs them every move they were going to animate.
1677
+ They pass `Infinity` to be handed every command instead, however long the script
1678
+ grows.
1679
+
1680
+ **Recording is chosen when the content service is built.** `bucketCommands` on
1681
+ `TOIMSlotContentBucketServiceOptions` is read once into a readonly field:
1682
+ `new OIMBucketCommands({ maxCommandsPerSlot })`. If you leave it out, the content service holds
1683
+ `OIMBucketCommandsNone.INSTANCE`, whose methods are empty and take positions instead of a
1684
+ command object, so a bucket that nobody renders positionally allocates no command. There is no
1685
+ setter, so a second recorder cannot silently replace the first. The first script recorded on a
1686
+ channel slot subscribes one reset to `OIMChannelService.INSTANCE.subscribeAfterEachFlush` for
1687
+ that channel slot; it walks only the store slots that recorded a script.
1688
+
1689
+ The bucket's own type says which storage it carries
1690
+ (`TOIMBucketSlot<TOIMBucketSlotsArrayStore<CardSlot>>`, reached as `bucket.bucketSlotsStore`), so handing
1691
+ a set-driven bucket to the array-driven content service is a compile error. A content service holds no
1692
+ per-bucket state, so **one instance may serve two bucket collections**.
1693
+
1694
+ ### The other strategy: an index that fills itself
1057
1695
 
1058
1696
  ```typescript
1059
- import {
1060
- OIMEntitiesByIndexKeySetBasedSelector,
1061
- OIMComputeRuntime,
1062
- OIMEventQueue,
1063
- OIMReactiveCollection,
1064
- OIMReactiveCollectionIndexManualSetBased,
1065
- } from '@oimdb/core';
1066
-
1067
- const queue = new OIMEventQueue();
1068
- const runtime = new OIMComputeRuntime(queue);
1069
- const users = new OIMReactiveCollection<User, string>(queue, {
1070
- selectPk: (u) => u.id,
1697
+ const indexService = createOIMIndexService(db, {
1698
+ entityService: cardService,
1699
+ keyOf: (card: Card) => card.deck, // which key an entity belongs to
1071
1700
  });
1072
- const roleIndex = new OIMReactiveCollectionIndexManualSetBased<string, string, User>(
1073
- queue,
1074
- { collection: users }
1075
- );
1076
-
1077
- // Watch all admin users
1078
- const adminUsersSelector = new OIMEntitiesByIndexKeySetBasedSelector(
1079
- runtime,
1080
- users,
1081
- roleIndex,
1082
- 'admin'
1083
- );
1701
+ const index = indexService.createIndexStoreSlot(cards);
1084
1702
 
1085
- adminUsersSelector.watch((adminUsers) => {
1086
- console.log('Admin users:', adminUsers.map((u) => u?.name));
1087
- });
1703
+ const render = (slots: unknown[]): void => {};
1088
1704
 
1089
- users.upsertMany([
1090
- { id: 'u1', name: 'Alice', role: 'admin' },
1091
- { id: 'u2', name: 'Bob', role: 'admin' },
1092
- ]);
1093
- roleIndex.setPks('admin', ['u1', 'u2']);
1094
- queue.flush(); // Callback fires with [Alice, Bob]
1095
-
1096
- // When index changes, selector automatically resubscribes to new entities
1097
- roleIndex.setPks('admin', ['u1']);
1098
- queue.flush(); // Callback fires with [Alice]
1099
-
1100
- adminUsersSelector.watch(() => {}); // Get unsubscribe function
1101
- roleIndex.destroy();
1102
- users.destroy();
1103
- queue.destroy();
1705
+ const spades = indexService.holdSlotByKey(index, 'spades'); // the hold mints the bucket and fills it
1706
+ effectService.createEffect(db, [spades], [], () => render(Array.from(indexService.slotContentService.getBucketMemberSlots(spades))));
1104
1707
  ```
1105
1708
 
1106
- **Object selector:**
1709
+ It **is** a bucket collection (`OIMIndexService` extends `OIMSlotCollectionService`), and
1710
+ `createIndexStoreSlot(cards)` makes its store slot for one entity store slot and files every
1711
+ entity of it into the buckets on write, from `keyOf`. You read through the index service
1712
+ (`getSlotByKey`, `holdSlotByKey`, `getSlotsByKeys`, `holdSlotsByKeys`, `getSlots`,
1713
+ `getSlotCount`, `getEntitiesByKey`, each with the index store slot first) and through its
1714
+ `slotContentService` on a bucket slot (`getBucketMemberSlots`, `getBucketMemberSlotCount`,
1715
+ `hasBucketMemberSlot`, and on an array-based content service `getBucketMemberSlotAt` and
1716
+ `getBucketMemberSlotPosition`). That `slotContentService` is narrowed to those reads, so the
1717
+ index's buckets cannot be written from outside. The default content service is
1718
+ `OIMSlotContentBucketServiceArrayDriven`; pass `slotContentService:
1719
+ new OIMSlotContentBucketServiceSetDriven()` for buckets that keep no positions. Passing
1720
+ `entityService` and then the entity store slot is the whole wiring: `createIndexStoreSlot(cards)`
1721
+ registers a content-change listener on that store slot
1722
+ (`OIMContentChangeListenerService.INSTANCE.addContentChangeListener(cards, …)`), and the entity
1723
+ service knows nothing about indexes.
1724
+ `disposeIndexStoreSlot(index)` takes that listener off, empties the buckets and gives back every
1725
+ entity held. After that, `getSlotByKey` and `holdSlotByKey` throw, but the two give-backs
1726
+ (`slot.owner.unholdSlot`, `indexService.unholdColumn`) still take back a hold
1727
+ that was given out before it.
1728
+
1729
+ The bucket wakes on **membership only**, meaning an entity joining or leaving. For "any member's
1730
+ content changed", the index also hands out the bucket's any-column slot
1731
+ (`getAnyBucketColumnByKey(index, key)` / `holdAnyBucketColumnByKey(index, key)`, given back with
1732
+ `indexService.unholdColumn`). It wakes when a member's content changes, and a member
1733
+ joining or leaving is not its business. A reader that wants both subscribes to the
1734
+ bucket slot and the any-column slot. **The any-column slot is not the recommended
1735
+ path for rendering**, because one edit of one entity wakes the whole list. Subscribe the list
1736
+ to the bucket and each entity to its own slot. The any-column slot costs
1737
+ something only while somebody holds or subscribes to it.
1738
+
1739
+ Batch doors (`upsertEntities`, `insertEntities`/`fillEntities`, `deleteEntities`,
1740
+ `clearAllEntities`) notify the index once per batch. The index groups entities by key and moves each
1741
+ group with one bucket write.
1742
+
1743
+ Know these four things before choosing it:
1744
+
1745
+ - **No bucket exists for a key until it is minted.** A bucket minted through any
1746
+ door (`getSlotByKey`, `holdSlotByKey`, `getSlotsByKeys`, `holdSlotsByKeys`) is filled from the source
1747
+ entities written before it, without a flush, so minting inside an effect body does not
1748
+ throw. `getSlotsByKeys` / `holdSlotsByKeys` fill all new buckets in ONE pass over the source. How long the bucket lives then is the
1749
+ collection's ordinary business: the `slotReleasePolicy` option, or `db.slotReleasePolicy`
1750
+ when you name none; with neither, the bucket is kept for good. `OIMSlotReleasePolicyAfterIdle`
1751
+ cools it on the queue, and `OIMSlotReleasePolicyAtUnhold.INSTANCE` ends it at
1752
+ the last give-back. The index injects no lifetime of its own.
1753
+ - **The key comes from `columns`, `keyOf`, or both**, the same pair as the sorter's
1754
+ `sortColumns` / `comparator`. The key type is inferred from the columns (`TOIMIndexKey`):
1755
+ `{ columns: ['deck'] }` is keyed by that one column's value and refuses an array;
1756
+ `createOIMIndexServiceByCompositeKey(db, { entityService, columns: ['deck', 'suit'] })` is keyed by the
1757
+ tuple of their values and builds the bucket collection on a tree-driven store
1758
+ (`OIMSlotStoreServiceTreeDriven`) by itself, while `createOIMIndexService` without a
1759
+ `bucketSlotStoreService` refuses two columns. When the index asks
1760
+ whether a key moved, it asks its own `isSameKey(key, otherKey)`, which
1761
+ every collection answers from its own slot store service.
1762
+ `{ keyOf }` is any function, read again on every write. With `{ keyOf, columns }`,
1763
+ the columns are the ones `keyOf` reads. With `columns`, a patch or put that changes
1764
+ none of them keeps the entity's key and calls nothing; a write of the whole value
1765
+ always reads the key again.
1766
+ - A write that reads the key costs, per index, one key read and one comparison
1767
+ against the key the entity was last filed under. Only a key that MOVED touches a
1768
+ bucket, and a key that nobody holds is not even looked up.
1769
+ - **Order is not the bucket's business.** A bucket keeps the order its slots
1770
+ arrived in. A sorted list is a computed over the index, and two readers who
1771
+ want two orders of one key build two of them over the one index.
1772
+
1773
+ ### A sorted list is a computed over an index
1107
1774
 
1108
1775
  ```typescript
1109
- import {
1110
- OIMObjectValueByKeySelector,
1111
- OIMObjectValuesByKeysSelector,
1112
- OIMComputeRuntime,
1113
- OIMEventQueue,
1114
- OIMReactiveObject,
1115
- } from '@oimdb/core';
1116
-
1117
- const queue = new OIMEventQueue();
1118
- const runtime = new OIMComputeRuntime(queue);
1119
- const settings = new OIMReactiveObject<'theme' | 'lang', string>(queue);
1120
-
1121
- // Watch single key
1122
- const themeSelector = new OIMObjectValueByKeySelector(runtime, settings, 'theme');
1123
- themeSelector.watch((theme) => {
1124
- console.log('Theme changed:', theme);
1776
+ const indexService = createOIMIndexService(db, {
1777
+ entityService: cardService,
1778
+ keyOf: (card: Card) => card.deck,
1125
1779
  });
1780
+ const index = indexService.createIndexStoreSlot(cards);
1126
1781
 
1127
- // Watch multiple keys
1128
- const settingsSelector = new OIMObjectValuesByKeysSelector(runtime, settings, ['theme', 'lang']);
1129
- settingsSelector.watch((values) => {
1130
- console.log('Settings changed:', values); // [theme, lang]
1782
+ const byRankInDeckService = createOIMComputedServiceSorted({
1783
+ indexService,
1784
+ sortColumns: [{ column: 'rank', direction: 'asc' }],
1131
1785
  });
1786
+ const byRankInDeck = byRankInDeckService.createSortedStoreSlot(index);
1132
1787
 
1133
- settings.setProperty('theme', 'dark');
1134
- queue.flush();
1135
-
1136
- settings.destroy();
1137
- queue.destroy();
1138
- ```
1139
-
1140
- **Key differences: Effects vs Selectors:**
1141
-
1142
- - **Effects** (`OIMEffect`): Run side effects when dependencies change. Use for logging, API calls, UI updates.
1143
- - **Selectors** (`OIMSelector`): Watch and deliver values only when they actually change. Use for reactive data access with automatic change detection.
1144
- - **Computed** (`OIMComputed`): Derive values from dependencies. Use for calculated/transformed data.
1145
-
1146
- #### Gotchas (read this once)
1147
-
1148
- - **Avoid cycles**: if A depends on B and B depends on A (directly or indirectly), you can get endless invalidation/recompute. Keep your dependency graph acyclic.
1149
- - **Keep `compute()` pure**: treat `compute()` as a pure function over current state. Doing writes inside `compute()` will create hard-to-debug re-entrancy.
1150
- - **Keep effects safe**: if you need to write to stores or trigger IO, do it from `OIMEffect`, but avoid creating endless update loops.
1151
- - **Always `destroy()`**: effects/computed/selectors subscribe to dependencies; if you create them dynamically, call `destroy()` or use the unsubscribe function to unsubscribe and free memory.
1152
- - **Selectors deliver only on change**: Selectors use equality checks (`areEqual`) to avoid delivering the same value multiple times. Override `areEqual` in custom selectors if needed.
1153
-
1154
- ### Scheduler Types
1155
-
1156
- Choose the right scheduler for your use case:
1157
-
1158
- - **`microtask`**: Most common - drains right after the current synchronous code, before the next macrotask and before paint (lowest delivery latency)
1159
- - **`timeout`**: Configurable macrotask delay for custom batching strategies
1160
- - **`animationFrame`**: Syncs with browser rendering (~60fps)
1161
- - **`immediate`**: Macrotask — `setImmediate` in Node, falling back to `MessageChannel` then `setTimeout(0)` in browsers; runs *after* the microtask queue, so higher latency than `microtask`
1162
- - **`sync`** (`createSync()`): flushes synchronously, in the same call stack, on every write — **no batching**. Debug/diagnostic only
1163
-
1164
- The async schedulers coalesce writes into one flush per tick. For synchronous delivery in normal code, call `queue.flush()` directly (e.g. in tests). Use the `sync` scheduler only to diagnose coalescing-related bugs: swap it in at the root and see whether the bug survives without batching (it exposes intermediate un-coalesced states and can overflow the stack on a cyclic graph).
1165
-
1166
- ### Reactive Collection Hierarchy
1167
-
1168
- ```
1169
- OIMCollection (base; upserts return canonical slots)
1170
- └── OIMReactiveCollection (adds updateEventEmitter wired to the queue)
1171
-
1172
- OIMIndexSetBased (base for Set-based)
1173
- ├── OIMIndexManualSetBased (manual Set-based index)
1174
- ├── OIMReactiveIndexManualSetBased (reactive Set-based index with event emitter)
1175
- └── OIMReactiveCollectionIndexManualSetBased (collection-bound reactive Set-based index)
1176
-
1177
- OIMIndexArrayBased (base for Array-based)
1178
- ├── OIMIndexManualArrayBased (manual Array-based index)
1179
- ├── OIMIndexManualOrderedArrayBased (slot-first manual ordered Array-based index)
1180
- ├── OIMCollectionIndexManualOrderedArrayBased (collection-bound ordered Array-based index)
1181
- ├── OIMReactiveIndexManualArrayBased (reactive Array-based index with event emitter)
1182
- └── OIMReactiveCollectionIndexManualArrayBased (collection-bound reactive Array-based index)
1183
-
1184
- OIMOrderedListCommandStream (slot-first ordered-list command stream)
1185
- OIMCollectionOrderedListCommandStream (collection-bound ordered-list command stream)
1186
- ```
1187
-
1188
- ## ⚡ Performance Characteristics
1189
-
1190
- - **Collections**: O(1) primary key lookups using Map-based storage
1191
- - **Reactive Collections**: O(1) lookups + efficient event coalescing
1192
- - **Indices**: O(1) index lookups with lazy evaluation
1193
- - **Event System**: Smart coalescing prevents redundant notifications
1194
- - **Memory**: Efficient key-based subscriptions, no global listeners
1195
- - **Schedulers**: Configurable timing for batching:
1196
- - **Microtask**: drains after the current synchronous stack, before macrotasks/paint (lowest latency)
1197
- - **Immediate**: macrotask — `setImmediate` (Node), falling back to `MessageChannel` then `setTimeout(0)` (browser); runs after the microtask queue, not synchronous
1198
- - **Timeout**: custom macrotask delay for batching strategies
1199
- - **AnimationFrame**: ~16ms, synced with ~60fps rendering
1200
-
1201
- ### Index Performance
1202
-
1203
- **SetBased Indexes** (`OIMReactiveCollectionIndexManualSetBased`):
1204
- - **Storage**: Set of canonical entity slots
1205
- - **PK projection**: `getPksByKey` returns `Set<TPk>` for efficient membership checks
1206
- - **Entity reads**: selectors/hooks read `slot.item` directly, avoiding per-item collection lookups
1207
- - **Best for**: Frequent incremental updates using `addPks`/`removePks`
1208
- - **Performance**: O(1) add/remove operations, O(n) for `setPks` (requires Set creation)
1209
- - **Use case**: When you need to frequently add/remove individual items
1210
-
1211
- **ArrayBased Indexes** (`OIMReactiveCollectionIndexManualArrayBased`):
1212
- - **Storage**: Array of canonical entity slots
1213
- - **PK projection**: `getPksByKey` returns `TPk[]` for direct array access
1214
- - **Entity reads**: selectors/hooks read `slot.item` directly, preserving array order
1215
- - **Best for**: Full array replacements using `setPks`
1216
- - **Performance**: O(1) `setPks` operation (direct assignment, no diff computation)
1217
- - **Use case**: When you typically replace the entire array (e.g., deck cards, ordered lists) or when you need to preserve element order/sorting
1218
- - **Note**: While `addPks`/`removePks` are available, they are less efficient (O(n)) than for SetBased indexes. For ArrayBased indexes, prefer `setPks` for better performance.
1219
-
1220
- ## 🔗 Integration Patterns
1221
-
1222
- ### With React (@oimdb/react)
1223
-
1224
- ```bash
1225
- npm install @oimdb/react
1788
+ const spades = byRankInDeckService.computedCollectionService.holdSlotByKey(byRankInDeck, 'spades');
1789
+ effectService.createEffect(db, [], [spades], () => { console.log(spades.computedValueService.getValueForSlot(spades).map((entitySlot) => entitySlot.entity?.title)); });
1790
+ spades.owner.unholdSlot(spades);
1226
1791
  ```
1227
1792
 
1228
- Create your collections outside React, wire them up once, then use hooks inside components:
1793
+ `createOIMComputedServiceSorted({ indexService, sortColumns?, comparator?, slotReleasePolicy? })`
1794
+ takes no `db`: its store slot is created from the index store slot and runs on that one's
1795
+ channel slot. It returns an `OIMComputedServiceSorted`, whose
1796
+ `createSortedStoreSlot(indexStoreSlot)` makes one store slot over one index store slot. Its doors
1797
+ are on its `computedCollectionService`: `getSlotByKey`, `holdSlotByKey`, `findSlotByKey`, each
1798
+ with that store slot first. A key's value, read with
1799
+ `computedSlot.computedValueService.getValueForSlot(computedSlot)`, is that key's entity slots,
1800
+ sorted. `indexService` is the `OIMIndexService` whose buckets it sorts.
1801
+
1802
+ - `sortColumns` is `readonly TOIMSortColumn<TEntity>[]`, each `{ column, direction: 'asc' | 'desc' }`.
1803
+ `column` is a `TOIMSortableColumnName<TEntity>`, meaning a column whose values are strings,
1804
+ numbers or bigints. **Columns decide which writes re-sort.**
1805
+ - `comparator` is `(left: TEntity, right: TEntity) => number`. **It decides the order.**
1806
+ Without it, the order is `oimColumnsComparator(sortColumns)`: the columns in turn, with an
1807
+ undefined or null value last.
1808
+ - With only `comparator` and no `sortColumns`, any write to a member re-sorts, because nothing
1809
+ says which columns the comparator reads.
1810
+ - With neither, a member keeps the place it first took, and an arrival is appended.
1811
+ - With both, `comparator` orders, and `sortColumns` is the list of columns whose change wakes
1812
+ the sort. **List every column the comparator reads**: a change to a column it reads but
1813
+ `sortColumns` omits does not re-sort. Use both when the order needs a rule
1814
+ `oimColumnsComparator` cannot express (a locale compare, a tie-break) and the sort
1815
+ should still wake only for the columns that rule reads.
1816
+ - `slotReleasePolicy` (default `OIMSlotReleasePolicyAtUnhold.INSTANCE`) decides whether a key's
1817
+ list outlives its last hold. `OIMSlotReleasePolicyNever.INSTANCE` keeps every key ever
1818
+ read and re-sorts only on a read that finds it changed, which suits a view that switches
1819
+ between groups. Give the index the same policy, or its bucket is rebuilt instead.
1820
+
1821
+ It re-sorts only when the bucket's membership changed or a write changed one of
1822
+ its columns on a member. Its sources are the bucket slot of the key plus one
1823
+ [bucket column slot](#bucket-column-slots) per column (or the bucket's any-column
1824
+ slot when only a comparator is given), so a write that touches no sort column
1825
+ does not re-derive it. It is a computed: it is pulled and re-derived on the read that asks,
1826
+ and an effect subscribes to it as to any other derivation. A derived value that
1827
+ compares equal by `oimSameSlotSequence` keeps the previous array.
1828
+
1829
+ ### A sorted index: sorted at write
1830
+
1831
+ The other strategy keeps the index's own buckets in order, so a read needs no computed.
1832
+ The consumer picks one at construction.
1229
1833
 
1230
1834
  ```typescript
1231
- import { createOIMCollectionKit, OIMEventQueue } from '@oimdb/core';
1232
- import {
1233
- OIMCollectionsProvider,
1234
- useOIMCollectionsContext,
1235
- useSelectEntityByPk,
1236
- useSelectEntitiesByIndexKeySetBased,
1237
- } from '@oimdb/react';
1238
-
1239
- // --- store.ts (created once, outside React) ---
1240
- const queue = new OIMEventQueue();
1241
- const { collection: users, indexFactory } =
1242
- createOIMCollectionKit<User, string>(queue, { selectPk: (u) => u.id });
1243
- const byTeam = indexFactory.derivedSetIndex((u) => [u.teamId]);
1244
- export const collections = { users };
1245
- export { byTeam };
1246
-
1247
- // --- App.tsx ---
1248
- function App() {
1249
- return (
1250
- <OIMCollectionsProvider collections={collections}>
1251
- <TeamList teamId="team1" />
1252
- </OIMCollectionsProvider>
1253
- );
1254
- }
1255
-
1256
- // --- TeamList.tsx ---
1257
- type AppCollections = typeof collections;
1258
-
1259
- function TeamList({ teamId }: { teamId: string }) {
1260
- const { users } = useOIMCollectionsContext<AppCollections>();
1261
- const members = useSelectEntitiesByIndexKeySetBased(users, byTeam, teamId);
1262
- return (
1263
- <ul>
1264
- {members?.map((u) => u && <li key={u.id}>{u.name}</li>)}
1265
- </ul>
1266
- );
1267
- }
1268
-
1269
- function UserCard({ userId }: { userId: string }) {
1270
- const { users } = useOIMCollectionsContext<AppCollections>();
1271
- const user = useSelectEntityByPk(users, userId);
1272
- return <span>{user?.name}</span>;
1273
- }
1274
- ```
1275
-
1276
- All hooks use `useSyncExternalStore` internally and re-render only when the specific data they watch actually changes.
1277
-
1278
- ### With Redux (@oimdb/redux-adapter)
1279
-
1280
- Migrate from Redux to OIMDB gradually or use both systems side-by-side with automatic two-way synchronization:
1281
-
1282
- ```typescript
1283
- import { OIMDBAdapter } from '@oimdb/redux-adapter';
1284
- import { createStore, combineReducers, applyMiddleware } from 'redux';
1285
-
1286
- // Create Redux adapter
1287
- const adapter = new OIMDBAdapter(queue);
1288
-
1289
- // Create Redux reducer from OIMDB collection
1290
- const usersReducer = adapter.createCollectionReducer(users);
1291
-
1292
- // Create middleware for automatic flushing
1293
- const middleware = adapter.createMiddleware();
1294
-
1295
- // Use in existing Redux store
1296
- const store = createStore(
1297
- combineReducers({
1298
- users: usersReducer, // OIMDB-backed reducer
1299
- ui: uiReducer, // Existing Redux reducer
1300
- }),
1301
- applyMiddleware(middleware)
1302
- );
1303
-
1304
- adapter.setStore(store);
1305
-
1306
- // OIMDB changes automatically sync to Redux
1307
- // Redux actions automatically sync back to OIMDB with child reducers
1308
- // Middleware automatically flushes queue after each action - no manual flush needed!
1835
+ const cardsByDeckService = createOIMIndexServiceSortedArrayDriven(db, {
1836
+ entityService: cardService,
1837
+ columns: ['deck'],
1838
+ sortColumns: [{ column: 'rank', direction: 'asc' }, { column: 'id', direction: 'desc' }],
1839
+ });
1840
+ const cardsByDeck = cardsByDeckService.createIndexStoreSlot(cards);
1841
+ const spades = cardsByDeckService.holdSlotByKey(cardsByDeck, 'spades');
1842
+ Array.from(cardsByDeckService.slotContentService.getBucketMemberSlots(spades));
1309
1843
  ```
1310
1844
 
1311
- **Key Benefits:**
1312
- - **🔄 Gradual Migration**: Migrate one collection at a time without breaking changes
1313
- - **🔄 Two-Way Sync**: Automatic synchronization between OIMDB and Redux
1314
- - **⚡ Automatic Flushing**: Middleware automatically processes events after Redux actions
1315
- - **📦 Production Ready**: Battle-tested adapter optimized for large datasets
1316
- - **🎯 Flexible**: Works with any Redux state structure via custom mappers
1317
-
1318
- [📖 See @oimdb/redux-adapter documentation](../redux-adapter/README.md) for complete migration guide and examples.
1319
-
1320
- ### Standalone Usage
1321
-
1322
- Use core classes directly for maximum control:
1845
+ `createOIMIndexServiceSortedArrayDriven(db, { entityService, columns? | keyOf?, sortColumns, slotReleasePolicy?, bucketSlotStoreService? })`
1846
+ returns an `OIMIndexServiceSortedArrayDriven`, whose content service is an
1847
+ `OIMSlotContentBucketServiceSortedArrayDriven`. `sortColumns` is the same `readonly TOIMSortColumn<TEntity>[]`
1848
+ that `createOIMComputedServiceSorted` takes.
1849
+
1850
+ - Writes append to an unsorted tail of the bucket; the first read settles it, by binary
1851
+ insertion for a short tail or by one sort for a long one.
1852
+ - The index re-files a member when one of its sort columns changes, and only then.
1853
+ - `getBucketMemberSlots`, `getBucketMemberSlotAt` and `getBucketMemberSlotPosition` answer in order.
1854
+ - The bucket has no positional writes, and its bucket commands are always `OIMBucketCommandsNone.INSTANCE`.
1855
+ - Sort-at-write suits point updates, where a rank change re-files one member. Sort-at-read
1856
+ (`createOIMComputedServiceSorted` over any index) suits bulk loads.
1857
+
1858
+ ### Bucket column slots
1859
+
1860
+ An index exposes one column slot per bucket and column:
1861
+ `getBucketColumnByKey(indexStoreSlot, key, columnName)`, `holdBucketColumnByKey(indexStoreSlot, key, columnName)` and
1862
+ `indexService.unholdColumn(columnSlot)`. The carrier of such a `TOIMColumnSlot` is the
1863
+ bucket slot, and it is written when a write changes that column on any member of the
1864
+ bucket. `markBucketColumnsChanged(bucketSlot, patch, changedColumns)` on the bucket content
1865
+ service is the door the index calls, and the door for whoever changes the members of a
1866
+ bucket slot they hold, a single bucket's included. The read side of this is the
1867
+ `OIMIndexService`: it carries its buckets' doors, the bucket column slot doors and the
1868
+ bucket any-column slot doors (`getAnyBucketColumnByKey(indexStoreSlot, key)`,
1869
+ `holdAnyBucketColumnByKey(indexStoreSlot, key)`; both are given back with
1870
+ `indexService.unholdColumn`), and its `slotContentService` carries the member reads,
1871
+ the positional ones of the array-based kinds included.
1323
1872
 
1324
1873
  ```typescript
1325
- // Manual subscription management
1326
- const unsubscribe = users.updateEventEmitter.subscribeOnKey('user1', () => {
1327
- // Handle user1 changes
1874
+ const decksService = createOIMIndexService(db, {
1875
+ entityService: cardService,
1876
+ keyOf: (card: Card) => card.deck,
1328
1877
  });
1878
+ const decks = decksService.createIndexStoreSlot(cards);
1329
1879
 
1330
- // Clean up when done
1331
- unsubscribe();
1332
- ```
1333
-
1334
- ## 🕳️ Missing entities: the holes policy
1335
-
1336
- A pk can point at nothing — never upserted, or removed while an index still holds it. One rule
1337
- applies everywhere:
1338
-
1339
- > **Reads are length-aligned and surface the gap as `undefined`. Dropping it is always a separate,
1340
- > explicitly named call.**
1341
-
1342
- ```ts
1343
- collection.getManyByPks(['u1', 'gone', 'u2']); // [User, undefined, User]
1344
- collection.getManyByPksCompact(['u1', 'gone', 'u2']); // [User, User] — SHORTER, deliberate
1345
- ```
1346
-
1347
- `index.getEntitiesByKey`, `globalIndex.getEntities`, `stream.getEntitiesByKey` and every
1348
- list-returning selector follow the same shape. A **derived** index is dense by construction, so the
1349
- `| undefined` there is spurious; a **manual** index (`setPks`, composite) can hold a dangling pk, and
1350
- compacting it away is how a torn state becomes invisible.
1351
-
1352
- ## 📚 API Reference
1353
-
1354
- ### DX Factories
1355
-
1356
- #### `createOIMCollectionKit<TEntity, TPk>(queue, opts?)`
1357
- Creates a small facade:
1358
-
1359
- ```typescript
1360
- type TOIMCollectionKit<TEntity, TPk> = {
1361
- queue: OIMEventQueue;
1362
- collection: OIMReactiveCollection<TEntity, TPk>;
1363
- indexFactory: OIMCollectionIndexFactory<TEntity, TPk>;
1364
- select: OIMCollectionSelectors<TEntity, TPk>;
1365
- };
1880
+ const rankOfSpades = decksService.holdBucketColumnByKey(decks, 'spades', 'rank');
1881
+ const onRankInSpades = effectService.createEffect(db, [rankOfSpades], [], () => {});
1882
+ effectService.disposeEffect(onRankInSpades);
1883
+ decksService.unholdColumn(rankOfSpades);
1366
1884
  ```
1367
1885
 
1368
- This is a DX entrypoint only: indexes and lists are still created as separate structures via `indexFactory`, not stored inside the collection.
1369
-
1370
- #### `OIMCollectionSelectors<TEntity, TPk>`
1371
- DX facade for reactive read selectors backed by one `OIMComputeRuntime`.
1886
+ ### Index keys strategy
1372
1887
 
1373
- **Methods:**
1374
- - `byPk(pk)` - Create `OIMCollectionByPkSelector<TEntity, TPk>`
1375
- - `byPks(pks)` - Create `OIMCollectionByPksSelector<TEntity, TPk>`
1376
- - `entitiesBySetIndexKey(index, key)` - Create an entity selector for a Set-based reactive index key
1377
- - `entitiesByArrayIndexKey(index, key)` - Create an entity selector for an Array-based reactive index key
1888
+ Each indexable entity slot carries, per index, the key it is filed under now, so a moved entity
1889
+ leaves its old bucket in O(1). Only an index writes it, through its `IOIMIndexKeysService`
1890
+ (`get(slot, position)`, `set(slot, position, indexKey)`), at the position its index store
1891
+ slot was given (`TOIMIndexStoreSlot.indexKeysPosition`, allocated from the source store slot's
1892
+ `indexKeysPositions`). Choose the service with the `indexKeysService` option of `OIMDB`; every
1893
+ index the factories build on that `db` takes it (default `OIMDefaults.INDEX_KEYS_SERVICE.getValue()`).
1378
1894
 
1379
- ### Core Classes
1895
+ - `OIMIndexKeysServiceAppending` (default): a disposed index's position is never
1896
+ reused, and its stale entry stays on each entity slot. A slot's `indexKeys` grows to the highest
1897
+ position any index wrote on it, so after K indexes are made and disposed, a slot the
1898
+ last one filed carries an array of K, and a stale entry keeps its key value
1899
+ reachable. A write pays nothing for a disposed index: the store slot keeps only the
1900
+ live listeners, in the order they were added.
1901
+ - `OIMIndexKeysServiceReclaiming`: on dispose, it wipes that position on every entity slot of the
1902
+ source store slot (O(N)) and reuses it.
1380
1903
 
1381
- #### `OIMReactiveCollection<TEntity, TPk>`
1382
- Reactive collection with automatic change notifications and event coalescing.
1904
+ Switch to `OIMIndexKeysServiceReclaiming` only if you create indexes dynamically and often.
1905
+ Creating indexes that way is expensive; better not to.
1383
1906
 
1384
- **Constructor:**
1385
- ```typescript
1386
- new OIMReactiveCollection(queue: OIMEventQueue, opts?: TOIMCollectionOptions<TEntity, TPk>)
1387
- ```
1388
-
1389
- **Properties:**
1390
- - `collection: OIMCollection<TEntity, TPk>` - Underlying collection
1391
- - `updateEventEmitter: OIMUpdateEventEmitter<TPk>` - Key-specific subscriptions
1392
- - Event batching/deduplication is handled internally by `OIMUpdateEventEmitter`
1393
-
1394
- **Methods:**
1395
- - `upsertOne(entity: TEntity): TOIMEntitySlot<TEntity, TPk>` - Insert or update single entity and return its canonical slot
1396
- - `upsertOneByPk(pk: TPk, entity: Partial<TEntity>): TOIMEntitySlot<TEntity, TPk>` - Insert or update by primary key and return its canonical slot
1397
- - `upsertMany(entities: TEntity[]): TOIMEntitySlot<TEntity, TPk>[]` - Insert or update multiple entities and return canonical slots
1398
- - `removeOne(entity: TEntity): void` - Remove single entity
1399
- - `removeMany(entities: TEntity[]): void` - Remove multiple entities
1400
- - `getOneByPk(pk: TPk): TEntity | undefined` - Get entity by primary key
1401
- - `getManyByPks(pks: readonly TPk[]): TEntity[]` - Get existing entities for primary keys
1402
- - `getSlotByPk(pk: TPk): OIMEntitySlot<TEntity, TPk> | undefined` - Get the canonical slot for a primary key
1403
- - `getSlotsByPks(pks: readonly TPk[]): OIMEntitySlot<TEntity, TPk>[]` - Get existing canonical slots for primary keys
1404
-
1405
- #### `OIMCollectionIndexFactory<TEntity, TPk>`
1406
- Factory helper for collection-bound indexes and ordered lists. It keeps `queue + collection` together for construction only; created indexes/lists still live next to the collection, not inside it.
1407
-
1408
- **Constructor:**
1409
- ```typescript
1410
- new OIMCollectionIndexFactory(queue: OIMEventQueue, collection: OIMReactiveCollection<TEntity, TPk>)
1411
- ```
1907
+ ### Rendering an ordered bucket
1412
1908
 
1413
- **Factory:**
1414
- ```typescript
1415
- createOIMCollectionIndexFactory(queue, collection)
1909
+ ```typescript setup
1910
+ const orderedBucketsService = createOIMBucketService<CardSlot>(db);
1911
+ const orderedBuckets = orderedBucketsService.createStoreSlot(db);
1416
1912
  ```
1417
1913
 
1418
- **Methods:**
1419
- - `setBasedIndex<TKey>()` - Create `OIMReactiveCollectionIndexManualSetBased<TKey, TPk, TEntity>`
1420
- - `compositeSetIndex()` - Create `OIMReactiveCollectionIndexCompositeTrieSetBased<TPk, TEntity>` — set-based index keyed by a composite **key path** (`TOIMKeyPath`, an arbitrary-length tuple of primitive segments, e.g. `setPks([projectId, role], pks)`). Matched by content in O(arity), no string concatenation. Primitive-keyed indexes keep their native-`Map` fast path.
1421
- - `compositeArrayIndex()` - Create `OIMReactiveCollectionIndexCompositeTrieArrayBased<TPk, TEntity>` — ordered counterpart of `compositeSetIndex`, buckets are ordered slot arrays per key path.
1422
- - `derivedSetIndex<TKey>(selectIndexKeys)` - Create `OIMDerivedCollectionIndexSetBased<TKey, TPk, TEntity>`
1423
- - `arrayBasedIndex<TKey>()` - Create `OIMReactiveCollectionIndexManualArrayBased<TKey, TPk, TEntity>`
1424
- - `derivedArrayIndex<TKey>(selectIndexKeys, { orderBy?, compareEntities? })` - Create `OIMDerivedCollectionIndexArrayBased<TKey, TPk, TEntity>`
1425
- - `orderedIndex<TKey>()` - Create `OIMCollectionIndexManualOrderedArrayBased<TKey, TPk, TEntity>`
1426
- - `orderedList<TKey>()` - Create `OIMCollectionOrderedListCommandStream<TKey, TPk, TEntity>`
1427
-
1428
- #### `OIMReactiveIndexManualSetBased<TKey, TPk>`
1429
- Reactive Set-based index with manual slot writes and change notifications. Use this as a raw slot-first primitive; use `OIMReactiveCollectionIndexManualSetBased` for PK-oriented writes.
1430
-
1431
- **Constructor:**
1432
1914
  ```typescript
1433
- new OIMReactiveIndexManualSetBased(queue: OIMEventQueue, opts?: {
1434
- indexOptions?: {
1435
- comparePks?: TOIMIndexComparator<TPk>;
1436
- store?: OIMIndexStoreSetBased<TKey, TPk>;
1915
+ const bucket = orderedBucketsService.holdSlotByKey(orderedBuckets, 'spades');
1916
+ const whatIRendered: CardSlot[] = [];
1917
+
1918
+ for (const command of oimOrderedListCommandsOfTransition(whatIRendered, Array.from(orderedBucketsService.slotContentService.getBucketMemberSlots(bucket)))) {
1919
+ switch (command.type) {
1920
+ case 'insert': /* command.index, command.slot */ break;
1921
+ case 'remove': /* command.index */ break;
1922
+ case 'move': /* command.from, command.to */ break;
1923
+ case 'reset': /* command.slots */ break;
1437
1924
  }
1438
- })
1925
+ }
1439
1926
  ```
1440
1927
 
1441
- **Properties:**
1442
- - `index: OIMIndexManualSetBased<TKey, TPk>` - Underlying Set-based index
1443
- - `updateEventEmitter: OIMUpdateEventEmitter<TKey>` - Key-specific subscriptions
1928
+ `whatIRendered` must be a snapshot taken where you rendered, never the value
1929
+ `getBucketMemberSlots` handed you. `getBucketMemberSlots` returns the bucket's live list, which is why it
1930
+ is typed `Iterable` and not an array. A `prev` kept live is the same array as `next`
1931
+ on the following frame, so the diff comes back empty, no reorder is ever drawn,
1932
+ and nothing throws. Keep the `Array.from(...)` copy, or subscribe to
1933
+ `createOIMComputedServiceBucketMemo`, whose value is a fresh array on every write
1934
+ that changed the list and the same array while it did not.
1444
1935
 
1445
- **Methods:**
1446
- - `setSlots(key: TKey, slots: Iterable<TOIMAnyEntitySlot<TPk>>): void` - Set canonical slots directly
1447
- - `clear(key?: TKey): void` - Clear all keys or specific key
1936
+ A list may name one slot twice: `OIMBucketArrayMembershipNonUnique`, the default strategy
1937
+ of `OIMSlotContentBucketServiceArrayDriven`, records every mention. The mentions of one identity
1938
+ are matched in the order they appear, and the script reproduces the new list
1939
+ mention for mention. The minimal move count is claimed for lists whose identities
1940
+ are distinct.
1448
1941
 
1449
- **Query:**
1450
- - `index.getPksByKey(key: TKey): Set<TPk>` - Returns Set projection of primary keys
1451
- - `index.getSlotsByKey(key: TKey): ReadonlySet<TOIMAnyEntitySlot<TPk>>` - Returns stored slots for fast entity reads
1942
+ `oimOrderedListCommandsOfTransition` is a free function and not a method on `OIMChannelService`, because a bundler
1943
+ cannot drop a class's methods one at a time, and every consumer imports the
1944
+ channel service.
1452
1945
 
1453
- #### `OIMReactiveCollectionIndexManualSetBased<TKey, TPk, TEntity>`
1454
- Collection-bound Set-based index. Use this when `setPks`/`addPks`/`removePks` should resolve PKs to canonical slots from a collection.
1946
+ ---
1455
1947
 
1456
- **Constructor:**
1457
- ```typescript
1458
- new OIMReactiveCollectionIndexManualSetBased(queue: OIMEventQueue, opts: {
1459
- collection: OIMReactiveCollection<TEntity, TPk>;
1460
- indexOptions?: {
1461
- comparePks?: TOIMIndexComparator<TPk>;
1462
- store?: OIMIndexStoreSetBased<TKey, TPk>;
1463
- };
1464
- } | {
1465
- getSlot: TOIMEntitySlotGetter<TPk>;
1466
- indexOptions?: {
1467
- comparePks?: TOIMIndexComparator<TPk>;
1468
- store?: OIMIndexStoreSetBased<TKey, TPk>;
1469
- };
1470
- })
1471
- ```
1948
+ ## Derivations
1472
1949
 
1473
- Pass exactly one binding: `collection` for normal collection-bound indexes, or `getSlot` for custom slot lookup.
1474
-
1475
- **Methods:**
1476
- - `setPks(key: TKey, pks: readonly TPk[]): void` - Set primary keys for index key
1477
- - `addPks(key: TKey, pks: readonly TPk[]): void` - Add primary keys to index key
1478
- - `removePks(key: TKey, pks: readonly TPk[]): void` - Remove primary keys from index key
1479
- - `setSlots(key: TKey, slots: Iterable<TOIMAnyEntitySlot<TPk>>): void` - Set canonical slots directly
1480
- - `clear(key?: TKey): void` - Clear all keys or specific key
1481
-
1482
- #### `OIMDerivedCollectionIndexSetBased<TKey, TPk, TEntity>`
1483
- Collection-bound Set-based index that derives membership from collection entities. Use this when index keys come from entity fields and should stay in sync automatically.
1484
-
1485
- **Constructor:**
1486
- ```typescript
1487
- new OIMDerivedCollectionIndexSetBased(queue, collection, {
1488
- selectIndexKeys: (entity: TEntity) => TKey | readonly TKey[] | undefined | null;
1489
- buildInitial?: boolean; // defaults to true
1490
- indexOptions?: {
1491
- comparePks?: TOIMIndexComparator<TPk>;
1492
- store?: OIMIndexStoreSetBased<TKey, TPk>;
1493
- };
1494
- })
1495
- ```
1496
-
1497
- **Methods:**
1498
- - `rebuildFromCollection(): void` - Rebuild all derived membership from current collection slots
1499
- - all read/subscription methods from `OIMReactiveCollectionIndexManualSetBased`
1500
-
1501
- #### `OIMDerivedCollection<TSource, TDerived, TPk>`
1502
- A reactive collection whose entities are derived one-to-one from a source collection (`derived[pk] = derive(source[pk], pk)`, keyed by the source pk), kept in sync automatically. It *is* a first-class `OIMReactiveCollection`, so it can be indexed, selected, and derived-from again (join → join); maintenance runs on the source's synchronous update channel, so chains stay consistent within a flush. Needs no compute runtime.
1503
-
1504
- **Constructor:**
1505
- ```typescript
1506
- new OIMDerivedCollection(queue, source, (entity: TSource, pk: TPk) => TDerived, {
1507
- compare?: (a: TDerived, b: TDerived) => boolean; // default Object.is; skip update when unchanged
1508
- store?: OIMCollectionStore<TDerived, TPk>; // trie-driven for a composite source PK
1509
- buildInitial?: boolean; // defaults to true
1510
- })
1950
+ ```typescript setup
1951
+ const totalService = createOIMComputedService<void, number, TOIMSingleSlotStore<TOIMComputedSlot<number, void>>>({
1952
+ computedSlotStoreService: new OIMSlotStoreServiceSingleSlotDriven<TOIMComputedSlot<number, void>>(),
1953
+ sameValueGuard: oimSameValueZeroGuard, // did the value move?
1954
+ });
1955
+ const total = totalService.createComputedStoreSlot(
1956
+ db,
1957
+ () => slot.entity?.rank ?? 0,
1958
+ () => ({
1959
+ sourceSlots: [slot], // root slots it reads
1960
+ }),
1961
+ );
1962
+ const totalSlot = totalService.computedCollectionService.getSlotByKey(total, undefined);
1511
1963
  ```
1512
1964
 
1513
- **Methods:**
1514
- - `rebuildFromSource(): void` - Re-derive all entities from the current source
1515
- - all read/write/subscription methods from `OIMReactiveCollection`
1516
-
1517
- #### `OIMReactiveIndexManualArrayBased<TKey, TPk>`
1518
- Reactive Array-based index with manual slot writes and change notifications. Use this as a raw slot-first primitive; use `OIMReactiveCollectionIndexManualArrayBased` for PK-oriented writes.
1965
+ `createSources` returns `sourceSlots` (root slots it reads) and `sourceComputeds` (derivations it reads). Both keys are optional; a missing one means none.
1966
+
1967
+ A derivation is a slot that nothing ever marks: it is **asked**, not woken. Its
1968
+ shape is `TOIMComputedSlot<TValue, TKey>` (`value`, `sourceSlots`,
1969
+ `sourceComputeds`, `ranAtRevision` / `ranAtEpoch`, `checkedAtRevision` /
1970
+ `checkedAtEpoch`, `isDeriving`, `allRootSlots`, `owner`, `computedValueService`), and its
1971
+ `computedValueService` is the only writer of its value. That service holds the
1972
+ `sameValueGuard` once and serves the value; the derive function and `createSources` come
1973
+ from the options or from `createComputedStoreSlot(db, derive?, createSources?)`, which keeps
1974
+ them on the store slot (`TOIMComputedStoreSlot.derive` / `.createSources`); a store slot that
1975
+ ends up with neither of a pair throws at creation. `createOIMComputedService` takes no `db`:
1976
+ the store slot is what is created on one. A
1977
+ derivation slot takes a counted hold on each source it named
1978
+ (its `sourceSlots` and its `sourceComputeds`) and gives them back when it
1979
+ goes, so a chain keeps itself one level at a time. The
1980
+ holds are the owner's, taken when it mints the slot. A chain
1981
+ short-circuits: `filter → sort → page` over quiet roots is a walk of integer
1982
+ comparisons, and a chain whose roots changed derives only the levels whose
1983
+ inputs actually changed.
1984
+
1985
+ **A source computed is a slot taken from its own service.** Every source list in
1986
+ this engine (`createEffect`'s second argument, and `sourceComputeds` in
1987
+ `createSources`) is typed `readonly TOIMComputedSlot[]`, so a typed slot goes in as
1988
+ it is. Inside `derive`, `computedSlot.value` is the previous value:
1519
1989
 
1520
- **Constructor:**
1521
1990
  ```typescript
1522
- new OIMReactiveIndexManualArrayBased(queue: OIMEventQueue, opts?: {
1523
- indexOptions?: {
1524
- comparePks?: TOIMIndexComparator<TPk>;
1525
- store?: OIMIndexStoreArrayBased<TKey, TPk>;
1526
- }
1527
- })
1991
+ effectService.createEffect(db, [], [totalSlot], () => { console.log(totalSlot.computedValueService.getValueForSlot(totalSlot)); });
1528
1992
  ```
1529
1993
 
1530
- **Properties:**
1531
- - `index: OIMIndexManualArrayBased<TKey, TPk>` - Underlying Array-based index
1532
- - `updateEventEmitter: OIMUpdateEventEmitter<TKey>` - Key-specific subscriptions
1533
-
1534
- **Methods:**
1535
- - `setSlots(key: TKey, slots: TOIMAnyEntitySlot<TPk>[]): void` - Set canonical slots directly
1536
- - `clear(key?: TKey): void` - Clear all keys or specific key
1537
-
1538
- **Query:**
1539
- - `index.getPksByKey(key: TKey): TPk[]` - Returns Array projection of primary keys
1540
- - `index.getSlotsByKey(key: TKey): readonly TOIMAnyEntitySlot<TPk>[]` - Returns stored slots for fast entity reads
1994
+ ### One derivation per key
1541
1995
 
1542
- **Note**: While `addPks`/`removePks` are available, they require array operations (Set creation, filtering) making them O(n) compared to O(1) for SetBased indexes. For ArrayBased indexes, prefer `setPks` for better performance when replacing the entire array.
1543
-
1544
- #### `OIMDerivedCollectionIndexArrayBased<TKey, TPk, TEntity>`
1545
- Collection-bound Array-based index that derives ordered membership from collection entities. Use this for UI lists where entities define both grouping and order.
1546
-
1547
- **Constructor:**
1548
- ```typescript
1549
- new OIMDerivedCollectionIndexArrayBased(queue, collection, {
1550
- selectIndexKeys: (entity: TEntity) => TKey | readonly TKey[] | undefined | null;
1551
- buildInitial?: boolean; // defaults to true
1552
- orderBy?: (entity: TEntity) => string | number | bigint | boolean;
1553
- compareEntities?: (a: TEntity, b: TEntity) => number;
1554
- indexOptions?: {
1555
- comparePks?: TOIMIndexComparator<TPk>;
1556
- store?: OIMIndexStoreArrayBased<TKey, TPk>;
1557
- };
1558
- })
1996
+ ```typescript setup
1997
+ const ranksService = createOIMComputedService<TOIMStoreKey, number>({
1998
+ createSources: (id) => ({ sourceSlots: [cardService.getSlotByKey(cards, id)] }),
1999
+ derive: (computedSlot) => cardService.getSlotByKey(cards, computedSlot.key).entity?.rank ?? -1,
2000
+ sameValueGuard: oimSameValueZeroGuard,
2001
+ initialValue: -1,
2002
+ });
2003
+ const ranks = ranksService.createComputedStoreSlot(db);
2004
+ const rankCollectionService = ranksService.computedCollectionService;
1559
2005
  ```
1560
2006
 
1561
- `compareEntities` takes priority over `orderBy`. Without either option, arrays keep collection slot iteration order.
1562
-
1563
- **Methods:**
1564
- - `rebuildFromCollection(): void` - Rebuild all derived ordered membership from current collection slots
1565
- - all read/subscription methods from `OIMReactiveCollectionIndexManualArrayBased`
1566
-
1567
- #### `OIMReactiveCollectionIndexManualArrayBased<TKey, TPk, TEntity>`
1568
- Collection-bound Array-based index. Use this when ordered PK arrays should resolve to canonical slots from a collection.
1569
-
1570
- **Constructor:**
1571
2007
  ```typescript
1572
- new OIMReactiveCollectionIndexManualArrayBased(queue: OIMEventQueue, opts: {
1573
- collection: OIMReactiveCollection<TEntity, TPk>;
1574
- indexOptions?: {
1575
- comparePks?: TOIMIndexComparator<TPk>;
1576
- store?: OIMIndexStoreArrayBased<TKey, TPk>;
1577
- };
1578
- } | {
1579
- getSlot: TOIMEntitySlotGetter<TPk>;
1580
- indexOptions?: {
1581
- comparePks?: TOIMIndexComparator<TPk>;
1582
- store?: OIMIndexStoreArrayBased<TKey, TPk>;
1583
- };
1584
- })
2008
+ const rank = rankCollectionService.holdSlotByKey(ranks, 'c1'); // minted once, same object while held
2009
+ rank.computedValueService.getValueForSlot(rank); // settles, then answers
2010
+ rankCollectionService.getSlotByKey(ranks, 'c9'); // minted if 'c9' had none; the collector tracks the mint
2011
+ rank.owner.unholdSlot(rank);
1585
2012
  ```
1586
2013
 
1587
- Pass exactly one binding: `collection` for normal collection-bound indexes, or `getSlot` for custom slot lookup.
1588
-
1589
- **Methods:**
1590
- - `setPks(key: TKey, pks: readonly TPk[]): void` - Set primary keys for index key
1591
- - `addPks(key: TKey, pks: readonly TPk[]): void` - Add primary keys to index key
1592
- - `removePks(key: TKey, pks: readonly TPk[]): void` - Remove primary keys from index key
1593
- - `setSlots(key: TKey, slots: TOIMAnyEntitySlot<TPk>[]): void` - Set canonical slots directly
1594
- - `clear(key?: TKey): void` - Clear all keys or specific key
1595
-
1596
- #### `OIMIndexManualOrderedArrayBased<TKey, TPk>`
1597
- Slot-first manual ordered Array-based index with direct list operations and update events. Use this when you need low-level ordered storage without a command stream.
2014
+ **`createSources` takes the key and the store slot, and nothing else. There is no `keep`.** The holds a filed
2015
+ derivation carries are the ones it NAMED. The service's mint
2016
+ holds every entry of `sourceSlots` and `sourceComputeds`, and
2017
+ the content strategy's `releaseSlotContent` gives all of them back. So the source list IS the dependency
2018
+ list, and a source that a build closes over without naming is a source that nothing holds.
2019
+
2020
+ A derivation is never empty: `OIMSlotContentComputedStrategy.isSlotEmpty` answers `false`,
2021
+ so a filed derivation goes only when nothing holds it,
2022
+ nothing watches it, and the grace is `0`. That is what
2023
+ `OIMSlotReleasePolicyAtUnhold.INSTANCE` answers, and it is the default when no `slotReleasePolicy` is given.
2024
+ A derivation let go of while the channel is flushing is not released on the spot. It is
2025
+ handed to the channel's unused-slot collector and released when the flush ends, the same
2026
+ deferral that every other non-empty slot gets.
2027
+ `createOIMComputedService` takes a `slotReleasePolicy` as an optional key of its options object.
2028
+
2029
+ `createOIMComputedService` is `OIMComputedService` with its generics
2030
+ inferred and its computed slot store service filled with a map-driven one unless you pass
2031
+ `computedSlotStoreService`, which a `void` key requires (`OIMSlotStoreServiceSingleSlotDriven`); both are exported.
2032
+
2033
+ ### Computed columns
2034
+
2035
+ A computed's value can be subscribed to by field. On the `computedCollectionService` of
2036
+ `OIMComputedService` (and of `OIMComputedServiceSorted` and `OIMComputedServiceBucketMemo`),
2037
+ keyed or single, where a single takes `getSlotByKey(storeSlot, undefined)` as the slot, sit `getColumn(slot, columnName)`, `holdColumn(slot, columnName)`,
2038
+ `getColumnGroup(names)`, `holdColumnGroup(slot, group)`, `unholdColumn(columnSlot)` and the any-column
2039
+ pair `getAnyColumn(slot)` / `holdAnyColumn(slot)`. Column
2040
+ names are typed as the keys of the value.
2041
+
2042
+ Each column slot is a computed slot over the carrier computed, its value `carrier.value[column]` (a tuple of
2043
+ the group's fields for a group, ordered by the sorted column names; the whole value for the any-column), compared with `Object.is`. It is a
2044
+ `TOIMComputedColumnSlot` (`TOIMComputedSlot<unknown, string>`), so it goes in the SECOND array of
2045
+ `createEffect`, and it wakes an effect only when that field of the derived value changes.
1598
2046
 
1599
- **Constructor:**
1600
2047
  ```typescript
1601
- new OIMIndexManualOrderedArrayBased<TKey, TPk>()
2048
+ type TCardSummary = { top: number; count: number };
2049
+ const cardSummaryService = createOIMComputedService<void, TCardSummary, TOIMSingleSlotStore<TOIMComputedSlot<TCardSummary, void>>>({
2050
+ computedSlotStoreService: new OIMSlotStoreServiceSingleSlotDriven<TOIMComputedSlot<TCardSummary, void>>(),
2051
+ });
2052
+ const cardSummary = cardSummaryService.createComputedStoreSlot(
2053
+ db,
2054
+ () => ({ top: slot.entity?.rank ?? -1, count: cardService.getSlotCount(cards) }),
2055
+ () => ({ sourceSlots: [slot] }),
2056
+ );
2057
+ const summaryCollectionService = cardSummaryService.computedCollectionService;
2058
+ const topColumn = summaryCollectionService.holdColumn(summaryCollectionService.getSlotByKey(cardSummary, undefined), 'top');
2059
+ const onTop = effectService.createEffect(db, [], [topColumn], () => { console.log(topColumn.value); });
2060
+ effectService.disposeEffect(onTop);
2061
+ summaryCollectionService.unholdColumn(topColumn);
1602
2062
  ```
1603
2063
 
1604
- **Methods:**
1605
- - `pushSlot(key: TKey, slot: TOIMAnyEntitySlot<TPk>): number` - Append a slot and return its inserted index
1606
- - `insertSlotAt(key: TKey, index: number, slot: TOIMAnyEntitySlot<TPk>): number` - Insert a slot at an index, clamped to the list bounds
1607
- - `removeAt(key: TKey, index: number): TOIMAnyEntitySlot<TPk> | undefined` - Remove and return the slot at an index
1608
- - `move(key: TKey, fromIndex: number, toIndex: number): TOIMAnyEntitySlot<TPk> | undefined` - Move a slot within the list
1609
- - `resetSlots(key: TKey, slots: readonly TOIMAnyEntitySlot<TPk>[]): void` - Replace the whole ordered slot list for a key
1610
- - `clear(key?: TKey): void` - Clear all keys or a specific key
2064
+ ### The order of a bucket is one of those derivations
1611
2065
 
1612
- **Query:**
1613
- - `getPksByKey(key: TKey): TPk[]` - Returns the ordered primary-key array
1614
- - `getSlotsByKey(key: TKey): readonly TOIMAnyEntitySlot<TPk>[]` - Returns the stored ordered slots
1615
- - `getEntitiesByKey<TEntity>(key: TKey): (TEntity | undefined)[]` - Returns entities from stored slots, aligned 1:1 with the pks (holes are `undefined`)
1616
-
1617
- #### `OIMCollectionIndexManualOrderedArrayBased<TKey, TPk, TEntity>`
1618
- Collection-bound ordered Array-based index. Use this when ordered PK writes should resolve to canonical slots from a collection.
1619
-
1620
- **Methods:**
1621
- - `push(key: TKey, pk: TPk): number` - Append a PK as its canonical slot
1622
- - `insertAt(key: TKey, index: number, pk: TPk): number` - Insert a PK as its canonical slot
1623
- - `reset(key: TKey, pks: readonly TPk[]): void` - Replace the whole ordered list from PKs
1624
- - `getEntitiesByKey(key: TKey): (TEntity | undefined)[]` - Returns entities from canonical slots (holes are `undefined`)
1625
-
1626
- #### `OIMOrderedListCommandStream<TKey, TPk, TEntity>`
1627
- Slot-first ordered per-key list with a command stream for imperative consumers. It stores data in an `OIMIndexManualOrderedArrayBased` and emits position-addressed `TOIMOrderedListCommand<Slot>` batches through `commandsEventEmitter`. Each command's `item` is the entity slot (read the entity via `item.item`):
2066
+ Subscribe to the DERIVATION instead of the bucket when a consumer wants the
2067
+ list as an array it can compare by identity:
1628
2068
 
1629
2069
  ```typescript
1630
- type TOIMOrderedListCommand<TItem> =
1631
- | { type: 'insert'; index: number; item: TItem }
1632
- | { type: 'remove'; index: number; count?: number } // count may be > 1
1633
- | { type: 'move'; from: number; to: number; count?: number }
1634
- | { type: 'set'; index: number; item: TItem } // replace one element
1635
- | { type: 'reset'; items: readonly TItem[] }; // replace whole list
1636
- ```
2070
+ const orderService = createOIMComputedServiceBucketMemo({
2071
+ bucketCollectionService: orderedBucketsService,
2072
+ });
2073
+ const order = orderService.createBucketMemoStoreSlot(orderedBuckets);
2074
+ const render = (slots: readonly CardSlot[]): void => {};
1637
2075
 
1638
- **Constructor:**
1639
- ```typescript
1640
- new OIMOrderedListCommandStream(
1641
- queue: OIMEventQueue,
1642
- index?: OIMIndexManualOrderedArrayBased<TKey, TPk>
1643
- )
2076
+ const deck = orderService.computedCollectionService.holdSlotByKey(order, 'spades'); // mints and holds the bucket too
2077
+ effectService.createEffect(db, [], [deck], () => render(deck.computedValueService.getValueForSlot(deck)));
1644
2078
  ```
1645
2079
 
1646
- **Properties:**
1647
- - `index: OIMIndexManualOrderedArrayBased<TKey, TPk>` - Underlying ordered index and source of truth
1648
- - `commandsEventEmitter: OIMUpdateEventEmitter<TKey>` - Key-specific command notifications delivered after queue flush
1649
-
1650
- **Methods:** (`Slot` = `TOIMEntitySlot<TEntity, TPk>`)
1651
- - `setSlots(key: TKey, slots: readonly Slot[]): void` - Replace the whole ordered list and emit a `reset` command
1652
- - `pushSlot(key: TKey, slot: Slot): void` - Append a slot and emit an `insert` command
1653
- - `insertSlotAt(key: TKey, index: number, slot: Slot): void` - Insert a slot and emit an `insert` command
1654
- - `setSlotAt(key: TKey, index: number, slot: Slot): void` - Replace the slot at `index` in place and emit a `set` command
1655
- - `removeAt(key: TKey, index: number): void` - Remove by index and emit a `remove` command
1656
- - `removeRange(key: TKey, index: number, count: number): void` - Remove `count` consecutive elements from `index` and emit a `remove` command with `count`
1657
- - `move(key: TKey, fromIndex: number, toIndex: number): void` - Move within the list and emit a `move` command
1658
- - `moveRange(key: TKey, from: number, to: number, count: number): void` - Move `count` consecutive elements and emit a `move` command with `count` (`to` is post-extraction space)
1659
- - `consumeCommands(key: TKey): TOIMOrderedListCommand<Slot>[]` - Read buffered commands for a key inside the notification handler
1660
- - `getBufferedCommands(key: TKey): readonly TOIMOrderedListCommand<Slot>[]` - Peek at buffered commands without clearing them
1661
- - `getPksByKey(key: TKey): readonly TPk[]` - Read the current ordered list
1662
- - `getSlotsByKey(key: TKey): readonly TOIMEntitySlot<TEntity, TPk>[]` - Read current ordered slots
1663
- - `getEntitiesByKey(key: TKey): (TEntity | undefined)[]` - Read current ordered entities (holes are `undefined`)
1664
- - `clear(key?: TKey): void` - Clear all keys or a specific key
1665
- - `destroy(): void` - Dispose subscriptions and clear state
1666
-
1667
- #### `OIMCollectionOrderedListCommandStream<TKey, TPk, TEntity>`
1668
- Collection-bound ordered-list command stream. Public writes use PKs that resolve to canonical collection slots; emitted commands carry the slot as `item` (entity via `item.item`).
1669
-
1670
- **Methods:**
1671
- - `set(key: TKey, pks: readonly TPk[]): void` - Replace the whole ordered list from PKs (emits `reset`)
1672
- - `setAt(key: TKey, index: number, pk: TPk): void` - Replace the element at `index` with the slot for `pk` (emits `set`)
1673
- - `push(key: TKey, pk: TPk): void` - Append a PK
1674
- - `insertAt(key: TKey, index: number, pk: TPk): void` - Insert a PK
1675
- - all read/command methods from `OIMOrderedListCommandStream`
1676
-
1677
- #### `OIMEventQueue`
1678
- Event processing queue with configurable scheduling.
1679
-
1680
- **Constructor:**
1681
- ```typescript
1682
- new OIMEventQueue(options?: TOIMEventQueueOptions)
1683
- ```
2080
+ Its value is the bucket's slots in the bucket's order, produced on the read that
2081
+ asks for it and never while nothing reads it. Its `changedAtRevision` and `changedAtEpoch` move only when
2082
+ the list a reader would see has actually changed. The array comes back by
2083
+ IDENTITY, unchanged for as long as the list holds, so a consumer that memoises on
2084
+ identity needs no comparison of its own.
1684
2085
 
1685
- **Properties:**
1686
- - `length: number` - Number of queued functions
1687
- - `isEmpty: boolean` - Whether queue is empty
2086
+ The source list is ONE slot, the bucket, and it never grows: a change to its slots
2087
+ is a move of the bucket's value, not of the derivation's sources.
1688
2088
 
1689
- **Methods:**
1690
- - `enqueue(fn: () => void): void` - Add function to queue
1691
- - `flush(): void` - Execute all queued functions
1692
- - `clear(): void` - Clear queue without executing
1693
- - `destroy(): void` - Clean up scheduler subscriptions
2089
+ A sorted list is the other derivation of this kind:
2090
+ [`createOIMComputedServiceSorted`](#a-sorted-list-is-a-computed-over-an-index) over an index.
1694
2091
 
1695
- ### Schedulers
2092
+ ---
1696
2093
 
1697
- #### `OIMEventQueueSchedulerFactory`
1698
- Factory for creating different scheduler types:
2094
+ ## Standalone records
1699
2095
 
1700
2096
  ```typescript
1701
- import { TOIMSchedulerType } from '@oimdb/core';
2097
+ type Settings = { theme: string; fontSize: number };
1702
2098
 
1703
- // Available scheduler types
1704
- type TOIMSchedulerType = 'immediate' | 'microtask' | 'timeout' | 'animationFrame';
1705
- ```
1706
-
1707
- **Static Methods:**
1708
- - `create(type: 'microtask'): OIMEventQueueSchedulerMicrotask`
1709
- - `create(type: 'animationFrame'): OIMEventQueueSchedulerAnimationFrame`
1710
- - `create(type: 'timeout', options?: { delay: number }): OIMEventQueueSchedulerTimeout`
1711
- - `create(type: 'immediate'): OIMEventQueueSchedulerImmediate`
1712
- - `createMicrotask(): OIMEventQueueSchedulerMicrotask`
1713
- - `createAnimationFrame(): OIMEventQueueSchedulerAnimationFrame`
1714
- - `createTimeout(delay?: number): OIMEventQueueSchedulerTimeout`
1715
- - `createImmediate(): OIMEventQueueSchedulerImmediate`
1716
-
1717
- ### Types
1718
-
1719
- #### `TOIMCollectionOptions<TEntity, TPk>`
1720
- Collection configuration options:
1721
- - `selectPk?: TOIMPkSelector<TEntity, TPk>` - Primary key selector function
1722
- - `store?: OIMCollectionStore<TEntity, TPk>` - Storage backend
1723
- - `updateEntity?: TOIMEntityUpdater<TEntity>` - Entity update strategy
1724
-
1725
- #### `TOIMEntityUpdater<TEntity>`
1726
- Entity update function signature:
1727
- ```typescript
1728
- (newEntity: TEntity, oldEntity: TEntity) => TEntity
1729
- ```
1730
-
1731
- #### `TOIMSchedulerType`
1732
- Available scheduler types:
1733
- ```typescript
1734
- 'microtask' | 'animationFrame' | 'timeout' | 'immediate'
2099
+ const settingsService = createOIMSingleEntityService<Settings>(db, {
2100
+ columnNames: ['theme', 'fontSize'],
2101
+ });
2102
+ const settings = settingsService.createStoreSlot(db);
2103
+ const settingsContentService = settingsService.slotContentService;
2104
+ settingsContentService.upsertEntity(settings, { theme: 'dark', fontSize: 14 });
2105
+ const settingsSlot = settingsService.holdSingleSlot(settings);
2106
+
2107
+ effectService.createEffect(db, [settingsSlot], [], () => { console.log(settingsSlot.entity?.theme); });
2108
+ effectService.createEffect(db, [settingsService.holdColumn(settingsSlot, 'theme')], [], () => {});
2109
+
2110
+ settingsContentService.updateEntity(settingsSlot, { theme: 'light' });
2111
+ settingsContentService.deleteEntityColumns(settingsSlot, ['fontSize']); // the thing a patch cannot say
2112
+ settingsContentService.writeEntity(settingsSlot, { theme: 'dark', fontSize: 16 }); // columns it had and this does not, go
2113
+ db.flush();
1735
2114
  ```
1736
2115
 
1737
- #### `TOIMEventQueueOptions`
1738
- Event queue configuration:
1739
- - `scheduler?: OIMEventQueueScheduler` - Optional scheduler for automatic flushing
1740
-
1741
- ## 🧪 Testing
2116
+ A standalone record is a single service: `createOIMSingleEntityService` is
2117
+ `createOIMSingleSlotService` over `createOIMSlotContentEntityService`, with the store fixed to
2118
+ `OIMSlotStoreServiceSingleSlotDriven` and no `keyOf`. Every option is optional:
2119
+ `entityWriter` and `slotEmptiness` fall back to `OIMDefaults`, and `columnNames` names the
2120
+ columns and files their slots in the ordinal store. The entity is
2121
+ written through `slotContentService.upsertEntity(settings, entity)`, an ordinary write announced
2122
+ to whoever subscribed, not handed to the options or to `createStoreSlot(db)`, which mints
2123
+ nothing. The column doors sit on the single service itself: `getColumn(slot, columnName)`,
2124
+ `holdColumn(slot, columnName)`, `getColumnGroup(names)`, `holdColumnGroup(slot, group)`,
2125
+ `getAnyColumn(slot)`, `holdAnyColumn(slot)` and `unholdColumn(columnSlot)`, with the single slot
2126
+ as the slot. It has at most one slot, read through the single doors: `getSingleSlot(settings)`,
2127
+ `holdSingleSlot(settings)`, `findSingleSlot(settings)` (no mint) and
2128
+ `hasSingleSlotContent(settings)`. Its slot is released like any slot, under its release policy
2129
+ (by default a slot with a value stays), and the next `getSingleSlot` mints a fresh one. It
2130
+ always reports.
2131
+
2132
+ ---
2133
+
2134
+ ## Caching what a consumer derives from a slot
2135
+
2136
+ Keep your own `Map` or `WeakMap`, or use `TOIMEntitySlot.data`
2137
+ (`unknown[] | undefined`). It belongs to you, and the core never reads or writes
2138
+ it. Clearing or removing the slot (`slotContentService.deleteEntity(slot)`, `slotContentService.deleteEntities(storeSlot, …)`)
2139
+ does not reset it either, so clear what you put there yourself. Each index keeps its last key for
2140
+ the entity in a separate field, `indexKeys`, which is read and written only through the
2141
+ index's `IOIMIndexKeysService` (`get` / `set`).
2142
+
2143
+ ---
2144
+
2145
+ ## Giving handles back
2146
+
2147
+ There is no scope object, so keep an array. An effect carries no disposer, and
2148
+ `unholdSlot` is a method on the slot's owner (`entitySlot.owner.unholdSlot`), so you wrap both in a
2149
+ function of your own.
1742
2150
 
1743
2151
  ```typescript
1744
- import {
1745
- OIMReactiveCollection,
1746
- OIMEventQueue,
1747
- OIMEventQueueSchedulerFactory
1748
- } from '@oimdb/core';
2152
+ const body = (): void => {};
2153
+ const entitySlot = cardService.holdSlotByKey(cards, 'c1');
1749
2154
 
1750
- describe('OIMReactiveCollection', () => {
1751
- let users: OIMReactiveCollection<User, string>;
1752
- let queue: OIMEventQueue;
1753
-
1754
- beforeEach(() => {
1755
- queue = new OIMEventQueue({
1756
- scheduler: OIMEventQueueSchedulerFactory.createMicrotask()
1757
- });
1758
-
1759
- users = new OIMReactiveCollection(queue, {
1760
- selectPk: (user) => user.id
1761
- });
1762
- });
1763
-
1764
- it('should upsert and retrieve entities', () => {
1765
- const user = { id: 'user1', name: 'John', email: 'john@example.com' };
1766
- users.upsertOne(user);
1767
-
1768
- expect(users.getOneByPk('user1')).toEqual(user);
1769
- });
1770
-
1771
- it('should notify subscribers of changes', (done) => {
1772
- users.updateEventEmitter.subscribeOnKey('user1', () => {
1773
- done(); // Test passes when callback is called
1774
- });
1775
-
1776
- users.upsertOne({ id: 'user1', name: 'John' });
1777
- queue.flush(); // Trigger immediate flush for testing
1778
- });
1779
- });
2155
+ const disposers: TOIMDisposer[] = [];
2156
+ disposers.push(effectService.subscribeEffect(db, [entitySlot], [], body));
2157
+ disposers.push(() => { entitySlot.owner.unholdSlot(entitySlot); });
2158
+ for (let i = disposers.length - 1; i >= 0; i--) disposers[i]();
1780
2159
  ```
1781
2160
 
1782
- ## 🤝 Contributing
1783
-
1784
- This package is part of the OIMDB ecosystem. See the main project repository for contribution guidelines.
1785
-
1786
- ## 📄 License
1787
-
1788
- MIT License - see LICENSE file for details.
2161
+ By keeping the array yourself, you take on running the disposers in reverse order and making sure
2162
+ a throwing disposer does not stop the rest. The engine ships no scope to do that for you.
2163
+
2164
+ ---
2165
+
2166
+ ## Export reference
2167
+
2168
+ **200 names. This list is `src/index.ts`; nothing else is exported.**
2169
+
2170
+ **The channel slot** — `OIMChannelService`, `TOIMChannelSlot`, `TOIMChannelSlotOptions`, `createOIMChannelSlot`,
2171
+ `IOIMChannelMap`, `TOIMChannelFrame`, `TOIMChannelClock`, `TOIMChannelScheduler`, `OIMChannelQueue` (**type only**),
2172
+ `oimIsAnySlotChangedAfter` (whether any of the slots changed after a given revision and epoch).
2173
+
2174
+ **Scheduling and errors** — `OIMFlushSchedulerService`, `TOIMFlushSchedulerSlot`, `TOIMTimer`, `TOIMDisposer`,
2175
+ `IOIMErrorPolicy`, `OIMErrorPolicyLogAndContinue`, `TOIMErrorPolicyLogAndContinueOptions`, `OIMErrorPolicyThrowAtOnce`.
2176
+
2177
+ **Release policies and collectors** — `IOIMSlotReleasePolicy`,
2178
+ `OIMSlotReleasePolicyAfterWrite`, `TOIMSlotReleasePolicyAfterWriteOptions`,
2179
+ `OIMSlotReleasePolicyAfterIdle`, `TOIMSlotReleasePolicyAfterIdleOptions`,
2180
+ `OIMSlotReleasePolicyAfterUnhold`, `TOIMSlotReleasePolicyAfterUnholdOptions`,
2181
+ `OIMSlotReleasePolicyAtUnhold`, `OIMSlotReleasePolicyAtEmpty`, `OIMSlotReleasePolicyNever`, `OIMSlotReleasePolicyEarliest`,
2182
+ `OIMUnusedSlotCollectorService`, `TOIMUnusedSlotCollectorSlot`, `createOIMUnusedSlotCollectorSlot`,
2183
+ `TOIMCollectorSlot`, `TOIMCollectorSlotOptions`, `createOIMCollectorSlot`,
2184
+ `OIMReleaseSlotCollectorService`, `OIMReleaseSlotCollectorServiceAfterIdle`, `OIMReleaseSlotCollectorServiceAfterUnhold`,
2185
+ `OIMReleaseSlotCollectorServiceAfterWrite`, `OIMReleaseSlotQueueService`, `TOIMReleaseSlotCollectorSlot`,
2186
+ `createOIMReleaseSlotCollectorSlot`, `oimSliceOfMs`, `OIM_SLICE_LENGTH_MS`.
2187
+
2188
+ **Slot services** — `OIMSlotColumnService` (the column doors both slot services inherit), `OIMSlotCollectionService`, `TOIMSlotCollectionServiceOptions`, `createOIMSlotCollectionService`,
2189
+ `OIMSingleSlotService`, `TOIMSingleSlotServiceOptions`, `createOIMSingleSlotService`, `OIMSlotOwnerService` (**type only**),
2190
+ `TOIMSlotOwnerServiceOptions`, `IOIMSlotContentStrategy`, `IOIMSlotContentFlushStrategy`, `IOIMSubframeCloseListener`,
2191
+ `OIMContentChangeListenerService`, `IOIMContentChangeListener`, `IOIMStoreSlotListener`, `OIMStoreSlotListenerNone`.
2192
+
2193
+ **Slots and their owners** — `TOIMSlot`, `TOIMStoreSlot`, `TOIMSingleSlotStoreSlot`, `TOIMSlotEditable`, `TOIMKeyedSlot`,
2194
+ `TOIMIndexableSlot`, `OIMSlotOwner`, `IOIMSlotOwner`, `IOIMSlotEmptiness`, `IOIMSlotStamp`, `TOIMSameValueGuard`,
2195
+ `oimSameValueZeroGuard`, `oimIsSlotEmpty`, `oimIsSlotReleased`, `oimHoldSlots`, `oimUnholdSlotsIfAlive`,
2196
+ `OIM_FROZEN_EMPTY_SLOTS`.
2197
+
2198
+ **Columns** — `TOIMColumnSlot`, `TOIMComputedColumnSlot`, `TOIMColumnGroup`, `TOIMColumnReaders`,
2199
+ `OIMColumnOwner`, `OIMColumnOwnerPushDriven` (**type only**), `OIMColumnOwnerPullDriven`, `TOIMColumnOwnerOptions`.
2200
+
2201
+ **Entities** — `OIMSlotContentEntityService`, `TOIMSlotContentEntityServiceOptions`, `TOIMEntitySlotOwnerService`,
2202
+ `TOIMEntitySlot`, `createOIMSlotContentEntityService`, `TOIMSlotContentEntityServiceFactoryOptions`, and the convenience wrappers `createOIMEntityService` and
2203
+ `createOIMSingleEntityService`; `IOIMEntityWriter`, `createOIMEntityWriterInPlace`,
2204
+ `createOIMEntityWriterReplacing`, `TOIMEntityWriterOptions`, `TOIMEntityInsertStrategy`,
2205
+ `oimInsertEntityWithCopy`, `oimInsertEntityWithoutCopy`, `oimInsertEntityWithSanitizedCopy`, `oimPatchEntityColumns`,
2206
+ `oimPatchEntityColumn`, `oimPatchEntityColumnsGuarded`, `oimPatchEntityColumnGuarded`, `oimPutEntityColumns`,
2207
+ `oimPutEntityColumnsGuarded`, `TOIMEntityColumnName`, `TOIMEntityColumnValue`.
2208
+
2209
+ **Buckets** — `OIMSlotContentBucketService`, `OIMSlotContentBucketServiceArrayBased`,
2210
+ `OIMSlotContentBucketServiceArrayDriven`, `OIMSlotContentBucketServiceSetDriven`,
2211
+ `OIMSlotContentBucketServiceSortedArrayDriven`, `TOIMSlotContentBucketServiceOptions`,
2212
+ `TOIMSlotContentBucketServiceSortedArrayDrivenOptions`, `TOIMBucketSlot`, `TOIMBucketSlotOwnerService`,
2213
+ the convenience wrappers `createOIMBucketService` and `createOIMSingleBucketService`,
2214
+ `TOIMBucketCollectionFactoryArguments`, `OIMBucketCommands`, `TOIMBucketCommandsOptions`, `IOIMBucketCommands`,
2215
+ `IOIMBucketArrayMembership`, `OIMBucketArrayMembershipNonUnique`, `OIMBucketArrayMembershipUnique`,
2216
+ `TOIMBucketSlotsSetStore`, `TOIMBucketSlotsArrayStore`, `TOIMBucketSlotsSortedArrayStore`, `TOIMBucketSlotsArrayUniqueStore`,
2217
+ `OIM_NOTHING_WAS_INSERTED`, `OIM_NOTHING_WAS_REMOVED`, `OIM_REMOVED_WITHOUT_A_POSITION`.
2218
+
2219
+ **Indexes** — `OIMIndexService`, `OIMIndexServiceSortedArrayDriven`, `TOIMIndexServiceOptions`, `TOIMIndexStoreSlot`,
2220
+ `TOIMIndexServiceStoreSlot`, `TOIMIndexServiceFactoryOptions`, `createOIMIndexService`, `createOIMIndexServiceByCompositeKey`,
2221
+ `createOIMIndexServiceSortedArrayDriven`, `TOIMIndexKeyOptions`, `TOIMIndexKey`,
2222
+ `IOIMIndexKeysService`, `TOIMIndexKeysPositions`, `OIMIndexKeysService`, `OIMIndexKeysServiceAppending`,
2223
+ `OIMIndexKeysServiceReclaiming`.
2224
+
2225
+ **The graph** — `OIMEffectService`, `IOIMEffectService`, `TOIMEffect`, `TOIMEffectBody`, `TOIMEffectBodyWithSourceTracking`,
2226
+ `OIMEffectSourceTracker` (**type only**), `TOIMComputedSlot`, `TOIMComputedSources`, `TOIMDerivation`, `TOIMComputedStoreSlot`,
2227
+ `OIMComputedValueService`, `IOIMComputedValueService`, `TOIMComputedValueServiceOptions`, `oimRefreshSourceComputeds` (brings every computed in a `sourceComputeds` list current; call it before a `derive` that runs outside an effect, as `useOIMValue` does).
2228
+
2229
+ **Computed** — `OIMComputedService`, `TOIMComputedServiceOptions`, `createOIMComputedService`,
2230
+ `OIMComputedServiceSorted`, `TOIMComputedServiceSortedOptions`, `createOIMComputedServiceSorted`, `TOIMSortedComputedSlot`,
2231
+ `OIMComputedServiceBucketMemo`, `TOIMComputedServiceBucketMemoOptions`, `createOIMComputedServiceBucketMemo`,
2232
+ `TOIMBucketMemoComputedSlot`, `TOIMSortColumn`, `TOIMSortableColumnName`, `oimColumnsComparator`.
2233
+
2234
+ **Stores and keys** — `TOIMStoreKey`, `IOIMSlotStoreService`,
2235
+ `OIMSlotStoreServiceMapDriven`, `OIMSlotStoreServiceTreeDriven`,
2236
+ `OIMSlotStoreServiceSingleSlotDriven`, `TOIMSingleSlotStore`, `TOIMDefaultSlotStore`, `TOIMSlotStoreTree`, `IOIMKeyTreeWalk`, `TOIMCompositeKeyNode`,
2237
+ `OIMKeyTreeWalk`, `OIMKeyTreeWalkComposite`, `OIMKeyTreeWalkScalar`.
2238
+
2239
+ **The root and its defaults** — `OIMDB`, `TOIMDBOptions`, `OIMDefaults`, `OIMDefaultValue`, `TOIMFactoryOptions`.
2240
+
2241
+ **Comparison and diffing** — `oimSameSlotSequence`, `oimSameKeySequence`, `oimOrderedListCommandsOfTransition`,
2242
+ `TOIMOrderedListCommand`.
2243
+
2244
+ **Shared stateless defaults** — the statics of `OIMDefaults`: `COLUMN_SLOT_STORE_SERVICE`,
2245
+ `COMPUTED_COLUMN_SLOT_STORE_SERVICE`, `KEY_TREE_WALK_SCALAR`, `KEY_TREE_WALK_COMPOSITE`,
2246
+ `STANDALONE_SLOT_OWNER`, `ENTITY_WRITER`, `ENTITY_SLOT_EMPTINESS`, `INDEX_KEYS_SERVICE`,
2247
+ `STORE_SLOT_LISTENER`. Each is minted on its first `getValue()` instead of at module load, so a
2248
+ consumer that names none of them constructs none of them. The cost of the single door is described under **Every optional
2249
+ dependency has a default** above.
2250
+
2251
+ That is the whole of `src/index.ts`. If a name is not on this list, it is not
2252
+ exported.
2253
+
2254
+ ---
2255
+
2256
+ ## Performance
2257
+
2258
+ **The only figures quoted in this document are the memory ones below.** Timings are on the
2259
+ [benchmark pages](https://oimdb.org/docs/benchmarks/results).
2260
+
2261
+ **Memory.** Measured with `npm run retained` in `packages/bench` on 2026-10-22, using node
2262
+ v24.16.0 on an Apple M1 with 8 cores. Each figure is over 100,000 rows: the heap difference
2263
+ between processes built with and without the mechanism, divided by the row count. A reference
2264
+ measured 8.00 B in the same run, and the noise floor was 64 B.
2265
+
2266
+ | what is held | per | bytes |
2267
+ |---|---|---:|
2268
+ | a settled, held derivation (`OIMComputedService`, fan-in 3, value a number) | derivation | 333.06 B |
2269
+ | per-source readings for the same derivation, the per-slot clock arm that does not ship (modelled) | derivation | 79.95 B |
2270
+
2271
+ A derivation with a larger fan-in, or one whose value is a list, holds more. The per-store
2272
+ heap for every participant is in [What each store holds](https://oimdb.org/docs/benchmarks/results#what-each-store-holds).
2273
+
2274
+ The measurement work in `packages/bench` aims at one claim: **for every
2275
+ typical task there is a combination of shipped strategies whose cost is at or
2276
+ below the hand-written floor**, on the same tasks that every framework in
2277
+ `packages/bench` is measured on. A ratio above 1.00 counts as a loss only when no
2278
+ strategy combination reaches the floor.
2279
+
2280
+ **Fast properties are checked, not timed.** Every slot kind is one object literal
2281
+ with every field present at birth, per-index state lives in the `indexKeys`
2282
+ array, nothing adds a field to a slot later or deletes one, and neither entity
2283
+ writer `delete`s a column; each writes `undefined` instead, in place or into a spread copy. So every slot of a kind
2284
+ keeps one V8 hidden class through holds, effects, moves between buckets, patches
2285
+ and release deadlines, and an entity keeps its own when a column leaves or
2286
+ arrives. `tests/what-v8-keeps-on-fast-properties.test.ts` asks V8 directly,
2287
+ through its own natives, for fast properties and a shared map. `tests/how-many-shapes-reach-the-write-door.test.ts`
2288
+ pins three slot shapes at the write door, one below V8's polymorphic ceiling, and four at
2289
+ `holdSlot`, at the ceiling. What that saves per read is not measured. One case is outside the engine's reach: an entity handed in with a few hundred
2290
+ fields is already in dictionary mode before it arrives.