@lunora/replica 1.0.0-alpha.4 → 1.0.0-alpha.6

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.
Files changed (31) hide show
  1. package/dist/adapters/better-sqlite3.d.mts +14 -14
  2. package/dist/adapters/better-sqlite3.d.ts +14 -14
  3. package/dist/adapters/sqlite-wasm.d.mts +29 -19
  4. package/dist/adapters/sqlite-wasm.d.ts +29 -19
  5. package/dist/adapters/sqlite-wasm.mjs +12 -24
  6. package/dist/adapters/sqljs.d.mts +10 -10
  7. package/dist/adapters/sqljs.d.ts +10 -10
  8. package/dist/index.d.mts +634 -574
  9. package/dist/index.d.ts +634 -574
  10. package/dist/index.mjs +9 -9
  11. package/dist/packem_shared/EventLog-CnK-3Wge.mjs +264 -0
  12. package/dist/packem_shared/{EventLogDO-CZYUvvSr.mjs → EventLogDO-DqlsVx0H.mjs} +155 -9
  13. package/dist/packem_shared/{EventLogDOClient-DGiEdi96.mjs → EventLogDOClient-F4FO8Si4.mjs} +8 -2
  14. package/dist/packem_shared/{EventSource-DfV4VoRD.mjs → EventSource-D5yO9_aI.mjs} +48 -22
  15. package/dist/packem_shared/{EventsSync-DkVbU0WV.mjs → EventsSync-BP36tC9O.mjs} +49 -17
  16. package/dist/packem_shared/{LocalMirror-GeJ26eNe.mjs → LocalMirror-a-5jEqFN.mjs} +34 -3
  17. package/dist/packem_shared/{MaterializerRuntime-HqNXqJxp.mjs → MaterializerRuntime-BoIrsMYB.mjs} +65 -45
  18. package/dist/packem_shared/applyDiff-98tKzmiW.mjs +67 -0
  19. package/dist/packem_shared/{classifyChanges-aZmkxgVI.mjs → classifyChanges-RcqLBpLs.mjs} +6 -3
  20. package/dist/packem_shared/local-mirror.d-CyGOpUES.d.ts +530 -0
  21. package/dist/packem_shared/local-mirror.d-DTavX_y0.d.mts +530 -0
  22. package/dist/packem_shared/{types.d-BuAWjEY5.d.mts → types.d-CkMkSwLJ.d.mts} +10 -10
  23. package/dist/packem_shared/{types.d-BuAWjEY5.d.ts → types.d-CkMkSwLJ.d.ts} +10 -10
  24. package/dist/react.d.mts +59 -59
  25. package/dist/react.d.ts +59 -59
  26. package/dist/react.mjs +2 -2
  27. package/package.json +1 -1
  28. package/dist/packem_shared/EventLog-zMy7AYP4.mjs +0 -162
  29. package/dist/packem_shared/applyDiff-BtbIl1D3.mjs +0 -40
  30. package/dist/packem_shared/local-mirror.d-BUeOe5KC.d.mts +0 -439
  31. package/dist/packem_shared/local-mirror.d-Cd8tAg-W.d.ts +0 -439
