@lunora/replica 1.0.0-alpha.3 → 1.0.0-alpha.5

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.
@@ -28,16 +28,29 @@ type RowChange = {
28
28
  interface TableDiff {
29
29
  /** Ordered row changes — insert/update/delete, earliest first. */
30
30
  readonly changes: ReadonlyArray<RowChange>;
31
+ /**
32
+ * Optional stable identity for this diff, distinct from `timestamp`
33
+ * (multiple diffs can legitimately share a millisecond, so `timestamp`
34
+ * alone is not a unique diff identity). Used by `deriveInsertId` in
35
+ * `apply-diff.ts` to derive deterministic row ids for id-less inserts:
36
+ * replaying the SAME diff (same `id`) must always mint the SAME id,
37
+ * while two DIFFERENT diffs emitted in the same millisecond must not
38
+ * alias onto the same one. `createTableDiff` auto-generates one when
39
+ * omitted; diffs built as plain object literals (bypassing the helper)
40
+ * simply fall back to `timestamp` for that derivation.
41
+ */
42
+ readonly id?: string;
31
43
  /** Logical table name (matches the schema table name). */
32
44
  readonly table: string;
33
45
  /** Monotonic server timestamp (ms since epoch) when this diff was emitted. */
34
46
  readonly timestamp: number;
35
47
  }
36
48
  /**
37
- * Create a {@link TableDiff} with a snapshot of the current time.
49
+ * Create a {@link TableDiff} with a snapshot of the current time and a
50
+ * fresh stable `id` (unless one is explicitly provided).
38
51
  * @experimental
39
52
  */
40
- declare const createTableDiff: (table: string, changes: ReadonlyArray<RowChange>, timestamp?: number) => TableDiff;
53
+ declare const createTableDiff: (table: string, changes: ReadonlyArray<RowChange>, timestamp?: number, id?: string) => TableDiff;
41
54
  /**
42
55
  * Return `true` when the diff contains no row changes.
43
56
  * @experimental
@@ -212,6 +225,37 @@ interface AppendOptions {
212
225
  readonly parentSeqNum?: Seq;
213
226
  /** Session identifier within the client. */
214
227
  readonly sessionId?: string;
228
+ /**
229
+ * Override the entry's `timestamp` instead of stamping `Date.now()` at
230
+ * append time.
231
+ *
232
+ * Used by callers (e.g. {@link import("./event-source").EventSource | EventSource})
233
+ * that must commit the EXACT entry a reducer already observed — a
234
+ * second, independently-drawn `Date.now()` at append time could produce
235
+ * a persisted entry a timestamp-dependent reducer cannot reproduce on
236
+ * replay.
237
+ */
238
+ readonly timestamp?: number;
239
+ }
240
+ /**
241
+ * Options for constructing an {@link EventLog}.
242
+ * @experimental
243
+ */
244
+ interface EventLogOptions {
245
+ /**
246
+ * Cap the number of entries retained in memory (REPLICA-06). When an
247
+ * append would exceed the cap, the OLDEST entries are evicted (ring
248
+ * buffer) — a `getSince`/`getFrom` call for a watermark below the oldest
249
+ * retained `seq` then returns only what's left, silently missing
250
+ * anything evicted.
251
+ *
252
+ * `undefined` (the default) preserves the original unbounded behavior.
253
+ * Set this only when you have another durable source of truth for
254
+ * anything older than the cap (a snapshot, a server-side `EventLogDO`) —
255
+ * see {@link EventLog#truncateBelow} for the caller-driven equivalent
256
+ * tied to snapshot persistence.
257
+ */
258
+ readonly maxEntries?: number;
215
259
  }
216
260
  /**
217
261
  * An append-only, in-memory event log for local event sourcing.
@@ -226,6 +270,7 @@ interface AppendOptions {
226
270
  */
