@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.
- 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,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
|
+
|