@@ -3,8 +3,13 @@ class EventsSync {
3
3
  /** The highest `seq + 1` that has been applied. Starts at `0`. */
4
4
  #watermark = 0;
5
5
  #timer;
6
- /** Guards against overlapping poll cycles (e.g. slow fetch). */
7
- #running = false;
6
+ /**
7
+ * The in-flight poll cycle, or `undefined` when idle. A concurrent
8
+ * `sync()`/timer tick AWAITS this instead of no-op'ing (REPLICA-08) —
9
+ * previously a concurrent call returned `0` immediately without waiting
10
+ * for the in-progress cycle to actually finish.
11
+ */
12
+ #inFlight;
8
13
  // ── Constructor ─────────────────────────────────────────────────────
9
14
  constructor(options) {
10
15
  this.#options = options;
@@ -56,34 +61,61 @@ class EventsSync {
56
61
  }
57
62
  // ── Internal ────────────────────────────────────────────────────────
58
63
  /**
59
- * One poll cycle: fetch → apply → diff → mirror.
64
+ * Entry point for a poll cycle. A cycle already in flight is AWAITED
65
+ * (not restarted, not no-op'd) so a `sync()` racing a timer tick — or two
66
+ * concurrent `sync()` calls — observes the real outcome of the one cycle
67
+ * that actually runs (REPLICA-08).
60
68
  */
61
69
  async #poll() {
62
- if (this.#running) {
63
- return 0;
70
+ if (this.#inFlight) {
71
+ return this.#inFlight;
64
72
  }
65
- this.#running = true;
73
+ const promise = this.#pollOnce().finally(() => {
74
+ this.#inFlight = void 0;
75
+ });
76
+ this.#inFlight = promise;
77
+ return promise;
78
+ }
79
+ /**
80
+ * One poll cycle: fetch → (apply → diff → mirror) per event.
81
+ *
82
+ * Each event is driven through the FULL pipeline — `applyEvents`,
83
+ * `getTableDiffs`, and `mirror.applyDiff` — atomically before the
84
+ * watermark advances past it (REPLICA-08). Advancing the watermark any
85
+ * earlier (e.g. right after `applyEvents`) would let a later throw from
86
+ * `getTableDiffs`/`mirror.applyDiff` skip mirror delivery for that event
87
+ * PERMANENTLY, since the next poll would never re-fetch it. Keeping the
88
+ * watermark pinned to the last event whose entire pipeline succeeded
89
+ * means the next poll re-fetches exactly the unapplied remainder — never
90
+ * re-applying a fully-succeeded event, never silently dropping one that
91
+ * partially failed.
92
+ */
93
+ async #pollOnce() {
66
94
  try {
67
95
  const events = await this.#options.fetchEventsSince(this.#watermark);
68
96
  if (events.length === 0) {
69
97
  return 0;
70
98
  }
71
- this.#options.applyEvents(events);
72
- const lastEvent = events[events.length - 1];
73
- if (lastEvent) {
74
- this.#watermark = lastEvent.seq + 1;
75
- }
76
- const diffs = this.#options.getTableDiffs();
77
- for (const diff of diffs) {
78
- this.#options.mirror.applyDiff(diff);
99
+ let appliedCount = 0;
100
+ try {
101
+ for (const event of events) {
102
+ this.#options.applyEvents([event]);
103
+ const diffs = this.#options.getTableDiffs();
104
+ for (const diff of diffs) {
105
+ this.#options.mirror.applyDiff(diff);
106
+ }
107
+ this.#watermark = event.seq + 1;
108
+ appliedCount += 1;
109
+ }
110
+ } catch (error) {
111
+ const onError = this.#options.onError ?? console.error;
112
+ onError(error);
79
113
  }
80
- return events.length;
114
+ return appliedCount;
81
115
  } catch (error) {
82
116
  const onError = this.#options.onError ?? console.error;
83
117
  onError(error);
84
118
  return 0;
85
- } finally {
86
- this.#running = false;
87
119
  }
88
120
  }
89
121
  }
@@ -1,6 +1,6 @@
1
1
  import { createSqlJsAdapter } from '../adapters/sqljs.mjs';
2
2
  import { applyDiffToDb as applyDiffToDatabase, escapeIdentifier } from './applyDiffToDb-DQ1xZp5J.mjs';
3
- import { EventLog } from './EventLog-zMy7AYP4.mjs';
3
+ import { EventLog } from './EventLog-CnK-3Wge.mjs';
4
4
 
5
5
  const MIRROR_META_TABLE = "__lunora_mirror_meta";
