@ebbjs/storage 0.0.0-stage → 0.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/LICENSE.txt +202 -0
- package/README.md +83 -2
- package/dist/cursor-record-D39emd6R.js +21 -0
- package/dist/indexeddb/index.d.ts +465 -0
- package/dist/indexeddb/index.js +335 -0
- package/dist/memory/index.d.ts +474 -0
- package/dist/memory/index.js +296 -0
- package/dist/replica/index.d.ts +271 -0
- package/dist/replica/index.js +308 -0
- package/dist/replica-record-D3lkFACh.js +167 -0
- package/dist/types/index.d.ts +621 -0
- package/dist/types/index.js +1 -0
- package/package.json +78 -3
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { Action, Update, Entity } from '@ebbjs/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One entry in the append-ordered action log.
|
|
5
|
+
*
|
|
6
|
+
* `position` is the log's own monotonic append cursor. It exists because
|
|
7
|
+
* the server `gsn` is not a total log cursor: an Action can arrive once
|
|
8
|
+
* per group (#473), `gsn: 0` is legal (#475 backfill), and a `gsn` cannot
|
|
9
|
+
* distinguish "already folded into the persisted replica" from
|
|
10
|
+
* "appended after it". The replica loads a persisted base at `position`
|
|
11
|
+
* and replays `readTail(position)`.
|
|
12
|
+
*/
|
|
13
|
+
interface LoggedAction {
|
|
14
|
+
/**
|
|
15
|
+
* The merged Action. A repeated id (#473) reports the union of every
|
|
16
|
+
* Update delivered for that id, not just this delivery's slice.
|
|
17
|
+
*/
|
|
18
|
+
readonly action: Action;
|
|
19
|
+
/** The log-local append cursor for this Action. Stable across re-delivery. */
|
|
20
|
+
readonly position: number;
|
|
21
|
+
/**
|
|
22
|
+
* The Updates to fold. For `append`, only those not already logged for
|
|
23
|
+
* this id — a repeated Action id (`#473`) can carry disjoint per-group
|
|
24
|
+
* copies, and folding a whole re-delivered copy would re-apply an older
|
|
25
|
+
* `put` and revert a newer `patch`. For `readTail`, every Update of the
|
|
26
|
+
* merged Action.
|
|
27
|
+
*/
|
|
28
|
+
readonly added: readonly Update[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* ActionLog — the append-ordered, id-keyed source of truth.
|
|
32
|
+
*
|
|
33
|
+
* The replica's durable log. `append` returns the newly-added Updates and
|
|
34
|
+
* the log-local position; `readTail` replays the entries after a
|
|
35
|
+
* watermark. `getAll` / `getForEntity` remain for the entity store that
|
|
36
|
+
* still backs the pre-replica read path.
|
|
37
|
+
*
|
|
38
|
+
* ## Methods
|
|
39
|
+
* - `append(action)` — stores or merges an Action, returns the delta + position
|
|
40
|
+
* - `appendMany(actions)` — `append` for a batch, in one durable write
|
|
41
|
+
* - `readTail(afterPosition)` — every entry with `position > afterPosition`, in order
|
|
42
|
+
* - `lastPosition()` — the log's current append cursor
|
|
43
|
+
* - `getAll()` — returns all merged Actions
|
|
44
|
+
* - `getForEntity(entityId)` — returns all Actions affecting an entity, sorted by gsn
|
|
45
|
+
* - `clear()` — removes all actions (used for reset)
|
|
46
|
+
*/
|
|
47
|
+
interface ActionLog {
|
|
48
|
+
append(action: Action): Promise<LoggedAction>;
|
|
49
|
+
/**
|
|
50
|
+
* Append every Action in one durable write (one IndexedDB transaction), in
|
|
51
|
+
* order, returning the same entries `append` would. A listing seed writes a
|
|
52
|
+
* page this way: the page is the unit that must land whole or not at all
|
|
53
|
+
* (#476).
|
|
54
|
+
*/
|
|
55
|
+
appendMany(actions: readonly Action[]): Promise<readonly LoggedAction[]>;
|
|
56
|
+
readTail(afterPosition: number): Promise<readonly LoggedAction[]>;
|
|
57
|
+
lastPosition(): Promise<number>;
|
|
58
|
+
getAll(): Promise<readonly Action[]>;
|
|
59
|
+
getForEntity(entityId: string): Promise<readonly Action[]>;
|
|
60
|
+
clear(): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* DirtyTracker — tracks which entities need rematerialization.
|
|
65
|
+
*
|
|
66
|
+
* An entity is "dirty" when it has pending actions that haven't been
|
|
67
|
+
* applied to the materialized entity cache. Callers should invoke
|
|
68
|
+
* `mark(entityId, entityType)` when an action affects an entity, then
|
|
69
|
+
* materialize via `EntityStore.get()` or `EntityStore.query()`.
|
|
70
|
+
*
|
|
71
|
+
* ## Methods
|
|
72
|
+
* - `mark(entityId, entityType)` — marks an entity dirty (idempotent)
|
|
73
|
+
* - `isDirty(entityId)` — checks if an entity needs materialization
|
|
74
|
+
* - `getDirtyForType(entityType)` — returns all dirty entity IDs of a type
|
|
75
|
+
* - `clear(entityId)` — clears dirty flag after materialization
|
|
76
|
+
* - `clearAll()` — clears all dirty flags (used for reset)
|
|
77
|
+
*/
|
|
78
|
+
interface DirtyTracker {
|
|
79
|
+
mark(entityId: string, entityType: string): Promise<void>;
|
|
80
|
+
isDirty(entityId: string): Promise<boolean>;
|
|
81
|
+
getDirtyForType(entityType: string): Promise<readonly string[]>;
|
|
82
|
+
clear(entityId: string): Promise<void>;
|
|
83
|
+
clearAll(): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Lookup key for the relationship index: the accessor name (`as`),
|
|
88
|
+
* the relationship type string, and the target entity id.
|
|
89
|
+
*/
|
|
90
|
+
interface RelationshipIndexQuery {
|
|
91
|
+
readonly as: string;
|
|
92
|
+
readonly type: string;
|
|
93
|
+
readonly targetId: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Lookup key for the source-keyed forward relationship index: the
|
|
97
|
+
* accessor name (`as`), the relationship type string, and the source
|
|
98
|
+
* entity id.
|
|
99
|
+
*/
|
|
100
|
+
interface RelationshipSourceQuery {
|
|
101
|
+
readonly as: string;
|
|
102
|
+
readonly type: string;
|
|
103
|
+
readonly sourceId: string;
|
|
104
|
+
}
|
|
105
|
+
/** One live relationship edge read from the source side. */
|
|
106
|
+
interface RelationshipLink {
|
|
107
|
+
/** The `relationship` row id — the `subject_id` an unlink names. */
|
|
108
|
+
readonly relationshipId: string;
|
|
109
|
+
readonly targetId: string;
|
|
110
|
+
}
|
|
111
|
+
/** One live membership row read from the member entity side. */
|
|
112
|
+
interface MembershipLink {
|
|
113
|
+
/** The `entityGroup` row id — the `subject_id` a delete names. */
|
|
114
|
+
readonly membershipId: string;
|
|
115
|
+
readonly groupId: string;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* EntityStore — materialized entity cache supporting get-by-ID and query-by-type.
|
|
119
|
+
*
|
|
120
|
+
* Entities are materialized on-demand from the action log when they are dirty.
|
|
121
|
+
* The entity store is not responsible for marking entities dirty — that is
|
|
122
|
+
* handled by the DirtyTracker.
|
|
123
|
+
*
|
|
124
|
+
* ## Merge Semantics
|
|
125
|
+
* - **put** — full entity replacement
|
|
126
|
+
* - **patch** — field-level Last-Writer-Wins (LWW): higher HLC wins; tiebreak by lexicographic `update_id`
|
|
127
|
+
* - **delete** — soft delete (sets `deleted_hlc`); patch-on-deleted is ignored
|
|
128
|
+
*
|
|
129
|
+
* A field value is either a leaf merged by the LWW rule above, or a
|
|
130
|
+
* `{ map }` of nested field values merged key by key under the same
|
|
131
|
+
* rule. Map key sets are unioned, so a write to one key leaves its
|
|
132
|
+
* siblings untouched; a leaf `value: null` is a tombstone retained for
|
|
133
|
+
* late-apply and hidden by projection.
|
|
134
|
+
*
|
|
135
|
+
* ## Methods
|
|
136
|
+
* - `get(id)` — returns entity by ID, materializes if dirty
|
|
137
|
+
* - `set(entity)` — directly sets an entity (bypasses materialization)
|
|
138
|
+
* - `query(type)` — returns all materialized entities of a type
|
|
139
|
+
* - `queryByRelationship({ as, type, targetId })` — returns the distinct
|
|
140
|
+
* source ids indexed under that triple in ascending order,
|
|
141
|
+
* materializing dirty relationship rows first
|
|
142
|
+
* - `queryByRelationshipSource({ as, type, sourceId })` — returns the
|
|
143
|
+
* live edges out of that source, ascending by target id,
|
|
144
|
+
* materializing dirty relationship rows first
|
|
145
|
+
* - `queryByMembership(groupId)` — returns the distinct live member
|
|
146
|
+
* entity ids whose `entityGroup` row points at the group, ascending,
|
|
147
|
+
* materializing dirty `entityGroup` rows first
|
|
148
|
+
* - `queryByMembershipEntity(entityId)` — returns the live membership
|
|
149
|
+
* rows of that member entity, ascending by group id, materializing
|
|
150
|
+
* dirty `entityGroup` rows first
|
|
151
|
+
* - `reset()` — clears all cached entities
|
|
152
|
+
*/
|
|
153
|
+
interface EntityStore {
|
|
154
|
+
get(id: string): Promise<Entity | null>;
|
|
155
|
+
set(entity: Entity): Promise<void>;
|
|
156
|
+
query(type: string): Promise<readonly Entity[]>;
|
|
157
|
+
queryByRelationship(query: RelationshipIndexQuery): Promise<readonly string[]>;
|
|
158
|
+
queryByRelationshipSource(query: RelationshipSourceQuery): Promise<readonly RelationshipLink[]>;
|
|
159
|
+
queryByMembership(groupId: string): Promise<readonly string[]>;
|
|
160
|
+
queryByMembershipEntity(entityId: string): Promise<readonly MembershipLink[]>;
|
|
161
|
+
reset(): Promise<void>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* CursorStore — per-group sync resume state for the client.
|
|
166
|
+
*
|
|
167
|
+
* One record per group holds the cursor its view is complete as of, and,
|
|
168
|
+
* while a group listing seed is in flight, the marker that pins the seed's
|
|
169
|
+
* watermark so a crash redoes the same listing instead of a newer one
|
|
170
|
+
* (#476). Cursor and marker live in one record because they are one resume
|
|
171
|
+
* concept: the bootstrap trigger is a single read.
|
|
172
|
+
*
|
|
173
|
+
* ## Methods
|
|
174
|
+
* - `get(groupId)` — the group's cursor (GSN), or null if none
|
|
175
|
+
* - `set(groupId, cursor)` — writes the cursor, keeping any seed marker
|
|
176
|
+
* - `getRecord(groupId)` — the whole resume record (`{cursor?, seed?}`)
|
|
177
|
+
* - `setRecord(groupId, record)` — replaces the record; omitting `seed`
|
|
178
|
+
* clears the marker, which is how the final seed page commits, and
|
|
179
|
+
* `through` records how far the seed's base folded
|
|
180
|
+
* - `records()` — every group's record, for the handshake's cursor map
|
|
181
|
+
* - `clear()` — drops every group's resume state (a full local sync reset)
|
|
182
|
+
*/
|
|
183
|
+
/**
|
|
184
|
+
* A listing seed in flight: the watermark the redo must reuse and the
|
|
185
|
+
* server page token to resume from.
|
|
186
|
+
*
|
|
187
|
+
* `w` is absent while page 1's seed has not committed — the attempt then
|
|
188
|
+
* holds no rows that a newer watermark could skip past, so the redo lists
|
|
189
|
+
* from page 1 afresh (see `bootstrapGroupListing`). `token` is absent when
|
|
190
|
+
* `w` is, and otherwise names the page after the last one already seeded.
|
|
191
|
+
*/
|
|
192
|
+
interface SeedMarker {
|
|
193
|
+
/** The group being seeded. Guards a record against reuse across groups. */
|
|
194
|
+
readonly group_id: string;
|
|
195
|
+
/** Pinned listing watermark; absent while page 1's seed is uncommitted. */
|
|
196
|
+
readonly w?: number;
|
|
197
|
+
/** Opaque server page token for the next page to list. */
|
|
198
|
+
readonly token?: string;
|
|
199
|
+
}
|
|
200
|
+
/** One group's durable resume state. */
|
|
201
|
+
interface CursorRecord {
|
|
202
|
+
/** Highest GSN the group's view is complete as of. Absent before any seed or catch-up. */
|
|
203
|
+
readonly cursor?: number;
|
|
204
|
+
/** Present while a listing seed is in flight. */
|
|
205
|
+
readonly seed?: SeedMarker;
|
|
206
|
+
/**
|
|
207
|
+
* Highest entity log position the group's seeded base folded. A row's
|
|
208
|
+
* `last_gsn` at or below it is the server's own state, so a re-delivered
|
|
209
|
+
* `put` / `delete` inside that window is already reflected and must not be
|
|
210
|
+
* folded again (#476). Absent until a listing seed completes; it outlives
|
|
211
|
+
* the marker because the tail re-delivers after the seed finishes.
|
|
212
|
+
*/
|
|
213
|
+
readonly through?: number;
|
|
214
|
+
}
|
|
215
|
+
interface CursorStore {
|
|
216
|
+
get(groupId: string): Promise<number | null>;
|
|
217
|
+
set(groupId: string, cursor: number): Promise<void>;
|
|
218
|
+
getRecord(groupId: string): Promise<CursorRecord | null>;
|
|
219
|
+
setRecord(groupId: string, record: CursorRecord): Promise<void>;
|
|
220
|
+
records(): Promise<ReadonlyMap<string, CursorRecord>>;
|
|
221
|
+
clear(): Promise<void>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Lifecycle status of a buffered local Action. The store persists
|
|
226
|
+
* whatever status the writer assigns; which transitions are legal, and
|
|
227
|
+
* when they happen, is the client outbox's concern, not storage's.
|
|
228
|
+
*
|
|
229
|
+
* `conflicted` is a terminal-until-resolved hold: a catch-up pass
|
|
230
|
+
* classified the Action as a loser, so it is structurally excluded from
|
|
231
|
+
* `pending()` and never submitted. The application resolves it by
|
|
232
|
+
* retrying (back to `pending`) or discarding (removed).
|
|
233
|
+
*/
|
|
234
|
+
type OutboxStatus = "pending" | "acknowledged" | "error" | "conflicted";
|
|
235
|
+
/**
|
|
236
|
+
* One side of a contested slot: the buffered local write, or the value
|
|
237
|
+
* the server holds once the catch-up window is applied. A leaf carries
|
|
238
|
+
* its LWW triple; a whole-field map replacement has no single leaf, so
|
|
239
|
+
* `update_id` / `hlc` are absent and `value` is the projected map. A
|
|
240
|
+
* field an inbound `put` or `delete` reverted also has no triple, and
|
|
241
|
+
* `value` is `undefined` because the server holds no value.
|
|
242
|
+
*/
|
|
243
|
+
interface ConflictSide {
|
|
244
|
+
/** Projected leaf or map value. `undefined` when the server holds nothing. */
|
|
245
|
+
readonly value: unknown;
|
|
246
|
+
/** `update_id` of the FieldValue; absent for a map side or a reverted field. */
|
|
247
|
+
readonly update_id?: string;
|
|
248
|
+
/** HLC carried by the FieldValue; absent for a map side or a reverted field. */
|
|
249
|
+
readonly hlc?: string;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* One contested position on a losing Action: a field plus the map-key
|
|
253
|
+
* path from the field root to the position. `path` is empty for a plain
|
|
254
|
+
* leaf field, an entity-level touch that overlaps no position, or a
|
|
255
|
+
* field an inbound `put` / `delete` reverted. Concurrent writes to
|
|
256
|
+
* different keys are different slots and so are not conflicts.
|
|
257
|
+
*/
|
|
258
|
+
interface ConflictSlot {
|
|
259
|
+
/** The `subject_id` of the Update that carried the write. */
|
|
260
|
+
readonly subjectId: string;
|
|
261
|
+
/** The field name on that subject. */
|
|
262
|
+
readonly field: string;
|
|
263
|
+
/** Ordered map keys from the field root to the position; empty at the root. */
|
|
264
|
+
readonly path: readonly string[];
|
|
265
|
+
/** The buffered local value at the position. */
|
|
266
|
+
readonly local: ConflictSide;
|
|
267
|
+
/** The value the server holds at the position after the missed window. */
|
|
268
|
+
readonly remote: ConflictSide;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The before/after entity rows for one losing subject. `before` is the
|
|
272
|
+
* row as the client had it immediately before the catch-up window was
|
|
273
|
+
* applied: last-synced server state with its own unflushed writes
|
|
274
|
+
* optimistically applied. `after` is the materialized row at detection,
|
|
275
|
+
* once the inbound window has replaced the optimistic row with the
|
|
276
|
+
* server's view. Both are structured-clone copies, not references into
|
|
277
|
+
* the action log, so the comparison survives a reload and compaction.
|
|
278
|
+
*/
|
|
279
|
+
interface ConflictSubjectState {
|
|
280
|
+
readonly subjectId: string;
|
|
281
|
+
readonly subjectType: string;
|
|
282
|
+
readonly before: Entity | null;
|
|
283
|
+
readonly after: Entity | null;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The conflict payload detection stamps onto a losing Action's outbox
|
|
287
|
+
* row. It travels with the row rather than in a second store, so the
|
|
288
|
+
* hold and the record are the same durable write and cannot disagree.
|
|
289
|
+
*/
|
|
290
|
+
interface OutboxConflict {
|
|
291
|
+
/**
|
|
292
|
+
* Subjects that actually lost. A create-plus-update write names only
|
|
293
|
+
* the entity that lost, so a freshly created entity is not dragged in.
|
|
294
|
+
*/
|
|
295
|
+
readonly subjects: readonly string[];
|
|
296
|
+
/**
|
|
297
|
+
* Full entity rows for each losing subject, in the same order as
|
|
298
|
+
* `subjects`. `after` is read as the materialized row at detection and
|
|
299
|
+
* is `null` only when replay yields no row.
|
|
300
|
+
*/
|
|
301
|
+
readonly subjectStates: readonly ConflictSubjectState[];
|
|
302
|
+
/**
|
|
303
|
+
* Overlapping contested positions, in detection order. Empty when the
|
|
304
|
+
* entity-level scope surfaced a subject with no position overlap; an
|
|
305
|
+
* application reads that as "nothing of mine would be lost".
|
|
306
|
+
*/
|
|
307
|
+
readonly slots: readonly ConflictSlot[];
|
|
308
|
+
/** The catch-up pass that produced this entry. */
|
|
309
|
+
readonly catchUpId: string;
|
|
310
|
+
/**
|
|
311
|
+
* Stamped at detection and persisted, so it survives a reload and
|
|
312
|
+
* partial resolution. Grouping is the connected set of losing
|
|
313
|
+
* subjects within one `catchUpId`.
|
|
314
|
+
*/
|
|
315
|
+
readonly conflictId: string;
|
|
316
|
+
/** Client HLC stamped at detection. The conflict view's ordering key. */
|
|
317
|
+
readonly detectedAtHlc: string;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* One locally-authored Action awaiting acknowledgement, plus the HLC at
|
|
321
|
+
* which it entered the outbox. A `conflicted` entry carries its
|
|
322
|
+
* {@link OutboxConflict} payload; the field is absent for every other
|
|
323
|
+
* status.
|
|
324
|
+
*/
|
|
325
|
+
interface OutboxEntry {
|
|
326
|
+
readonly action: Action;
|
|
327
|
+
readonly status: OutboxStatus;
|
|
328
|
+
/**
|
|
329
|
+
* Client HLC stamped when the Action was buffered. The ordering key
|
|
330
|
+
* for `list()` — Actions must flush in the order they were authored,
|
|
331
|
+
* independent of object-store iteration order.
|
|
332
|
+
*/
|
|
333
|
+
readonly enqueuedAtHlc: string;
|
|
334
|
+
/**
|
|
335
|
+
* Highest server GSN the client had applied when the Action was
|
|
336
|
+
* authored. Catch-up detection treats an inbound Action as concurrent
|
|
337
|
+
* when its GSN is greater; `0` means "always concurrent", so a legacy
|
|
338
|
+
* or base-less entry is never silently dropped.
|
|
339
|
+
*/
|
|
340
|
+
readonly basedOnGsn: number;
|
|
341
|
+
/** Present iff `status === "conflicted"`. */
|
|
342
|
+
readonly conflict?: OutboxConflict;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* OutboxStore — durable buffer of pending local Actions.
|
|
346
|
+
*
|
|
347
|
+
* Entries are keyed by `action.id`. Re-`put`ting an id replaces the
|
|
348
|
+
* whole row, which is how a status transition — including a detection
|
|
349
|
+
* that stamps a conflict payload — is persisted; `list()` returns
|
|
350
|
+
* entries in ascending `enqueuedAtHlc` order so a rehydrating client
|
|
351
|
+
* replays them in authoring order.
|
|
352
|
+
*/
|
|
353
|
+
interface OutboxStore {
|
|
354
|
+
put(entry: OutboxEntry): Promise<void>;
|
|
355
|
+
/**
|
|
356
|
+
* Persist `entry` and delete `deleteId` in one durable operation. Used
|
|
357
|
+
* to resolve a conflict by retrying: the fresh re-assert must never
|
|
358
|
+
* exist without the conflicted row being gone, nor the row be gone
|
|
359
|
+
* without the re-assert persisted.
|
|
360
|
+
*/
|
|
361
|
+
putAndDelete(entry: OutboxEntry, deleteId: string): Promise<void>;
|
|
362
|
+
list(): Promise<readonly OutboxEntry[]>;
|
|
363
|
+
get(actionId: string): Promise<OutboxEntry | null>;
|
|
364
|
+
delete(actionId: string): Promise<void>;
|
|
365
|
+
clear(): Promise<void>;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The persisted form of the materialized replica — the replica at rest.
|
|
370
|
+
*
|
|
371
|
+
* `position` is the `ActionLog` append cursor the record was taken at:
|
|
372
|
+
* load is `record ⊕ readTail(record.position)`. `revision` is the
|
|
373
|
+
* replica's observable-content version at that point. `entities` is the
|
|
374
|
+
* row-shaped entity set; derived indices are rebuilt on load, never
|
|
375
|
+
* persisted, because IndexedDB cannot index nested `FieldValue` paths,
|
|
376
|
+
* express live-row filtering, or query a stored map.
|
|
377
|
+
*/
|
|
378
|
+
interface ReplicaRecord {
|
|
379
|
+
/** Shape version of this record. A record of an unknown format reads as absent. */
|
|
380
|
+
readonly format: number;
|
|
381
|
+
/** The log position the record was taken at. */
|
|
382
|
+
readonly position: number;
|
|
383
|
+
/** The replica's revision when the record was taken. */
|
|
384
|
+
readonly revision: number;
|
|
385
|
+
readonly entities: readonly Entity[];
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* ReplicaStore — single-slot persistence for the materialized replica.
|
|
389
|
+
*
|
|
390
|
+
* Only ever a fast path. Correctness never depends on the record
|
|
391
|
+
* existing: an absent or corrupt record reads as `null` so the caller
|
|
392
|
+
* falls back to full log replay rather than hydrating a partial base.
|
|
393
|
+
*/
|
|
394
|
+
interface ReplicaStore {
|
|
395
|
+
load(): Promise<ReplicaRecord | null>;
|
|
396
|
+
save(record: ReplicaRecord): Promise<void>;
|
|
397
|
+
clear(): Promise<void>;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* StorageAdapter — unified interface composing all storage components.
|
|
402
|
+
*
|
|
403
|
+
* Use `createMemoryAdapter()` from `@ebbjs/storage` to get a full
|
|
404
|
+
* in-memory implementation.
|
|
405
|
+
*
|
|
406
|
+
* ## Composition
|
|
407
|
+
* - `actions` — ActionLog for storing and querying actions
|
|
408
|
+
* - `entities` — EntityStore for materialized entity cache
|
|
409
|
+
* - `dirtyTracker` — DirtyTracker for tracking entities needing materialization
|
|
410
|
+
* - `cursors` — CursorStore for per-group GSN tracking
|
|
411
|
+
* - `outbox` — OutboxStore for durable pending local Actions; a detected
|
|
412
|
+
* conflict is a status + payload on its outbox row, not a sibling store
|
|
413
|
+
* - `replica` — ReplicaStore for the persisted materialized replica; single-slot
|
|
414
|
+
* and a fast path only
|
|
415
|
+
*
|
|
416
|
+
* ## Cross-cutting Methods
|
|
417
|
+
* - `isDirty(entityId)` — delegates to dirtyTracker
|
|
418
|
+
* - `reset()` — clears the action log, dirty flags, entity cache, the
|
|
419
|
+
* persisted replica, and every group cursor; outbox entries are durable
|
|
420
|
+
* user data and survive a reset
|
|
421
|
+
*/
|
|
422
|
+
interface StorageAdapter {
|
|
423
|
+
readonly actions: ActionLog;
|
|
424
|
+
readonly entities: EntityStore;
|
|
425
|
+
readonly dirtyTracker: DirtyTracker;
|
|
426
|
+
readonly cursors: CursorStore;
|
|
427
|
+
readonly outbox: OutboxStore;
|
|
428
|
+
/**
|
|
429
|
+
* Single-slot persistence for the replica. An absent or corrupt record
|
|
430
|
+
* reads as `null`, so correctness never depends on it existing.
|
|
431
|
+
*/
|
|
432
|
+
readonly replica: ReplicaStore;
|
|
433
|
+
isDirty(entityId: string): Promise<boolean>;
|
|
434
|
+
reset(): Promise<void>;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
interface IndexedDBAdapterOptions {
|
|
438
|
+
dbName?: string;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Creates a `StorageAdapter` backed by IndexedDB.
|
|
442
|
+
*
|
|
443
|
+
* ## Usage
|
|
444
|
+
* ```ts
|
|
445
|
+
* const adapter = await createIndexedDBAdapter({ dbName: "my-app" });
|
|
446
|
+
* await adapter.actions.append(action);
|
|
447
|
+
* ```
|
|
448
|
+
*
|
|
449
|
+
* ## Browser-only
|
|
450
|
+
* This adapter requires `indexedDB` in the global scope. In Node /
|
|
451
|
+
* test environments it must be run inside a DOM environment that
|
|
452
|
+
* provides the API (e.g. `happy-dom`).
|
|
453
|
+
*
|
|
454
|
+
* ## Concurrency
|
|
455
|
+
* Single-tab ownership is assumed for v1. Multi-tab coordination is
|
|
456
|
+
* out of scope per issue #145.
|
|
457
|
+
*
|
|
458
|
+
* ## Schema version
|
|
459
|
+
* Opens at `EBB_SCHEMA_VERSION`; `openTestDb` opens the test schema at
|
|
460
|
+
* the same version so the two cannot drift.
|
|
461
|
+
*/
|
|
462
|
+
declare const createIndexedDBAdapter: (options?: IndexedDBAdapterOptions) => Promise<StorageAdapter>;
|
|
463
|
+
|
|
464
|
+
export { createIndexedDBAdapter };
|
|
465
|
+
export type { IndexedDBAdapterOptions };
|