@lunora/replica 1.0.0-alpha.8 → 1.0.0-alpha.80

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 (57) hide show
  1. package/LICENSE.md +214 -0
  2. package/README.md +55 -10
  3. package/dist/adapters/better-sqlite3.d.mts +1 -1
  4. package/dist/adapters/better-sqlite3.d.ts +1 -1
  5. package/dist/adapters/better-sqlite3.mjs +1 -29
  6. package/dist/adapters/sqlite-wasm.d.mts +3 -9
  7. package/dist/adapters/sqlite-wasm.d.ts +3 -9
  8. package/dist/adapters/sqlite-wasm.mjs +1 -44
  9. package/dist/adapters/sqljs.d.mts +1 -1
  10. package/dist/adapters/sqljs.d.ts +1 -1
  11. package/dist/adapters/sqljs.mjs +1 -55
  12. package/dist/index.d.mts +113 -52
  13. package/dist/index.d.ts +113 -52
  14. package/dist/index.mjs +1 -20
  15. package/dist/packem_shared/EventEmitter-uo75adUL.mjs +1 -0
  16. package/dist/packem_shared/EventLog-B1-yhArT.mjs +1 -0
  17. package/dist/packem_shared/EventLogDO-BF9ZWc6C.mjs +1 -0
  18. package/dist/packem_shared/EventLogDOClient-DWerZ3_n.mjs +1 -0
  19. package/dist/packem_shared/EventSource-BC0hKJSA.mjs +1 -0
  20. package/dist/packem_shared/EventsSync-B3wzXm-b.mjs +1 -0
  21. package/dist/packem_shared/InMemorySnapshotStore-C4taIG5K.mjs +1 -0
  22. package/dist/packem_shared/LocalMirror-ZmB8SJFe.mjs +4 -0
  23. package/dist/packem_shared/MaterializerRuntime-S-Knx6BM.mjs +1 -0
  24. package/dist/packem_shared/SubscriptionManager-AhPw3lFc.mjs +1 -0
  25. package/dist/packem_shared/applyDiff-DRJ1gap3.mjs +1 -0
  26. package/dist/packem_shared/applyDiffToDb-C6ek5Elp.mjs +1 -0
  27. package/dist/packem_shared/classifyChanges-BBc0-770.mjs +1 -0
  28. package/dist/packem_shared/defineEvents-DHo-VK7G.mjs +1 -0
  29. package/dist/packem_shared/eventsContext-Dxow9Y7S.mjs +1 -0
  30. package/dist/packem_shared/fnv1a-BNN96GYb.mjs +1 -0
  31. package/dist/packem_shared/isClientSeq-D2Xm0_lj.mjs +1 -0
  32. package/dist/packem_shared/{local-mirror.d-CyGOpUES.d.ts → local-mirror.d-CErKffFW.d.ts} +20 -12
  33. package/dist/packem_shared/{local-mirror.d-DTavX_y0.d.mts → local-mirror.d-CtQovAv_.d.mts} +20 -12
  34. package/dist/packem_shared/subscribeToMirror-BT4oOBng.mjs +1 -0
  35. package/dist/packem_shared/{types.d-CkMkSwLJ.d.mts → types.d-BuLTPLaQ.d.mts} +0 -2
  36. package/dist/packem_shared/{types.d-CkMkSwLJ.d.ts → types.d-BuLTPLaQ.d.ts} +0 -2
  37. package/dist/react.d.mts +39 -29
  38. package/dist/react.d.ts +39 -29
  39. package/dist/react.mjs +1 -15
  40. package/package.json +1 -1
  41. package/dist/packem_shared/EventEmitter-CMZfct03.mjs +0 -92
  42. package/dist/packem_shared/EventLog-CnK-3Wge.mjs +0 -264
  43. package/dist/packem_shared/EventLogDO-DqlsVx0H.mjs +0 -381
  44. package/dist/packem_shared/EventLogDOClient-F4FO8Si4.mjs +0 -92
  45. package/dist/packem_shared/EventSource-D5yO9_aI.mjs +0 -221
  46. package/dist/packem_shared/EventsSync-BP36tC9O.mjs +0 -123
  47. package/dist/packem_shared/InMemorySnapshotStore-BHVAD-Bp.mjs +0 -24
  48. package/dist/packem_shared/LocalMirror-a-5jEqFN.mjs +0 -219
  49. package/dist/packem_shared/MaterializerRuntime-BoIrsMYB.mjs +0 -224
  50. package/dist/packem_shared/SubscriptionManager-C5xbw0pg.mjs +0 -75
  51. package/dist/packem_shared/applyDiff-98tKzmiW.mjs +0 -67
  52. package/dist/packem_shared/applyDiffToDb-DQ1xZp5J.mjs +0 -58
  53. package/dist/packem_shared/classifyChanges-RcqLBpLs.mjs +0 -41
  54. package/dist/packem_shared/defineEvents-DiBkPTh_.mjs +0 -28
  55. package/dist/packem_shared/eventsContext-Bk_p48hj.mjs +0 -6
  56. package/dist/packem_shared/isClientSeq-C46BkzqJ.mjs +0 -5
  57. package/dist/packem_shared/subscribeToMirror-CiaM-nQ7.mjs +0 -45