6
6
  const ensureMetaTable = (database) => {
@@ -14,8 +14,16 @@ const ensureMetaTable = (database) => {
14
14
  class LocalMirror {
15
15
  #db;
16
16
  #tables;
17
- #eventLog = new EventLog();
17
+ #eventLog;
18
18
  #changeListeners = /* @__PURE__ */ new Set();
19
+ /**
20
+ * Monotonically increasing counter bumped on every state-changing
21
+ * operation (`applyDiff`, `clearData`) — independent of `eventLog.size`.
22
+ * `clearData` doesn't grow the log (REPLICA-09), so a consumer that used
23
+ * `eventLog.size` as its `useSyncExternalStore` snapshot would never
24
+ * re-render after a clear; `version` changes on both operations.
25
+ */
26
+ #version = 0;
19
27
  /**
20
28
  * Convenience factory that creates a {@link LocalMirror} backed by a
21
29
  * {@link createSqlJsAdapter sql.js adapter} without needing to import
@@ -40,6 +48,7 @@ class LocalMirror {
40
48
  constructor(options) {
41
49
  this.#db = options.db;
42
50
  this.#tables = { ...options.tables };
51
+ this.#eventLog = new EventLog({ maxEntries: options.maxEventLogEntries });
43
52
  ensureMetaTable(this.#db);
44
53
  }
45
54
  /**
@@ -68,6 +77,15 @@ class LocalMirror {
68
77
  get db() {
69
78
  return this.#db;
70
79
  }
80
+ /**
81
+ * Monotonically increasing version counter, bumped on every operation
82
+ * that changes mirrored data (`applyDiff`, `clearData`). Use this — not
83
+ * `eventLog.size` — as a `useSyncExternalStore` snapshot so operations
84
+ * that don't append to the log still trigger a re-render.
85
+ */
86
+ get version() {
87
+ return this.#version;
88
+ }
71
89
  /**
72
90
  * Apply a server-side diff to the local SQLite mirror.
73
91
  *
@@ -82,6 +100,7 @@ class LocalMirror {
82
100
  this.#ensureTableSchema(diff);
83
101
  applyDiffToDatabase(this.#db, diff, pkColumn);
84
102
  this.#eventLog.append("table-diff", diff, [diff]);
103
+ this.#version += 1;
85
104
  for (const listener of this.#changeListeners) {
86
105
  try {
87
106
  listener();
@@ -106,16 +125,28 @@ class LocalMirror {
106
125
  /**
107
126
  * Delete every row from all known tables (preserves the event log
108
127
  * and schema). Useful when re-syncing from scratch.
128
+ *
129
+ * Notifies `onChange` subscribers and bumps {@link LocalMirror.version}
130
+ * (REPLICA-09) even though nothing is appended to the event log — a
131
+ * consumer keyed only on `eventLog.size` would otherwise never learn the
132
+ * mirror was cleared and keep rendering deleted rows.
109
133
  */
110
134
  clearData() {
111
135
  const tables = this.#db.query(
112
- `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__lunora_%' AND name NOT LIKE 'sqlite_%'`
136
+ String.raw`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\_\_lunora\_%' ESCAPE '\' AND name NOT LIKE 'sqlite\_%' ESCAPE '\'`
113
137
  );
114
138
  this.#db.transaction(() => {
115
139
  for (const { name } of tables) {
116
140
  this.#db.exec(`DELETE FROM ${escapeIdentifier(name)}`);
117
141
  }
118
142
  });
143
+ this.#version += 1;
144
+ for (const listener of this.#changeListeners) {
145
+ try {
146
+ listener();
147
+ } catch {
148
+ }
149
+ }
119
150
  }
120
151
  /**
121
152
  * Dispose the mirror and close the database connection.
@@ -22,50 +22,60 @@ class MaterializerRuntime {
22
22
  #doClient;
23
23
  #unknownEventHandling;
24
24
  /**
25
- * The highest event seq that has been applied to all materializers.
26
- * Starts at `0` and advances monotonically.
25
+ * Per-materializer watermark: the seq of the next event each materializer
26
+ * (by index, parallel to `#materializers`) has NOT yet applied. Starts at
27
+ * `0` for every materializer and advances independently — a materializer
28
+ * with no snapshot stays at `0` even when a sibling has recovered to a
29
+ * much higher watermark, so catch-up never skips events for it (REPLICA-04).
27
30
  */
