@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.
- package/CHANGELOG.md +83 -0
- package/README.md +2083 -1581
- package/dist/index.cjs +5057 -6335
- package/dist/index.d.cts +1421 -3312
- package/dist/index.d.ts +1421 -3312
- package/dist/index.js +4991 -6242
- package/llms.txt +3909 -469
- package/package.json +69 -61
package/README.md
CHANGED
|
@@ -1,1788 +1,2290 @@
|
|
|
1
1
|
# @oimdb/core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
103
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
193
|
+
let seen = '';
|
|
194
|
+
const effect = effectService.createEffect(db, [slot], [], () => {
|
|
195
|
+
seen = slot.entity?.title ?? '';
|
|
146
196
|
});
|
|
147
197
|
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
import { createMergeEntityUpdater, createInPlaceEntityUpdater } from '@oimdb/core';
|
|
1010
|
+
### `markEntityChanged`: you moved the columns, so you supply the list
|
|
579
1011
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1064
|
+
### Which entities actually changed: the `changed` array
|
|
634
1065
|
|
|
635
1066
|
```typescript
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
//
|
|
654
|
-
//
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
734
|
-
const
|
|
735
|
-
|
|
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
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
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
|
-
|
|
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
|
-
|
|
807
|
-
|
|
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
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
903
|
-
|
|
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
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
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
|
-
|
|
919
|
-
|
|
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
|
-
|
|
928
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
|
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
|
-
|
|
1086
|
-
console.log('Admin users:', adminUsers.map((u) => u?.name));
|
|
1087
|
-
});
|
|
1703
|
+
const render = (slots: unknown[]): void => {};
|
|
1088
1704
|
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
1369
|
-
|
|
1370
|
-
#### `OIMCollectionSelectors<TEntity, TPk>`
|
|
1371
|
-
DX facade for reactive read selectors backed by one `OIMComputeRuntime`.
|
|
1886
|
+
### Index keys strategy
|
|
1372
1887
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
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
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1613
|
-
|
|
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
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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
|
-
|
|
1639
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
1686
|
-
|
|
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
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
2092
|
+
---
|
|
1696
2093
|
|
|
1697
|
-
|
|
1698
|
-
Factory for creating different scheduler types:
|
|
2094
|
+
## Standalone records
|
|
1699
2095
|
|
|
1700
2096
|
```typescript
|
|
1701
|
-
|
|
2097
|
+
type Settings = { theme: string; fontSize: number };
|
|
1702
2098
|
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
-
|
|
1745
|
-
|
|
1746
|
-
OIMEventQueue,
|
|
1747
|
-
OIMEventQueueSchedulerFactory
|
|
1748
|
-
} from '@oimdb/core';
|
|
2152
|
+
const body = (): void => {};
|
|
2153
|
+
const entitySlot = cardService.holdSlotByKey(cards, 'c1');
|
|
1749
2154
|
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
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
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
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.
|