227
271
  declare class EventLog {
228
272
  #private;
273
+ constructor(options?: EventLogOptions);
229
274
  /**
230
275
  * Append a new entry to the log.
231
276
  *
@@ -254,6 +299,10 @@ declare class EventLog {
254
299
  * This is the restore counterpart of {@link EventLog#snapshot}.
255
300
  * Restores `headSeq` from the snapshot so auto-parenting continues
256
301
  * after restore.
302
+ *
303
+ * Runs `#enforceCap()` after restoring so a snapshot captured under a
304
+ * different (or no) `maxEntries` can never leave this log over its
305
+ * configured capacity.
257
306
  */
258
307
  load(snapshot: EventLogSnapshot): void;
259
308
  /**
@@ -289,6 +338,24 @@ declare class EventLog {
289
338
  /** Remove all entries (primarily for testing). */
290
339
  clear(): void;
291
340
  /**
341
+ * Discard all entries with `seq < floorSeq` (REPLICA-06).
342
+ *
343
+ * `headSeq`/`nextSeq` are untouched (they're independent counters), so
344
+ * appends after a truncation continue the same sequence uninterrupted.
345
+ *
346
+ * **Caller-driven, not automatic**: only call this after the truncated
347
+ * range has already been durably captured elsewhere (a snapshot, a
348
+ * server-side `EventLogDO`) — truncating without such a floor makes any
349
+ * future `getSince`/`getFrom`/`EventSource.replayFromLog` call for a
350
+ * watermark below `floorSeq` silently miss the discarded entries. This is
351
+ * the hook the caller ties to snapshot persistence; the log itself has no
352
+ * concept of "already durably persisted".
353
+ *
354
+ * `floorSeq` must be a non-negative safe integer — `NaN` would make
355
+ * every comparison false and silently clear the entire log.
356
+ */
357
+ truncateBelow(floorSeq: number): void;
358
+ /**
292
359
  * Return an async generator that yields every entry starting from
293
360
  * `fromSeq` (default `0` = all entries).
294
361
  *
@@ -314,6 +381,18 @@ interface LocalMirrorOptions {
314
381
  /** Platform-specific SQLite adapter. */
315
382
  readonly db: SqliteAdapter;
316
383
  /**
384
+ * Cap the mirror's internal {@link EventLog} to this many entries
385
+ * (REPLICA-06). Every applied diff is recorded in the log — with no cap,
386
+ * a long-running client accumulates one entry per diff forever.
387
+ *
388
+ * `undefined` (the default) preserves unbounded retention. Set this when
389
+ * catch-up replication only ever needs a bounded recent window; older
390
+ * entries are silently evicted (oldest-first) once the cap is exceeded.
391
+ * See {@link EventLog#truncateBelow} for caller-driven truncation tied to
392
+ * a snapshot instead.
393
+ */
394
+ readonly maxEventLogEntries?: number;
395
+ /**
317
396
  * Table schemas the mirror should manage.
318
397
  *
319
398
  * On first use the mirror creates any missing tables automatically
@@ -399,6 +478,13 @@ declare class LocalMirror {
399
478
  */
400
479
  get db(): SqliteAdapter;
401
480
  /**
481
+ * Monotonically increasing version counter, bumped on every operation
482
+ * that changes mirrored data (`applyDiff`, `clearData`). Use this — not
483
+ * `eventLog.size` — as a `useSyncExternalStore` snapshot so operations
484
+ * that don't append to the log still trigger a re-render.
485
+ */
486
+ get version(): number;
487
+ /**
402
488
  * Apply a server-side diff to the local SQLite mirror.
403
489
  *
404
490
  * The diff is applied in a transaction and recorded in the event log
@@ -420,6 +506,11 @@ declare class LocalMirror {
420
506
  /**
421
507
  * Delete every row from all known tables (preserves the event log
422
508
  * and schema). Useful when re-syncing from scratch.
509
+ *
510
+ * Notifies `onChange` subscribers and bumps {@link LocalMirror.version}
511
+ * (REPLICA-09) even though nothing is appended to the event log — a
512
+ * consumer keyed only on `eventLog.size` would otherwise never learn the
513
+ * mirror was cleared and keep rendering deleted rows.
423
514
  */
424
515
  clearData(): void;
425
516
  /**
@@ -436,4 +527,4 @@ declare class LocalMirror {
436
527
  */
437
528
  get mirroredTables(): ReadonlyArray<string>;
438
529
  }
439
- export { AppendOptions as A, ClientSeq as C, EventLogEntry as E, GlobalSeq as G, InputEvent as I, LocalMirror as L, MirrorTableDef as M, RowChange as R, Seq as S, TableDiff as T, EventLog as a, EventLogSnapshot as b, LocalMirrorOptions as c, classifyChanges as d, createTableDiff as e, diffSize as f, isDiffEmpty as g, isGlobalSeq as h, isClientSeq as i, isInputEvent as j, mergeDiffs as m };
530
+ export { AppendOptions as A, ClientSeq as C, EventLogEntry as E, GlobalSeq as G, InputEvent as I, LocalMirror as L, MirrorTableDef as M, RowChange as R, Seq as S, TableDiff as T, EventLog as a, EventLogOptions as b, EventLogSnapshot as c, LocalMirrorOptions as d, classifyChanges as e, createTableDiff as f, diffSize as g, isDiffEmpty as h, isClientSeq as i, isGlobalSeq as j, isInputEvent as k, mergeDiffs as m };
package/dist/react.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { L as LocalMirror } from "./packem_shared/local-mirror.d-BUeOe5KC.mjs";
1
+ import { L as LocalMirror } from "./packem_shared/local-mirror.d-DL1XJBB3.mjs";
2
2
  import "./packem_shared/types.d-BuAWjEY5.mjs";