28
- #appliedSeq = 0;
31
+ #watermarks;
29
32
  constructor(materializers, options = {}) {
30
33
  this.#materializers = [...materializers];
34
+ this.#watermarks = this.#materializers.map(() => 0);
31
35
  this.#snapshotStore = options.snapshotStore;
32
36
  this.#doClient = options.doClient;
33
37
  this.#unknownEventHandling = options.unknownEventHandling ?? "warn";
34
38
  }
35
39
  // ── Public API ──────────────────────────────────────────────────────
36
40
  /**
37
- * The sequence number of the last event applied to all materializers.
41
+ * The lowest per-materializer watermark — the seq of the next event that
42
+ * at least one materializer has not yet applied. `0` when there are no
43
+ * materializers.
38
44
  */
39
45
  get appliedSeq() {
40
- return this.#appliedSeq;
46
+ return this.#watermarks.length > 0 ? Math.min(...this.#watermarks) : 0;
41
47
  }
42
48
  /**
43
- * Replay a batch of entries through all materializers.
44
- *
45
- * Entries with `seq < this.appliedSeq` are silently skipped (idempotent).
46
- * @returns The number of entries actually applied.
49
+ * Replay a batch of entries, applying each entry only to the
50
+ * materializers whose own watermark is behind it — a materializer at or
51
+ * past an entry's seq (e.g. recovered from a snapshot, or already caught
52
+ * up) skips it, so no materializer ever double-applies an event.
53
+ * @returns The number of entries applied to at least one materializer.
47
54
  */
