@ebbjs/storage 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,621 @@
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
+ * The persisted form of the materialized replica — the replica at rest.
226
+ *
227
+ * `position` is the `ActionLog` append cursor the record was taken at:
228
+ * load is `record ⊕ readTail(record.position)`. `revision` is the
229
+ * replica's observable-content version at that point. `entities` is the
230
+ * row-shaped entity set; derived indices are rebuilt on load, never
231
+ * persisted, because IndexedDB cannot index nested `FieldValue` paths,
232
+ * express live-row filtering, or query a stored map.
233
+ */
234
+ interface ReplicaRecord {
235
+ /** Shape version of this record. A record of an unknown format reads as absent. */
236
+ readonly format: number;
237
+ /** The log position the record was taken at. */
238
+ readonly position: number;
239
+ /** The replica's revision when the record was taken. */
240
+ readonly revision: number;
241
+ readonly entities: readonly Entity[];
242
+ }
243
+ /**
244
+ * ReplicaStore — single-slot persistence for the materialized replica.
245
+ *
246
+ * Only ever a fast path. Correctness never depends on the record
247
+ * existing: an absent or corrupt record reads as `null` so the caller
248
+ * falls back to full log replay rather than hydrating a partial base.
249
+ */
250
+ interface ReplicaStore {
251
+ load(): Promise<ReplicaRecord | null>;
252
+ save(record: ReplicaRecord): Promise<void>;
253
+ clear(): Promise<void>;
254
+ }
255
+
256
+ /**
257
+ * A monotonic counter over the replica's observable content. It bumps once
258
+ * per entity change a reader could see — never for delivery bookkeeping like
259
+ * `last_gsn` — so it doubles as the invalidation token for derived state.
260
+ */
261
+ type Revision = number;
262
+ /** One observable entity change, appended to the revision journal. */
263
+ interface ReplicaChange {
264
+ readonly revision: Revision;
265
+ readonly id: string;
266
+ readonly type: string;
267
+ readonly deleted: boolean;
268
+ }
269
+ /**
270
+ * Whether the replica retains a type's rows. The seam #500 builds on-demand
271
+ * residency against: a policy that holds nothing lets the engine fetch a type
272
+ * on first read and react to the journal it still receives.
273
+ */
274
+ interface MaterializationPolicy {
275
+ holds(type: string): boolean;
276
+ }
277
+ interface ReplicaOptions {
278
+ readonly materialization?: MaterializationPolicy;
279
+ /**
280
+ * Reads a row from the local stores when `open` misses. Without it an
281
+ * on-demand id that is not resident stays a miss.
282
+ */
283
+ readonly readEntity?: (id: string) => Promise<Entity | null>;
284
+ /**
285
+ * Maximum resident on-demand rows. Defaults to 16. A non-positive or
286
+ * non-finite value keeps every opened row resident; opening past the
287
+ * window releases the least recently opened one.
288
+ */
289
+ readonly pinnedCapacity?: number;
290
+ /**
291
+ * How long a closed on-demand row stays cached before release. Defaults to
292
+ * 30_000 ms. A non-positive or non-finite value releases it immediately on
293
+ * close. An open row is never released by the TTL.
294
+ */
295
+ readonly pinnedTtlMs?: number;
296
+ /**
297
+ * Maximum retained change-journal entries after trimming. Defaults to 1024.
298
+ * A non-positive or non-finite value keeps the journal unbounded; a
299
+ * positive fraction retains at least one entry.
300
+ */
301
+ readonly journalCapacity?: number;
302
+ }
303
+ /** A snapshot of what the replica currently materializes and retains. */
304
+ interface ReplicaStats {
305
+ /** Number of row objects currently materialized (tombstones included). */
306
+ readonly entities: number;
307
+ /** Materialized row count per entity type. */
308
+ readonly byType: Readonly<Record<string, number>>;
309
+ /** Rows retained because their type is held: the metadata footprint. */
310
+ readonly held: number;
311
+ /** Held row count per type; excludes opened on-demand rows. */
312
+ readonly heldByType: Readonly<Record<string, number>>;
313
+ /** Resident on-demand rows (open or closed and waiting out their TTL). */
314
+ readonly pinned: number;
315
+ readonly revision: Revision;
316
+ /** Append cursor of the last durable position folded into the base. */
317
+ readonly position: number;
318
+ /** Retained journal entries. */
319
+ readonly changes: number;
320
+ /** Oldest revision after which `changesSince` is complete. */
321
+ readonly changeFloor: Revision;
322
+ }
323
+ /**
324
+ * Adjacency read key for the replica's relationship index. `sourceId`
325
+ * reads forward — the targets out of the source; `targetId` reads
326
+ * reverse — the sources pointing at the target. `sourceId` wins when
327
+ * both are set, and neither yields no ids.
328
+ */
329
+ interface RelationshipQuery {
330
+ readonly as: string;
331
+ readonly type: string;
332
+ readonly sourceId?: string;
333
+ readonly targetId?: string;
334
+ }
335
+ /**
336
+ * Adjacency read key for the replica's membership index. `groupId`
337
+ * reads the group's members; `entityId` reads the entity's groups.
338
+ * `groupId` wins when both are set, and neither yields no ids.
339
+ */
340
+ interface MembershipQuery {
341
+ readonly groupId?: string;
342
+ readonly entityId?: string;
343
+ }
344
+ /**
345
+ * The materialized read path. Writes are synchronous and single-writer: the
346
+ * caller serializes them and folds durable updates in ascending log position.
347
+ * Reads return the replica's own row objects (shared, not cloned), tombstones
348
+ * included; callers must treat them as immutable.
349
+ */
350
+ interface Replica {
351
+ readonly revision: Revision;
352
+ /**
353
+ * Smallest revision for which the retained journal is complete: the
354
+ * revision of the newest trimmed change, or the loaded record's revision.
355
+ * A `changesSince` below this floor yields nothing because everything may
356
+ * have changed.
357
+ */
358
+ readonly changeFloor: Revision;
359
+ get(id: string): Entity | undefined;
360
+ /**
361
+ * The durable base row for `id`, without this client's optimistic overlay
362
+ * writes. A reader judging "has this row already folded a later Action than
363
+ * the one I am delivering" must read the base: an overlay `put` composes
364
+ * with gsn 0, which would hide the row's folded position.
365
+ */
366
+ base(id: string): Entity | undefined;
367
+ iterate(type: string): readonly Entity[];
368
+ /** Whether the materialization policy holds `type` in full. */
369
+ isHeld(type: string): boolean;
370
+ /**
371
+ * Load `id` through `readEntity` when absent, pinning it and journaling the
372
+ * install so a derived reader sees the row arrive. Idempotent: a resident row
373
+ * is marked open and touched (LRU order) without another read, a concurrent
374
+ * open shares the in-flight read, and a held row is returned without
375
+ * installing or pinning. Resolves to the composed row, or `null` when the
376
+ * read misses.
377
+ */
378
+ open(id: string): Promise<Entity | null>;
379
+ /**
380
+ * Close a pinned row. It stays cached until `pinnedTtlMs` so a remount reads
381
+ * it back, then leaves; with the TTL disabled it leaves immediately. A no-op
382
+ * for held rows.
383
+ */
384
+ close(id: string): void;
385
+ /** Whether `id` is currently open (closed rows waiting out their TTL are not). */
386
+ isOpen(id: string): boolean;
387
+ /**
388
+ * Changes newer than `revision`, ascending. Empty when the cursor predates
389
+ * `changeFloor`, where the trimmed journal can no longer answer it.
390
+ */
391
+ changesSince(revision: Revision): readonly ReplicaChange[];
392
+ stats(): ReplicaStats;
393
+ /**
394
+ * Distinct live targets out of `(as, type, sourceId)`, or distinct
395
+ * live sources pointing at `(as, type, targetId)`, ascending.
396
+ */
397
+ relationships(query: RelationshipQuery): readonly string[];
398
+ /**
399
+ * Distinct live member entity ids of a group, or distinct live group
400
+ * ids of an entity, ascending.
401
+ */
402
+ memberships(query: MembershipQuery): readonly string[];
403
+ /** Live forward relationship edges out of a source, ascending by target then row id. */
404
+ relationshipLinks(query: RelationshipSourceQuery): readonly RelationshipLink[];
405
+ /** Live member-keyed membership rows of an entity, ascending by group then row id. */
406
+ membershipLinks(entityId: string): readonly MembershipLink[];
407
+ /** Fold one durable Action's `delta` at its log position. */
408
+ ingest(action: Action, delta: readonly Update[], position: number): Revision;
409
+ /** Fold an unacknowledged local Action into the overlay only. */
410
+ optimistic(action: Action): Revision;
411
+ /** Drop an overlay Action's writes, reverting the composed view. */
412
+ discard(actionId: string): Revision;
413
+ /**
414
+ * Replace the materialized base with `record`. The journal resets to that
415
+ * baseline. The revision advances to the record's revision when that is
416
+ * newer, and by one otherwise, so a derived reader always observes the
417
+ * wholesale replacement even when the record is the same revision.
418
+ */
419
+ load(record: ReplicaRecord): Revision;
420
+ toRecord(): ReplicaRecord;
421
+ /**
422
+ * Drop rows from the in-memory base and indices without tombstoning them:
423
+ * afterwards `get(id)` is undefined and `iterate(type)` omits the row. Each
424
+ * removed row journals one change, so derived readers see it leave. Absent
425
+ * ids are ignored. Returns the resulting revision.
426
+ */
427
+ evict(ids: readonly string[]): Revision;
428
+ /**
429
+ * Subscribe to post-change notifications. Fires after every mutating call
430
+ * (`ingest` / `optimistic` / `discard` / `load` / `evict` / `open` /
431
+ * `close`) that settles the composed view, so a derived reader can
432
+ * resynchronise from `revision` and `changesSince`. A no-op write still
433
+ * fires; readers cheaply ignore an unchanged revision. Re-opening a resident
434
+ * row only touches the LRU window, so it fires nothing. Returns an
435
+ * unsubscribe function.
436
+ */
437
+ subscribe(listener: () => void): () => void;
438
+ }
439
+
440
+ /**
441
+ * Lifecycle status of a buffered local Action. The store persists
442
+ * whatever status the writer assigns; which transitions are legal, and
443
+ * when they happen, is the client outbox's concern, not storage's.
444
+ *
445
+ * `conflicted` is a terminal-until-resolved hold: a catch-up pass
446
+ * classified the Action as a loser, so it is structurally excluded from
447
+ * `pending()` and never submitted. The application resolves it by
448
+ * retrying (back to `pending`) or discarding (removed).
449
+ */
450
+ type OutboxStatus = "pending" | "acknowledged" | "error" | "conflicted";
451
+ /**
452
+ * One side of a contested slot: the buffered local write, or the value
453
+ * the server holds once the catch-up window is applied. A leaf carries
454
+ * its LWW triple; a whole-field map replacement has no single leaf, so
455
+ * `update_id` / `hlc` are absent and `value` is the projected map. A
456
+ * field an inbound `put` or `delete` reverted also has no triple, and
457
+ * `value` is `undefined` because the server holds no value.
458
+ */
459
+ interface ConflictSide {
460
+ /** Projected leaf or map value. `undefined` when the server holds nothing. */
461
+ readonly value: unknown;
462
+ /** `update_id` of the FieldValue; absent for a map side or a reverted field. */
463
+ readonly update_id?: string;
464
+ /** HLC carried by the FieldValue; absent for a map side or a reverted field. */
465
+ readonly hlc?: string;
466
+ }
467
+ /**
468
+ * One contested position on a losing Action: a field plus the map-key
469
+ * path from the field root to the position. `path` is empty for a plain
470
+ * leaf field, an entity-level touch that overlaps no position, or a
471
+ * field an inbound `put` / `delete` reverted. Concurrent writes to
472
+ * different keys are different slots and so are not conflicts.
473
+ */
474
+ interface ConflictSlot {
475
+ /** The `subject_id` of the Update that carried the write. */
476
+ readonly subjectId: string;
477
+ /** The field name on that subject. */
478
+ readonly field: string;
479
+ /** Ordered map keys from the field root to the position; empty at the root. */
480
+ readonly path: readonly string[];
481
+ /** The buffered local value at the position. */
482
+ readonly local: ConflictSide;
483
+ /** The value the server holds at the position after the missed window. */
484
+ readonly remote: ConflictSide;
485
+ }
486
+ /**
487
+ * The before/after entity rows for one losing subject. `before` is the
488
+ * row as the client had it immediately before the catch-up window was
489
+ * applied: last-synced server state with its own unflushed writes
490
+ * optimistically applied. `after` is the materialized row at detection,
491
+ * once the inbound window has replaced the optimistic row with the
492
+ * server's view. Both are structured-clone copies, not references into
493
+ * the action log, so the comparison survives a reload and compaction.
494
+ */
495
+ interface ConflictSubjectState {
496
+ readonly subjectId: string;
497
+ readonly subjectType: string;
498
+ readonly before: Entity | null;
499
+ readonly after: Entity | null;
500
+ }
501
+ /**
502
+ * The conflict payload detection stamps onto a losing Action's outbox
503
+ * row. It travels with the row rather than in a second store, so the
504
+ * hold and the record are the same durable write and cannot disagree.
505
+ */
506
+ interface OutboxConflict {
507
+ /**
508
+ * Subjects that actually lost. A create-plus-update write names only
509
+ * the entity that lost, so a freshly created entity is not dragged in.
510
+ */
511
+ readonly subjects: readonly string[];
512
+ /**
513
+ * Full entity rows for each losing subject, in the same order as
514
+ * `subjects`. `after` is read as the materialized row at detection and
515
+ * is `null` only when replay yields no row.
516
+ */
517
+ readonly subjectStates: readonly ConflictSubjectState[];
518
+ /**
519
+ * Overlapping contested positions, in detection order. Empty when the
520
+ * entity-level scope surfaced a subject with no position overlap; an
521
+ * application reads that as "nothing of mine would be lost".
522
+ */
523
+ readonly slots: readonly ConflictSlot[];
524
+ /** The catch-up pass that produced this entry. */
525
+ readonly catchUpId: string;
526
+ /**
527
+ * Stamped at detection and persisted, so it survives a reload and
528
+ * partial resolution. Grouping is the connected set of losing
529
+ * subjects within one `catchUpId`.
530
+ */
531
+ readonly conflictId: string;
532
+ /** Client HLC stamped at detection. The conflict view's ordering key. */
533
+ readonly detectedAtHlc: string;
534
+ }
535
+ /**
536
+ * One locally-authored Action awaiting acknowledgement, plus the HLC at
537
+ * which it entered the outbox. A `conflicted` entry carries its
538
+ * {@link OutboxConflict} payload; the field is absent for every other
539
+ * status.
540
+ */
541
+ interface OutboxEntry {
542
+ readonly action: Action;
543
+ readonly status: OutboxStatus;
544
+ /**
545
+ * Client HLC stamped when the Action was buffered. The ordering key
546
+ * for `list()` — Actions must flush in the order they were authored,
547
+ * independent of object-store iteration order.
548
+ */
549
+ readonly enqueuedAtHlc: string;
550
+ /**
551
+ * Highest server GSN the client had applied when the Action was
552
+ * authored. Catch-up detection treats an inbound Action as concurrent
553
+ * when its GSN is greater; `0` means "always concurrent", so a legacy
554
+ * or base-less entry is never silently dropped.
555
+ */
556
+ readonly basedOnGsn: number;
557
+ /** Present iff `status === "conflicted"`. */
558
+ readonly conflict?: OutboxConflict;
559
+ }
560
+ /**
561
+ * OutboxStore — durable buffer of pending local Actions.
562
+ *
563
+ * Entries are keyed by `action.id`. Re-`put`ting an id replaces the
564
+ * whole row, which is how a status transition — including a detection
565
+ * that stamps a conflict payload — is persisted; `list()` returns
566
+ * entries in ascending `enqueuedAtHlc` order so a rehydrating client
567
+ * replays them in authoring order.
568
+ */
569
+ interface OutboxStore {
570
+ put(entry: OutboxEntry): Promise<void>;
571
+ /**
572
+ * Persist `entry` and delete `deleteId` in one durable operation. Used
573
+ * to resolve a conflict by retrying: the fresh re-assert must never
574
+ * exist without the conflicted row being gone, nor the row be gone
575
+ * without the re-assert persisted.
576
+ */
577
+ putAndDelete(entry: OutboxEntry, deleteId: string): Promise<void>;
578
+ list(): Promise<readonly OutboxEntry[]>;
579
+ get(actionId: string): Promise<OutboxEntry | null>;
580
+ delete(actionId: string): Promise<void>;
581
+ clear(): Promise<void>;
582
+ }
583
+
584
+ /**
585
+ * StorageAdapter — unified interface composing all storage components.
586
+ *
587
+ * Use `createMemoryAdapter()` from `@ebbjs/storage` to get a full
588
+ * in-memory implementation.
589
+ *
590
+ * ## Composition
591
+ * - `actions` — ActionLog for storing and querying actions
592
+ * - `entities` — EntityStore for materialized entity cache
593
+ * - `dirtyTracker` — DirtyTracker for tracking entities needing materialization
594
+ * - `cursors` — CursorStore for per-group GSN tracking
595
+ * - `outbox` — OutboxStore for durable pending local Actions; a detected
596
+ * conflict is a status + payload on its outbox row, not a sibling store
597
+ * - `replica` — ReplicaStore for the persisted materialized replica; single-slot
598
+ * and a fast path only
599
+ *
600
+ * ## Cross-cutting Methods
601
+ * - `isDirty(entityId)` — delegates to dirtyTracker
602
+ * - `reset()` — clears the action log, dirty flags, entity cache, the
603
+ * persisted replica, and every group cursor; outbox entries are durable
604
+ * user data and survive a reset
605
+ */
606
+ interface StorageAdapter {
607
+ readonly actions: ActionLog;
608
+ readonly entities: EntityStore;
609
+ readonly dirtyTracker: DirtyTracker;
610
+ readonly cursors: CursorStore;
611
+ readonly outbox: OutboxStore;
612
+ /**
613
+ * Single-slot persistence for the replica. An absent or corrupt record
614
+ * reads as `null`, so correctness never depends on it existing.
615
+ */
616
+ readonly replica: ReplicaStore;
617
+ isDirty(entityId: string): Promise<boolean>;
618
+ reset(): Promise<void>;
619
+ }
620
+
621
+ export type { ActionLog, ConflictSide, ConflictSlot, ConflictSubjectState, CursorRecord, CursorStore, DirtyTracker, EntityStore, LoggedAction, MaterializationPolicy, MembershipLink, MembershipQuery, OutboxConflict, OutboxEntry, OutboxStatus, OutboxStore, RelationshipIndexQuery, RelationshipLink, RelationshipQuery, RelationshipSourceQuery, Replica, ReplicaChange, ReplicaOptions, ReplicaRecord, ReplicaStats, ReplicaStore, Revision, SeedMarker, StorageAdapter };
@@ -0,0 +1 @@
1
+