3
3
  /**
4
4
  * Options for the {@link useLocalQuery} hook.
package/dist/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { L as LocalMirror } from "./packem_shared/local-mirror.d-Cd8tAg-W.js";
1
+ import { L as LocalMirror } from "./packem_shared/local-mirror.d-ByIjd7sW.js";
2
2
  import "./packem_shared/types.d-BuAWjEY5.js";
3
3
  /**
4
4
  * Options for the {@link useLocalQuery} hook.
package/dist/react.mjs CHANGED
@@ -2,8 +2,8 @@ import { useSyncExternalStore } from 'react';
2
2
 
3
3
  const useLocalQuery = (mirror, sql, params, _options) => {
4
4
  const subscribe = (onStoreChange) => mirror.onChange(onStoreChange);
5
- const getSnapshot = () => mirror.eventLog.size;
6
- const getServerSnapshot = () => mirror.eventLog.size;
5
+ const getSnapshot = () => mirror.version;
6
+ const getServerSnapshot = () => mirror.version;
7
7
  useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
8
8
  try {
9
9
  return mirror.query(sql, params);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/replica",
3
- "version": "1.0.0-alpha.3",
3
+ "version": "1.0.0-alpha.5",
4
4
  "description": "Local-first replica runtime + local SQLite mirror for Lunora",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -1,162 +0,0 @@
1
- class EventLog {
2
- #entries = [];
3
- #nextSeq = 0;
4
- // eslint-disable-next-line unicorn/no-null -- public contract uses `null` for an empty log head
5
- #headSeq = null;
6
- append(typeOrEvent, payload, tableDiffs, options) {
7
- const type = typeof typeOrEvent === "string" ? typeOrEvent : typeOrEvent.type;
8
- const pl = typeof typeOrEvent === "string" ? payload : typeOrEvent.payload;
9
- let diffs;
10
- let resolvedOptions;
11
- if (typeof typeOrEvent === "string") {
12
- diffs = tableDiffs;
13
- resolvedOptions = options;
14
- } else {
15
- diffs = void 0;
16
- resolvedOptions = payload;
17
- }
18
- const parentSeqNumber = resolvedOptions?.parentSeqNum ?? this.#headSeq ?? void 0;
19
- const seq = this.#nextSeq;
20
- this.#nextSeq += 1;
21
- const entry = {
22
- seq,
23
- type,
24
- payload: pl,
25
- timestamp: Date.now(),
26
- tableDiffs: diffs,
27
- clientId: resolvedOptions?.clientId,
28
- sessionId: resolvedOptions?.sessionId,
29
- parentSeqNum: parentSeqNumber
30
- };
31
- this.#entries.push(entry);
32
- this.#headSeq = entry.seq;
33
- return entry;
34
- }
35
- /**
36
- * Atomically append multiple events to the log.
37
- *
38
- * All events are assigned sequential global sequence numbers and
39
- * automatically wired as a causal chain (each event's `parentSeqNum`
40
- * points to the previous event in the batch, or to the log head for
41
- * the first event).
42
- * @param events An array of events to commit atomically.
43
- * @returns The newly created entries in order.
44
- */
45
- commitAll(events) {
46
- if (events.length === 0) {
47
- return [];
48
- }
49
- const entries = [];
50
- for (const event of events) {
51
- const { type } = event;
52
- const payload = "payload" in event ? event.payload : void 0;
53
- const ts = "timestamp" in event ? event.timestamp : Date.now();
54
- const parentSeqNumber = entries.at(-1)?.seq ?? this.#headSeq ?? void 0;
55
- const seq = this.#nextSeq;
56
- this.#nextSeq += 1;
57
- const entry = {
58
- seq,
59
- type,
60
- payload,
61
- timestamp: ts,
62
- parentSeqNum: parentSeqNumber
63
- };
64
- this.#entries.push(entry);
65
- entries.push(entry);
66
- }
67
- this.#headSeq = entries.at(-1)?.seq ?? this.#headSeq;
68
- return entries;
69
- }
70
- /**
71
- * Replace the log contents with a previously captured snapshot.
72
- * This is the restore counterpart of {@link EventLog#snapshot}.
73
- * Restores `headSeq` from the snapshot so auto-parenting continues
74
- * after restore.
75
- */
76
- load(snapshot) {
77
- this.#entries = [...snapshot.entries];
78
- this.#nextSeq = snapshot.nextSeq;
79
- this.#headSeq = snapshot.headSeq;
80
- }
81
- // ── Queries ───────────────────────────────────────────────────────
82
- /**
83
- * Return **all** entries whose `seq >= sinceSeq`.
84
- * Useful for catch-up: "give me everything since my last watermark".
85
- */
86
- getSince(sinceSeq) {
87
- if (sinceSeq <= 0) {
88
- return [...this.#entries];
89
- }
90
- const first = this.#entries.findIndex((entry) => entry.seq >= sinceSeq);
91
- return first === -1 ? [] : this.#entries.slice(first);
92
- }
93
- /**
94
- * Paginated read starting at `fromSeq`.
95
- * @returns `{ entries, hasMore }` where `hasMore` is `true` when more
96
- * entries exist beyond the requested page.
97
- */
98
- getFrom(fromSeq, limit = 50) {
99
- const first = this.#entries.findIndex((entry) => entry.seq >= fromSeq);
100
- if (first === -1) {
101
- return { entries: [], hasMore: false };
102
- }
103
- const slice = this.#entries.slice(first, first + limit);
104
- return {
105
- entries: slice,
106
- hasMore: first + limit < this.#entries.length
107
- };
108
- }
109
- /**
110
- * Return all entries as a snapshot suitable for serialisation.
111
- */
112
- snapshot() {
113
- return {
114
- entries: [...this.#entries],
115
- nextSeq: this.#nextSeq,
116
- headSeq: this.#headSeq
117
- };
118
- }
119
- /** Number of entries currently in the log. */
120
- get size() {
121
- return this.#entries.length;
122
- }
123
- /** The next sequence number that will be assigned. */
124
- get nextSeq() {
125
- return this.#nextSeq;
126
- }
127
- /** Return `true` when there are no entries. */
128
- get isEmpty() {
129
- return this.#entries.length === 0;
130
- }
131
- /**
132
- * The sequence number of the last (most recent) entry, or `null`
133
- * when the log is empty. Used internally for auto-parenting and
134
- * exposed for consumers that need the causal head.
135
- */
136
- get headSeq() {
137
- return this.#headSeq;
138
- }
139
- /** Remove all entries (primarily for testing). */
140
- clear() {
141
- this.#entries = [];
142
- this.#nextSeq = 0;
143
- this.#headSeq = null;
144
- }
145
- /**
146
- * Return an async generator that yields every entry starting from
147
- * `fromSeq` (default `0` = all entries).
148
- *
149
- * Because `EventLog` is purely in-memory, the generator yields all
150
- * matching entries synchronously on first iteration and then completes.
151
- * For a streaming / push-based variant see {@link EventSource.events}.
152
- */
153
- // eslint-disable-next-line @typescript-eslint/require-await -- kept async so callers can uniformly `for await` over any event stream
154
- async *events(fromSeq = 0) {
155
- const entries = this.getSince(fromSeq);
156
- for (const entry of entries) {
157
- yield entry;
158
- }
159
- }
160
- }
161
-
162
- export { EventLog };
@@ -1,40 +0,0 @@
1
- const applyDiff = (current, diff) => {
2
- const next = new Map(current);
3
- for (const change of diff.changes) {
4
- switch (change.type) {
5
- case "delete": {
6
- next.delete(change.id);
7
- break;
8
- }
9
- case "insert": {
10
- const rawId = change.data.id;
11
- const id = typeof rawId === "string" || typeof rawId === "number" ? String(rawId) : crypto.randomUUID();
12
- next.set(id, { ...change.data, id });
13
- break;
14
- }
15
- case "update": {
16
- const existing = next.get(change.id);
17
- if (existing) {
18
- next.set(change.id, { ...existing, ...change.data });
19
- }
20
- break;
21
- }
22
- }
23
- }
24
- return next;
25
- };
26
- const applyDiffs = (current, diffs) => {
27
- let result = new Map(current);
28
- for (const diff of diffs) {
29
- result = applyDiff(result, diff);
30
- }
31
- return result;
32
- };
33
- const applyDiffToSnapshot = (snapshot, diff) => {
34
- const next = new Map(snapshot);
35
- const tableMap = next.get(diff.table) ?? /* @__PURE__ */ new Map();
36
- next.set(diff.table, applyDiff(tableMap, diff));
37
- return next;
38
- };
39
-
40
- export { applyDiff, applyDiffToSnapshot, applyDiffs };