@@ -1,92 +0,0 @@
1
- class EventLogDOClient {
2
- #fetch;
3
- constructor(options) {
4
- this.#fetch = options.fetch;
5
- }
6
- // ── Append ─────────────────────────────────────────────────────────
7
- /**
8
- * Append one or more events to the log.
9
- * @param events The events to append.
10
- * @param options Idempotency controls for the batch.
11
- * @param options.batchId Optional idempotency key for the whole batch — a
12
- * retried `append` call with the same `batchId` (e.g. after a network
13
- * timeout that hid a successful response) returns the originally-persisted
14
- * entries instead of inserting duplicates.
15
- * @returns The persisted entries with their assigned `seq` numbers.
16
- */
17
- async append(events, options) {
18
- const body = JSON.stringify({ events, batchId: options?.batchId });
19
- const response = await this.#fetch(
20
- new Request("https://do/append", {
21
- method: "POST",
22
- headers: { "content-type": "application/json" },
23
- body
24
- })
25
- );
26
- if (!response.ok) {
27
- throw await EventLogDOClient.#toError(response, "append");
28
- }
29
- const data = await response.json();
30
- return data.entries;
31
- }
32
- // ── Read / replay ───────────────────────────────────────────────────
33
- /**
34
- * Fetch all entries with `seq >= sinceSeq`.
35
- *
36
- * Pass `sinceSeq = 0` to fetch the entire log.
37
- */
38
- async getSince(sinceSeq) {
39
- const response = await this.#fetch(new Request(`https://do/since?seq=${String(sinceSeq)}`));
40
- if (!response.ok) {
41
- throw await EventLogDOClient.#toError(response, "getSince");
42
- }
43
- const data = await response.json();
44
- return data.entries;
45
- }
46
- /**
47
- * Fetch a paginated range of entries.
48
- * @returns `{ entries, hasMore }` — `hasMore` is `true` when another
49
- * page exists (i.e. the DO returned `limit + 1` rows).
50
- */
51
- async getRange(fromSeq, limit = 50) {
52
- const response = await this.#fetch(new Request(`https://do/range?from=${String(fromSeq)}&limit=${String(limit)}`));
53
- if (!response.ok) {
54
- throw await EventLogDOClient.#toError(response, "getRange");
55
- }
56
- return await response.json();
57
- }
58
- /**
59
- * Return the total number of entries currently in the log.
60
- */
61
- async getSize() {
62
- const response = await this.#fetch(new Request("https://do/size"));
63
- if (!response.ok) {
64
- throw await EventLogDOClient.#toError(response, "getSize");
65
- }
66
- const data = await response.json();
67
- return data.count;
68
- }
69
- /**
70
- * Return the full log state — all entries plus the next seq number.
71
- */
72
- async getState() {
73
- const response = await this.#fetch(new Request("https://do/state"));
74
- if (!response.ok) {
75
- throw await EventLogDOClient.#toError(response, "getState");
76
- }
77
- return await response.json();
78
- }
79
- // ── Internal ────────────────────────────────────────────────────────
80
- /** Parse an error response body and return a descriptive `Error`. */
81
- static async #toError(response, method) {
82
- try {
83
- const body = await response.json();
84
- const message = body.error?.message ?? response.statusText;
85
- return new Error(`EventLogDO.${method} failed (${String(response.status)}): ${message}`);
86
- } catch {
87
- return new Error(`EventLogDO.${method} failed (${String(response.status)}): ${response.statusText}`);
88
- }
89
- }
90
- }
91
-
92
- export { EventLogDOClient };
@@ -1,221 +0,0 @@
1
- import { EventEmitter } from './EventEmitter-CMZfct03.mjs';
2
- import { EventLog } from './EventLog-CnK-3Wge.mjs';
3
-
4
- const UNHANDLED = /* @__PURE__ */ Symbol("lunora.replica.event-source.unhandled");
5
- class EventSource {
6
- // eslint-disable-next-line unicorn/prefer-event-target -- EventEmitter is the library's typed public API
7
- emitter = new EventEmitter();
8
- log;
9
- #state;
10
- #reducer;
11
- #replayed = false;
12
- #unknownEventHandling;
13
- /**
14
- * Watermark over the EXTERNAL source log: the highest source `seq` already
15
- * applied by {@link EventSource.replayFromLog}, or `-1` when nothing has been applied.
16
- * Tracked separately from `this.log.nextSeq` (the destination log, which
17
- * `applyEvent` and each replay append advance independently) so a repeated
18
- * `replayFromLog` neither skips unseen source entries nor reprocesses
19
- * already-applied ones.
20
- */
21
- #lastAppliedSeq = -1;
22
- constructor(initialState, reducer, options) {
23
- this.#state = { ...initialState };
24
- this.#reducer = reducer;
25
- this.#unknownEventHandling = options?.unknownEventHandling ?? "warn";
26
- this.log = new EventLog({ maxEntries: options?.maxLogEntries });
27
- }
28
- // ── Public API ────────────────────────────────────────────────────
29
- /**
30
- * The current derived state. Read-only snapshot; mutate through events.
31
- */
32
- get state() {
33
- return this.#state;
34
- }
35
- /**
36
- * Whether the initial replay from an existing log has completed.
37
- */
38
- get replayed() {
39
- return this.#replayed;
40
- }
41
- applyEvent(typeOrEvent, payload, options) {
42
- let type;
43
- let pl;
44
- let resolvedOptions;
45
- if (typeof typeOrEvent === "string") {
46
- type = typeOrEvent;
47
- pl = payload;
48
- resolvedOptions = options;
49
- } else {
50
- type = typeOrEvent.type;
51
- pl = typeOrEvent.payload;
52
- resolvedOptions = payload;
53
- }
54
- const candidate = {
55
- seq: this.log.nextSeq,
56
- type,
57
- payload: pl,
58
- timestamp: Date.now(),
59
- clientId: resolvedOptions?.clientId,
60
- sessionId: resolvedOptions?.sessionId,
61
- parentSeqNum: resolvedOptions?.parentSeqNum ?? this.log.headSeq ?? void 0
62
- };
63
- let reduced;
64
- try {
65
- reduced = this.#reducer(this.#state, candidate);
66
- } catch (error) {
67
- const normalizedError = error instanceof Error ? error : new Error(String(error));
68
- this.emitter.emit("replay-error", {
69
- entry: candidate,
70
- error: normalizedError
71
- });
72
- throw normalizedError;
73
- }
74
- let handledByCallback = true;
75
- if (reduced === UNHANDLED) {
76
- handledByCallback = this.#handleUnknown(candidate);
77
- }
78
- const entry = this.log.append(type, pl, void 0, { ...resolvedOptions, timestamp: candidate.timestamp });
79
- if (reduced === UNHANDLED) {
80
- if (handledByCallback) {
81
- this.emitter.emit("state-changed", { state: this.#state, entry });
82
- }
83
- return entry;
84
- }
85
- this.#state = reduced;
86
- this.emitter.emit("state-changed", { state: this.#state, entry });
87
- return entry;
88
- }
89
- /**
90
- * Replay all entries from an existing {@link EventLog} to bootstrap
91
- * the current state.
92
- *
93
- * Idempotent across calls: only source entries past the `#lastAppliedSeq`
94
- * watermark are applied, so re-invoking picks up just the new entries.
95
- * @param log The external log to replay from.
96
- */
97
- replayFromLog(log) {
98
- const entries = log.getSince(this.#lastAppliedSeq + 1);
99
- for (const entry of entries) {
100
- try {
101
- const reduced = this.#reducer(this.#state, entry);
102
- if (reduced !== UNHANDLED) {
103
- this.#state = reduced;
104
- }
105
- this.log.append(entry.type, entry.payload, entry.tableDiffs, {
106
- clientId: entry.clientId,
107
- sessionId: entry.sessionId,
108
- parentSeqNum: entry.parentSeqNum
109
- });
110
- } catch (error) {
111
- this.emitter.emit("replay-error", {
112
- entry,
113
- error: error instanceof Error ? error : new Error(String(error))
114
- });
115
- } finally {
116
- this.#lastAppliedSeq = entry.seq;
117
- }
118
- }
119
- this.#replayed = true;
120
- this.emitter.emit("ready", { entryCount: this.log.size });
121
- }
122
- /**
123
- * Reset the runtime to a base state, optionally resuming from a watermark.
124
- *
125
- * Useful after loading a snapshot from the DO: pass the snapshot's state as
126
- * `initialState` and its highest applied source `seq` as `resumeFromSeq`, so
127
- * the next {@link replayFromLog} applies ONLY the events after the snapshot
128
- * (`getSince(resumeFromSeq + 1)`) rather than replaying the whole log on top
129
- * of the snapshot — which would double-apply non-idempotent reducers.
130
- *
131
- * Omit `resumeFromSeq` (default `-1`) for a full reset that replays from the
132
- * beginning.
133
- * @param initialState The base state to reset to (e.g. a loaded snapshot).
134
- * @param resumeFromSeq Highest source `seq` already baked into `initialState`, or `-1` to replay all.
135
- */
136
- reset(initialState, resumeFromSeq = -1) {
137
- this.#state = { ...initialState };
138
- this.#lastAppliedSeq = resumeFromSeq;
139
- this.#replayed = resumeFromSeq >= 0;
140
- }
141
- /**
142
- * Return an async generator that yields every event as it is applied,
143
- * starting from the events currently in the log and continuing with
144
- * every future `applyEvent` / `replayFromLog` call.
145
- *
146
- * The generator runs indefinitely — it never returns. Callers should
147
- * break out of the `for await` loop or use an `AbortSignal` to stop.
148
- * @example
149
- * ```ts
150
- * for await (const entry of source.events()) {
151
- * console.log("event applied:", entry);
152
- * }
153
- * ```
154
- */
155
- async *events(signal) {
156
- const buffer = [];
157
- let wake;
158
- const unsub = this.emitter.on("state-changed", ({ entry }) => {
159
- buffer.push(entry);
160
- wake?.();
161
- });
162
- try {
163
- let watermark = 0;
164
- while (watermark < this.log.size) {
165
- if (signal?.aborted) {
166
- return;
167
- }
168
- for (const entry of this.log.getSince(watermark)) {
169
- yield entry;
170
- }
171
- watermark = this.log.nextSeq;
172
- }
173
- while (!signal?.aborted) {
174
- while (buffer.length > 0) {
175
- const nextEntry = buffer.shift();
176
- if (nextEntry) {
177
- yield nextEntry;
178
- }
179
- }
180
- if (buffer.length === 0) {
181
- await new Promise((resolve) => {
182
- wake = resolve;
183
- });
184
- }
185
- }
186
- } finally {
187
- unsub();
188
- }
189
- }
190
- // ── Internal ──────────────────────────────────────────────────────
191
- /**
192
- * Explicit-sentinel detection (not `state === stateBefore` reference
193
- * equality) is what lets a reducer legitimately return `state` unchanged
194
- * for a type it DOES recognise without being misclassified as unhandled
195
- * (REPLICA-07).
196
- */
197
- #handleUnknown(entry) {
198
- const strategy = this.#unknownEventHandling;
199
- if (typeof strategy === "function") {
200
- return strategy(entry);
201
- }
202
- switch (strategy) {
203
- case "ignore": {
204
- return false;
205
- }
206
- case "fail": {
207
- throw new Error(
208
- `EventSource: unhandled event type "${entry.type}" (seq ${String(entry.seq)}). Configure \`unknownEventHandling\` to handle this event or change the strategy.`
209
- );
210
- }
211
- default: {
212
- console.warn(
213
- `[EventSource] unhandled event type "${entry.type}" (seq ${String(entry.seq)}). The event was skipped. Configure \`unknownEventHandling\` if this is expected.`
214
- );
215
- return false;
216
- }
217
- }
218
- }
219
- }
220
-
221
- export { EventSource, UNHANDLED };
@@ -1,123 +0,0 @@
1
- class EventsSync {
2
- #options;
3
- /** The highest `seq + 1` that has been applied. Starts at `0`. */
4
- #watermark = 0;
5
- #timer;
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;
13
- // ── Constructor ─────────────────────────────────────────────────────
14
- constructor(options) {
15
- this.#options = options;
16
- }
17
- // ── Public API ──────────────────────────────────────────────────────
18
- /**
19
- * The current watermark — the next `seq` the sync will fetch from.
20
- *
21
- * Starts at `0` (fetch everything). Advances to `max(seq) + 1` after
22
- * each successful poll cycle.
23
- */
24
- get watermark() {
25
- return this.#watermark;
26
- }
27
- /**
28
- * Start polling for new events on the configured interval.
29
- *
30
- * Does nothing if polling is already active.
31
- * Does **not** perform an initial sync — call {@link sync} once if you
32
- * need to catch up immediately.
33
- */
34
- start() {
35
- if (this.#timer !== void 0) {
36
- return;
37
- }
38
- const ms = this.#options.pollInterval ?? 5e3;
39
- this.#timer = setInterval(() => {
40
- this.#poll().catch(() => void 0);
41
- }, ms);
42
- }
43
- /**
44
- * Stop polling for new events.
45
- *
46
- * Safe to call when not started.
47
- */
48
- stop() {
49
- if (this.#timer !== void 0) {
50
- clearInterval(this.#timer);
51
- this.#timer = void 0;
52
- }
53
- }
54
- /**
55
- * Perform a one-shot sync: fetch events since the current watermark,
56
- * apply them through the state machine, and push diffs to the mirror.
57
- * @returns The number of events that were fetched and applied.
58
- */
59
- async sync() {
60
- return this.#poll();
61
- }
62
- // ── Internal ────────────────────────────────────────────────────────
63
- /**
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).
68
- */
69
- async #poll() {
70
- if (this.#inFlight) {
71
- return this.#inFlight;
72
- }
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() {
94
- try {
95
- const events = await this.#options.fetchEventsSince(this.#watermark);
96
- if (events.length === 0) {
97
- return 0;
98
- }
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);
113
- }
114
- return appliedCount;
115
- } catch (error) {
116
- const onError = this.#options.onError ?? console.error;
117
- onError(error);
118
- return 0;
119
- }
120
- }
121
- }
122
-
123
- export { EventsSync };
@@ -1,24 +0,0 @@
1
- class InMemorySnapshotStore {
2
- #store = /* @__PURE__ */ new Map();
3
- save(key, snapshot) {
4
- this.#store.set(key, structuredClone(snapshot));
5
- return Promise.resolve();
6
- }
7
- load(key) {
8
- const value = this.#store.get(key);
9
- return Promise.resolve(value === void 0 ? null : structuredClone(value));
10
- }
11
- list() {
12
- return Promise.resolve([...this.#store.keys()]);
13
- }
14
- delete(key) {
15
- this.#store.delete(key);
16
- return Promise.resolve();
17
- }
18
- clear() {
19
- this.#store.clear();
20
- return Promise.resolve();
21
- }
22
- }
23
-
24
- export { InMemorySnapshotStore };
@@ -1,219 +0,0 @@
1
- import { createSqlJsAdapter } from '../adapters/sqljs.mjs';
2
- import { applyDiffToDb as applyDiffToDatabase, escapeIdentifier } from './applyDiffToDb-DQ1xZp5J.mjs';
3
- import { EventLog } from './EventLog-CnK-3Wge.mjs';
4
-
5
- const MIRROR_META_TABLE = "__lunora_mirror_meta";
6
- const ensureMetaTable = (database) => {
7
- database.exec(
8
- `CREATE TABLE IF NOT EXISTS ${MIRROR_META_TABLE} (
9
- key TEXT PRIMARY KEY NOT NULL,
10
- value TEXT NOT NULL
11
- )`
12
- );
13
- };
14
- class LocalMirror {
15
- #db;
16
- #tables;
17
- #eventLog;
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;
27
- /**
28
- * Convenience factory that creates a {@link LocalMirror} backed by a
29
- * {@link createSqlJsAdapter sql.js adapter} without needing to import
30
- * and wire sql.js manually.
31
- *
32
- * The caller provides an initialised sql.js database — this method wraps
33
- * it in an adapter and constructs the mirror.
34
- * @example
35
- * ```ts
36
- * import initSqlJs from "sql.js";
37
- *
38
- * const SQL = await initSqlJs();
39
- * const mirror = LocalMirror.create(new SQL.Database(), {
40
- * tables: { todos: { primaryKey: "id" } },
41
- * });
42
- * ```
43
- */
44
- static create(sqlJsDatabase, options) {
45
- const adapter = createSqlJsAdapter(sqlJsDatabase);
46
- return new LocalMirror({ db: adapter, tables: options?.tables });
47
- }
48
- constructor(options) {
49
- this.#db = options.db;
50
- this.#tables = { ...options.tables };
51
- this.#eventLog = new EventLog({ maxEntries: options.maxEventLogEntries });
52
- ensureMetaTable(this.#db);
53
- }
54
- /**
55
- * Subscribe to data-change notifications. Fires after every {@link applyDiff}.
56
- * Returns an unsubscribe function.
57
- */
58
- onChange(callback) {
59
- this.#changeListeners.add(callback);
60
- return () => {
61
- this.#changeListeners.delete(callback);
62
- };
63
- }
64
- // ── Public API ─────────────────────────────────────────────────────
65
- /**
66
- * The in-memory event log tracking every diff applied to this mirror.
67
- * Use {@link EventLog.getSince} for catch-up replication across tabs
68
- * or service-worker instances.
69
- */
70
- get eventLog() {
71
- return this.#eventLog;
72
- }
73
- /**
74
- * The raw SQLite adapter. Advanced consumers (e.g. the React hook)
75
- * can use it for ad-hoc queries or bulk operations.
76
- */
77
- get db() {
78
- return this.#db;
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
- }
89
- /**
90
- * Apply a server-side diff to the local SQLite mirror.
91
- *
92
- * The diff is applied in a transaction and recorded in the event log
93
- * so other tabs or the SW can catch up.
94
- */
95
- applyDiff(diff) {
96
- if (diff.changes.length === 0) {
97
- return;
98
- }
99
- const pkColumn = this.#tables[diff.table]?.primaryKey ?? "id";
100
- this.#ensureTableSchema(diff);
101
- applyDiffToDatabase(this.#db, diff, pkColumn);
102
- this.#eventLog.append("table-diff", diff, [diff]);
103
- this.#version += 1;
104
- for (const listener of this.#changeListeners) {
105
- try {
106
- listener();
107
- } catch {
108
- }
109
- }
110
- }
111
- /**
112
- * Run an arbitrary SQL query against the local mirror and return
113
- * typed results.
114
- * @example
115
- * ```ts
116
- * const users = mirror.query<{ id: string; name: string }>(
117
- * "SELECT id, name FROM users WHERE active = ?",
118
- * [true],
119
- * );
120
- * ```
121
- */
122
- query(sql, params) {
123
- return this.#db.query(sql, params);
124
- }
125
- /**
126
- * Delete every row from all known tables (preserves the event log
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.
133
- */
134
- clearData() {
135
- const tables = this.#db.query(
136
- String.raw`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\_\_lunora\_%' ESCAPE '\' AND name NOT LIKE 'sqlite\_%' ESCAPE '\'`
137
- );
138
- this.#db.transaction(() => {
139
- for (const { name } of tables) {
140
- this.#db.exec(`DELETE FROM ${escapeIdentifier(name)}`);
141
- }
142
- });
143
- this.#version += 1;
144
- for (const listener of this.#changeListeners) {
145
- try {
146
- listener();
147
- } catch {
148
- }
149
- }
150
- }
151
- /**
152
- * Dispose the mirror and close the database connection.
153
- */
154
- close() {
155
- this.#db.close();
156
- this.#eventLog.clear();
157
- }
158
- // ── Schema helpers ─────────────────────────────────────────────────
159
- /**
160
- * Register a table schema so the mirror can create the table on
161
- * first use.
162
- */
163
- registerTable(name, definition) {
164
- this.#tables[name] = definition;
165
- }
166
- /**
167
- * Return the list of mirrored table names.
168
- */
169
- get mirroredTables() {
170
- return Object.keys(this.#tables);
171
- }
172
- // ── Internal ───────────────────────────────────────────────────────
173
- /**
174
- * Derive the UNION of non-PK column names across every non-delete change.
175
- * @param diff The table diff whose changes are scanned.
176
- * @param pk The primary-key column to exclude from the result.
177
- */
178
- static #collectDiffColumns(diff, pk) {
179
- const requiredColumns = /* @__PURE__ */ new Set();
180
- for (const change of diff.changes) {
181
- if (change.type === "delete") {
182
- continue;
183
- }
184
- for (const key of Object.keys(change.data)) {
185
- if (key !== pk) {
186
- requiredColumns.add(key);
187
- }
188
- }
189
- }
190
- return requiredColumns;
191
- }
192
- /**
193
- * Ensure the target table exists with all columns needed by the diff.
194
- *
195
- * - If the table doesn't exist yet, CREATE it with columns derived from the diff data (PK + every non-delete column).
196
- * - If the table already exists, ALTER TABLE ADD COLUMN for any keys in the diff that don't have a corresponding column yet (schema evolution).
197
- */
198
- #ensureTableSchema(diff) {
199
- const pk = this.#tables[diff.table]?.primaryKey ?? "id";
200
- const requiredColumns = LocalMirror.#collectDiffColumns(diff, pk);
201
- const existing = this.#db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, [diff.table]);
202
- if (existing.length === 0) {
203
- let columnDefs = `${escapeIdentifier(pk)} TEXT PRIMARY KEY NOT NULL`;
204
- for (const key of requiredColumns) {
205
- columnDefs += `, ${escapeIdentifier(key)} TEXT`;
206
- }
207
- this.#db.exec(`CREATE TABLE IF NOT EXISTS ${escapeIdentifier(diff.table)} (${columnDefs})`);
208
- } else if (requiredColumns.size > 0) {
209
- const existingColumns = new Set(this.#db.query(`PRAGMA table_info(${escapeIdentifier(diff.table)})`).map((row) => row.name));
210
- for (const key of requiredColumns) {
211
- if (!existingColumns.has(key)) {
212
- this.#db.exec(`ALTER TABLE ${escapeIdentifier(diff.table)} ADD COLUMN ${escapeIdentifier(key)} TEXT`);
213
- }
214
- }
215
- }
216
- }
217
- }
218
-
219
- export { LocalMirror };