@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.
@@ -0,0 +1,474 @@
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
+ /**
438
+ * MemoryAdapter — in-memory implementation of StorageAdapter for v1.
439
+ *
440
+ * ## Composition
441
+ * - ActionLog — stores actions, provides entity-level queries
442
+ * - DirtyTracker — tracks dirty entities, queryable by type
443
+ * - EntityStore — materialized entity cache
444
+ * - CursorStore — per-group GSN cursors
445
+ * - OutboxStore — durable pending local Actions; a detected conflict is
446
+ * a status + payload on its outbox row
447
+ * - ReplicaStore — single-slot persisted replica; a fast path only, so a
448
+ * cleared action log invalidates it
449
+ *
450
+ * ## Usage
451
+ * ```typescript
452
+ * const storage = createMemoryAdapter();
453
+ *
454
+ * // On action receipt:
455
+ * await storage.actions.append(action);
456
+ * for (const update of action.updates) {
457
+ * await storage.dirtyTracker.mark(update.subject_id, update.subject_type);
458
+ * }
459
+ *
460
+ * // On entity read:
461
+ * const entity = await storage.entities.get(entityId);
462
+ *
463
+ * // On query:
464
+ * const entities = await storage.entities.query("todo");
465
+ * ```
466
+ *
467
+ * ## Sync with Server
468
+ * The adapter maintains local state only. Sync with the server requires:
469
+ * 1. Receiving actions from server (append to ActionLog, mark dirty)
470
+ * 2. Sending local changes to server (future: outbox/writes)
471
+ */
472
+ declare const createMemoryAdapter: () => StorageAdapter;
473
+
474
+ export { createMemoryAdapter };