48
55
  applyEntries(entries) {
49
56
  let count = 0;
50
57
  for (const entry of entries) {
51
- if (entry.seq < this.#appliedSeq) {
52
- continue;
53
- }
54
- const statesBefore = this.#materializers.map((m) => m.state);
55
- let stateChanged = false;
56
- for (const m of this.#materializers) {
57
- m.apply(entry);
58
- }
59
- for (let i = 0; i < this.#materializers.length; i += 1) {
60
- if (this.#materializers[i]?.state !== statesBefore[i]) {
61
- stateChanged = true;
62
- break;
58
+ let appliedToAny = false;
59
+ let anyChanged = false;
60
+ for (const [i, materializer] of this.#materializers.entries()) {
61
+ const watermark = this.#watermarks[i] ?? 0;
62
+ if (entry.seq < watermark) {
63
+ continue;
63
64
  }
65
+ const stateBefore = materializer.state;
66
+ materializer.apply(entry);
67
+ appliedToAny = true;
68
+ if (materializer.state !== stateBefore) {
69
+ anyChanged = true;
70
+ }
71
+ this.#watermarks[i] = entry.seq + 1;
72
+ }
73
+ if (!appliedToAny) {
74
+ continue;
64
75
  }
65
- if (!stateChanged) {
76
+ if (!anyChanged) {
66
77
  this.#handleUnknownEvent(entry);
67
78
  }
68
- this.#appliedSeq = entry.seq + 1;
69
79
  count += 1;
70
80
  }
71
81
  return count;
@@ -99,44 +109,50 @@ class MaterializerRuntime {
99
109
  /**
100
110
  * Attempt to recover materialized state from a snapshot store.
101
111
  *
102
- * When a snapshot is found for a materializer, its state is restored
103
- * and the snapshot's watermark (`appliedSeq`) is returned so the caller
104
- * can skip replaying entries up to that point.
105
- * @returns The highest `appliedSeq` across all recovered snapshots, or `0`.
112
+ * When a snapshot is found for a materializer, its state AND its own
113
+ * watermark are restored from that snapshot. A materializer with no
114
+ * snapshot keeps its current watermark (`0` for a fresh runtime) — it
115
+ * does NOT inherit another materializer's watermark, so it still catches
116
+ * up from the very beginning (REPLICA-04: previously a shared watermark
117
+ * was bumped to the MAX across snapshots, permanently skipping events 0..N
118
+ * for any un-snapshotted or lagging materializer).
119
+ * @returns The highest snapshot `appliedSeq` across all materializers, or
120
+ * `0` — kept for backward compatibility; callers that need the fetch
121
+ * watermark for catch-up should use the per-materializer minimum instead
122
+ * (see `initialize`).
106
123
  */
107
124
  async recoverFromSnapshots() {
108
125
  if (!this.#snapshotStore) {
109
126
  return 0;
110
127
  }
111
128
  let maxSeq = 0;
112
- for (const m of this.#materializers) {
113
- const raw = await this.#snapshotStore.load(m.def.name);
129
+ for (const [i, materializer] of this.#materializers.entries()) {
130
+ const raw = await this.#snapshotStore.load(materializer.def.name);
114
131
  if (raw !== null && typeof raw === "object") {
115
132
  const snapshot = raw;
116
133
  if (snapshot.state !== void 0) {
117
- m.setState(snapshot.state);
134
+ materializer.setState(snapshot.state);
118
135
  }
136
+ this.#watermarks[i] = snapshot.appliedSeq;
119
137
  if (snapshot.appliedSeq > maxSeq) {
120
138
  maxSeq = snapshot.appliedSeq;
121
139
  }
122
140
  }
123
141
  }
124
- if (maxSeq > this.#appliedSeq) {
125
- this.#appliedSeq = maxSeq;
126
- }
127
142
  return maxSeq;
128
143
  }
129
144
  /**
130
- * Persist the current state of all materializers as snapshots.
145
+ * Persist the current state of all materializers as snapshots, each
146
+ * tagged with ITS OWN watermark (not a shared one).
131
147
  */
132
148
  async persistSnapshots() {
133
149
  if (!this.#snapshotStore) {
134
150
  return;
135
151
  }
136
- for (const m of this.#materializers) {
137
- await this.#snapshotStore.save(m.def.name, {
138
- appliedSeq: this.#appliedSeq,
139
- state: m.state
152
+ for (const [i, materializer] of this.#materializers.entries()) {
153
+ await this.#snapshotStore.save(materializer.def.name, {
154
+ appliedSeq: this.#watermarks[i] ?? 0,
155
+ state: materializer.state
140
156
  });
141
157
  }
142
158
  }
@@ -146,8 +162,11 @@ class MaterializerRuntime {
146
162
  *
147
163
  * 1. Recover materialized state from snapshots (if a snapshotStore is
148
164
  * configured).
149
- * 2. Fetch all entries since the recovered watermark from the DO.
150
- * 3. Apply them through the materializers.
165
+ * 2. Fetch all entries since the MINIMUM per-materializer watermark from
166
+ * the DO — not the maximum — so a materializer with no snapshot (or a
167
+ * lower one) still receives every event it hasn't seen (REPLICA-04).
168
+ * 3. Apply them through the materializers; `applyEntries` skips each
169
+ * entry for any materializer already past it, so nothing is double-applied.
151
170
  *
152
171
  * Call this once on startup / after the DO binding is available.
153
172
  * @returns The number of entries applied during catch-up.
@@ -156,8 +175,9 @@ class MaterializerRuntime {
156
175
  if (!this.#doClient) {
157
176
  return 0;
158
177
  }
159
- const snapshotSeq = await this.recoverFromSnapshots();
160
- const entries = await this.#doClient.getSince(snapshotSeq);
178
+ await this.recoverFromSnapshots();
179
+ const minWatermark = this.#watermarks.length > 0 ? Math.min(...this.#watermarks) : 0;
180
+ const entries = await this.#doClient.getSince(minWatermark);
161
181
  if (entries.length === 0) {
162
182
  return 0;
163
183
  }
@@ -188,9 +208,9 @@ class MaterializerRuntime {
188
208
  * Reset all materializers to their initial state and clear snapshots.
189
209
  */
190
210
  reset() {
191
- this.#appliedSeq = 0;
192
- for (const m of this.#materializers) {
193
- m.reset();
211
+ for (const [i, materializer] of this.#materializers.entries()) {
212
+ this.#watermarks[i] = 0;
213
+ materializer.reset();
194
214
  }
195
215
  }
196
216
  /**
@@ -0,0 +1,67 @@
1
+ const canonicalizeForHash = (value) => {
2
+ if (Array.isArray(value)) {
3
+ return value.map((item) => canonicalizeForHash(item));
4
+ }
5
+ if (value !== null && typeof value === "object") {
6
+ const record = value;
7
+ const sortedKeys = Object.keys(record).toSorted((a, b) => a.localeCompare(b));
8
+ const result = {};
9
+ for (const key of sortedKeys) {
10
+ result[key] = canonicalizeForHash(record[key]);
11
+ }
12
+ return result;
13
+ }
14
+ return value;
15
+ };
16
+ const deriveInsertId = (diff, changeIndex, data) => {
17
+ const diffIdentity = diff.id ?? String(diff.timestamp);
18
+ const input = `${diff.table}::${diffIdentity}::${String(changeIndex)}::${JSON.stringify(canonicalizeForHash(data))}`;
19
+ let hash = 0xcbf29ce484222325n;
20
+ const prime = 0x00000100000001b3n;
21
+ const mask64 = 0xffffffffffffffffn;
22
+ for (let index = 0; index < input.length; index += 1) {
23
+ hash ^= BigInt(input.codePointAt(index) ?? 0);
24
+ hash = hash * prime & mask64;
25
+ }
26
+ return `row-${hash.toString(16).padStart(16, "0")}`;
27
+ };
28
+ const applyDiff = (current, diff) => {
29
+ const next = new Map(current);
30
+ for (const [changeIndex, change] of diff.changes.entries()) {
31
+ switch (change.type) {
32
+ case "delete": {
33
+ next.delete(change.id);
34
+ break;
35
+ }
36
+ case "insert": {
37
+ const rawId = change.data.id;
38
+ const id = typeof rawId === "string" || typeof rawId === "number" ? String(rawId) : deriveInsertId(diff, changeIndex, change.data);
39
+ next.set(id, { ...change.data, id });
40
+ break;
41
+ }
42
+ case "update": {
43
+ const existing = next.get(change.id);
44
+ if (existing) {
45
+ next.set(change.id, { ...existing, ...change.data });
46
+ }
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ return next;
52
+ };
53
+ const applyDiffs = (current, diffs) => {
54
+ let result = new Map(current);
55
+ for (const diff of diffs) {
56
+ result = applyDiff(result, diff);
57
+ }
58
+ return result;
59
+ };
60
+ const applyDiffToSnapshot = (snapshot, diff) => {
61
+ const next = new Map(snapshot);
62
+ const tableMap = next.get(diff.table) ?? /* @__PURE__ */ new Map();
63
+ next.set(diff.table, applyDiff(tableMap, diff));
64
+ return next;
65
+ };
66
+
67
+ export { applyDiff, applyDiffToSnapshot, applyDiffs };
@@ -1,8 +1,9 @@
1
- const createTableDiff = (table, changes, timestamp) => {
1
+ const createTableDiff = (table, changes, timestamp, id) => {
2
2
  return {
3
3
  table,
4
4
  changes,
5
- timestamp: timestamp ?? Date.now()
5
+ timestamp: timestamp ?? Date.now(),
6
+ id: id ?? crypto.randomUUID()
6
7
  };
7
8
  };
8
9
  const isDiffEmpty = (diff) => diff.changes.length === 0;
@@ -28,10 +29,12 @@ const mergeDiffs = (diffs) => {
28
29
  }
29
30
  const first = diffs[0];
30
31
  const last = diffs[diffs.length - 1];
32
+ const mergedId = `merge:${diffs.map((d) => d.id ?? String(d.timestamp)).join("|")}`;
31
33
  return createTableDiff(
32
34
  first.table,
33
35
  diffs.flatMap((d) => d.changes),
34
- last.timestamp
36
+ last.timestamp,
37
+ mergedId
35
38
  );
36
39
  };
37
40