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