@lunora/replica 0.0.0 → 1.0.0-alpha.2
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.md +231 -0
- package/README.md +192 -29
- package/dist/adapters/better-sqlite3.d.mts +27 -0
- package/dist/adapters/better-sqlite3.d.ts +27 -0
- package/dist/adapters/better-sqlite3.mjs +29 -0
- package/dist/adapters/sqlite-wasm.d.mts +25 -0
- package/dist/adapters/sqlite-wasm.d.ts +25 -0
- package/dist/adapters/sqlite-wasm.mjs +56 -0
- package/dist/adapters/sqljs.d.mts +19 -0
- package/dist/adapters/sqljs.d.ts +19 -0
- package/dist/adapters/sqljs.mjs +55 -0
- package/dist/index.d.mts +865 -0
- package/dist/index.d.ts +865 -0
- package/dist/index.mjs +20 -0
- package/dist/packem_shared/EventEmitter-CMZfct03.mjs +92 -0
- package/dist/packem_shared/EventLog-zMy7AYP4.mjs +162 -0
- package/dist/packem_shared/EventLogDO-CZYUvvSr.mjs +235 -0
- package/dist/packem_shared/EventLogDOClient-DGiEdi96.mjs +86 -0
- package/dist/packem_shared/EventSource-DfV4VoRD.mjs +195 -0
- package/dist/packem_shared/EventsSync-DkVbU0WV.mjs +91 -0
- package/dist/packem_shared/InMemorySnapshotStore-BHVAD-Bp.mjs +24 -0
- package/dist/packem_shared/LocalMirror-GeJ26eNe.mjs +188 -0
- package/dist/packem_shared/MaterializerRuntime-HqNXqJxp.mjs +204 -0
- package/dist/packem_shared/SubscriptionManager-C5xbw0pg.mjs +75 -0
- package/dist/packem_shared/applyDiff-BtbIl1D3.mjs +40 -0
- package/dist/packem_shared/applyDiffToDb-DQ1xZp5J.mjs +58 -0
- package/dist/packem_shared/classifyChanges-aZmkxgVI.mjs +38 -0
- package/dist/packem_shared/defineEvents-DiBkPTh_.mjs +28 -0
- package/dist/packem_shared/eventsContext-Bk_p48hj.mjs +6 -0
- package/dist/packem_shared/isClientSeq-C46BkzqJ.mjs +5 -0
- package/dist/packem_shared/local-mirror.d-Bp19ueGy.d.mts +412 -0
- package/dist/packem_shared/local-mirror.d-GhuZAKgm.d.ts +412 -0
- package/dist/packem_shared/subscribeToMirror-CiaM-nQ7.mjs +45 -0
- package/dist/packem_shared/types.d-VfJ76cK4.d.mts +23 -0
- package/dist/packem_shared/types.d-VfJ76cK4.d.ts +23 -0
- package/dist/react.d.mts +65 -0
- package/dist/react.d.ts +65 -0
- package/dist/react.mjs +15 -0
- package/package.json +88 -7
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { EventEmitter } from './EventEmitter-CMZfct03.mjs';
|
|
2
|
+
import { EventLog } from './EventLog-zMy7AYP4.mjs';
|
|
3
|
+
|
|
4
|
+
class EventSource {
|
|
5
|
+
// eslint-disable-next-line unicorn/prefer-event-target -- EventEmitter is the library's typed public API
|
|
6
|
+
emitter = new EventEmitter();
|
|
7
|
+
log = new EventLog();
|
|
8
|
+
#state;
|
|
9
|
+
#reducer;
|
|
10
|
+
#replayed = false;
|
|
11
|
+
#unknownEventHandling;
|
|
12
|
+
/**
|
|
13
|
+
* Watermark over the EXTERNAL source log: the highest source `seq` already
|
|
14
|
+
* applied by {@link EventSource.replayFromLog}, or `-1` when nothing has been applied.
|
|
15
|
+
* Tracked separately from `this.log.nextSeq` (the destination log, which
|
|
16
|
+
* `applyEvent` and each replay append advance independently) so a repeated
|
|
17
|
+
* `replayFromLog` neither skips unseen source entries nor reprocesses
|
|
18
|
+
* already-applied ones.
|
|
19
|
+
*/
|
|
20
|
+
#lastAppliedSeq = -1;
|
|
21
|
+
constructor(initialState, reducer, options) {
|
|
22
|
+
this.#state = { ...initialState };
|
|
23
|
+
this.#reducer = reducer;
|
|
24
|
+
this.#unknownEventHandling = options?.unknownEventHandling ?? "warn";
|
|
25
|
+
}
|
|
26
|
+
// ── Public API ────────────────────────────────────────────────────
|
|
27
|
+
/**
|
|
28
|
+
* The current derived state. Read-only snapshot; mutate through events.
|
|
29
|
+
*/
|
|
30
|
+
get state() {
|
|
31
|
+
return this.#state;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether the initial replay from an existing log has completed.
|
|
35
|
+
*/
|
|
36
|
+
get replayed() {
|
|
37
|
+
return this.#replayed;
|
|
38
|
+
}
|
|
39
|
+
applyEvent(typeOrEvent, payload, options) {
|
|
40
|
+
let type;
|
|
41
|
+
let pl;
|
|
42
|
+
let resolvedOptions;
|
|
43
|
+
if (typeof typeOrEvent === "string") {
|
|
44
|
+
type = typeOrEvent;
|
|
45
|
+
pl = payload;
|
|
46
|
+
resolvedOptions = options;
|
|
47
|
+
} else {
|
|
48
|
+
type = typeOrEvent.type;
|
|
49
|
+
pl = typeOrEvent.payload;
|
|
50
|
+
resolvedOptions = payload;
|
|
51
|
+
}
|
|
52
|
+
const entry = this.log.append(type, pl, void 0, resolvedOptions);
|
|
53
|
+
this.#applyEntry(entry);
|
|
54
|
+
return entry;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Replay all entries from an existing {@link EventLog} to bootstrap
|
|
58
|
+
* the current state.
|
|
59
|
+
*
|
|
60
|
+
* Idempotent across calls: only source entries past the `#lastAppliedSeq`
|
|
61
|
+
* watermark are applied, so re-invoking picks up just the new entries.
|
|
62
|
+
* @param log The external log to replay from.
|
|
63
|
+
*/
|
|
64
|
+
replayFromLog(log) {
|
|
65
|
+
const entries = log.getSince(this.#lastAppliedSeq + 1);
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
try {
|
|
68
|
+
this.#state = this.#reducer(this.#state, entry);
|
|
69
|
+
this.log.append(entry.type, entry.payload, entry.tableDiffs, {
|
|
70
|
+
clientId: entry.clientId,
|
|
71
|
+
sessionId: entry.sessionId,
|
|
72
|
+
parentSeqNum: entry.parentSeqNum
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
this.emitter.emit("replay-error", {
|
|
76
|
+
entry,
|
|
77
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
78
|
+
});
|
|
79
|
+
} finally {
|
|
80
|
+
this.#lastAppliedSeq = entry.seq;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.#replayed = true;
|
|
84
|
+
this.emitter.emit("ready", { entryCount: this.log.size });
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Reset the runtime to a base state, optionally resuming from a watermark.
|
|
88
|
+
*
|
|
89
|
+
* Useful after loading a snapshot from the DO: pass the snapshot's state as
|
|
90
|
+
* `initialState` and its highest applied source `seq` as `resumeFromSeq`, so
|
|
91
|
+
* the next {@link replayFromLog} applies ONLY the events after the snapshot
|
|
92
|
+
* (`getSince(resumeFromSeq + 1)`) rather than replaying the whole log on top
|
|
93
|
+
* of the snapshot — which would double-apply non-idempotent reducers.
|
|
94
|
+
*
|
|
95
|
+
* Omit `resumeFromSeq` (default `-1`) for a full reset that replays from the
|
|
96
|
+
* beginning.
|
|
97
|
+
* @param initialState The base state to reset to (e.g. a loaded snapshot).
|
|
98
|
+
* @param resumeFromSeq Highest source `seq` already baked into `initialState`, or `-1` to replay all.
|
|
99
|
+
*/
|
|
100
|
+
reset(initialState, resumeFromSeq = -1) {
|
|
101
|
+
this.#state = { ...initialState };
|
|
102
|
+
this.#lastAppliedSeq = resumeFromSeq;
|
|
103
|
+
this.#replayed = resumeFromSeq >= 0;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Return an async generator that yields every event as it is applied,
|
|
107
|
+
* starting from the events currently in the log and continuing with
|
|
108
|
+
* every future `applyEvent` / `replayFromLog` call.
|
|
109
|
+
*
|
|
110
|
+
* The generator runs indefinitely — it never returns. Callers should
|
|
111
|
+
* break out of the `for await` loop or use an `AbortSignal` to stop.
|
|
112
|
+
* @example
|
|
113
|
+
* ```ts
|
|
114
|
+
* for await (const entry of source.events()) {
|
|
115
|
+
* console.log("event applied:", entry);
|
|
116
|
+
* }
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
async *events(signal) {
|
|
120
|
+
const buffer = [];
|
|
121
|
+
let wake;
|
|
122
|
+
const unsub = this.emitter.on("state-changed", ({ entry }) => {
|
|
123
|
+
buffer.push(entry);
|
|
124
|
+
wake?.();
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
let watermark = 0;
|
|
128
|
+
while (watermark < this.log.size) {
|
|
129
|
+
if (signal?.aborted) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
for (const entry of this.log.getSince(watermark)) {
|
|
133
|
+
yield entry;
|
|
134
|
+
}
|
|
135
|
+
watermark = this.log.nextSeq;
|
|
136
|
+
}
|
|
137
|
+
while (!signal?.aborted) {
|
|
138
|
+
while (buffer.length > 0) {
|
|
139
|
+
const nextEntry = buffer.shift();
|
|
140
|
+
if (nextEntry) {
|
|
141
|
+
yield nextEntry;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (buffer.length === 0) {
|
|
145
|
+
await new Promise((resolve) => {
|
|
146
|
+
wake = resolve;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
unsub();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// ── Internal ──────────────────────────────────────────────────────
|
|
155
|
+
#applyEntry(entry) {
|
|
156
|
+
const stateBefore = this.#state;
|
|
157
|
+
try {
|
|
158
|
+
this.#state = this.#reducer(this.#state, entry);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.emitter.emit("replay-error", {
|
|
161
|
+
entry,
|
|
162
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (this.#state === stateBefore && !this.#handleUnknown(entry)) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.emitter.emit("state-changed", { state: this.#state, entry });
|
|
170
|
+
}
|
|
171
|
+
#handleUnknown(entry) {
|
|
172
|
+
const strategy = this.#unknownEventHandling;
|
|
173
|
+
if (typeof strategy === "function") {
|
|
174
|
+
return strategy(entry);
|
|
175
|
+
}
|
|
176
|
+
switch (strategy) {
|
|
177
|
+
case "ignore": {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
case "fail": {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`EventSource: unhandled event type "${entry.type}" (seq ${String(entry.seq)}). Configure \`unknownEventHandling\` to handle this event or change the strategy.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
default: {
|
|
186
|
+
console.warn(
|
|
187
|
+
`[EventSource] unhandled event type "${entry.type}" (seq ${String(entry.seq)}). The event was skipped. Configure \`unknownEventHandling\` if this is expected.`
|
|
188
|
+
);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export { EventSource };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
class EventsSync {
|
|
2
|
+
#options;
|
|
3
|
+
/** The highest `seq + 1` that has been applied. Starts at `0`. */
|
|
4
|
+
#watermark = 0;
|
|
5
|
+
#timer;
|
|
6
|
+
/** Guards against overlapping poll cycles (e.g. slow fetch). */
|
|
7
|
+
#running = false;
|
|
8
|
+
// ── Constructor ─────────────────────────────────────────────────────
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.#options = options;
|
|
11
|
+
}
|
|
12
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
13
|
+
/**
|
|
14
|
+
* The current watermark — the next `seq` the sync will fetch from.
|
|
15
|
+
*
|
|
16
|
+
* Starts at `0` (fetch everything). Advances to `max(seq) + 1` after
|
|
17
|
+
* each successful poll cycle.
|
|
18
|
+
*/
|
|
19
|
+
get watermark() {
|
|
20
|
+
return this.#watermark;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Start polling for new events on the configured interval.
|
|
24
|
+
*
|
|
25
|
+
* Does nothing if polling is already active.
|
|
26
|
+
* Does **not** perform an initial sync — call {@link sync} once if you
|
|
27
|
+
* need to catch up immediately.
|
|
28
|
+
*/
|
|
29
|
+
start() {
|
|
30
|
+
if (this.#timer !== void 0) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const ms = this.#options.pollInterval ?? 5e3;
|
|
34
|
+
this.#timer = setInterval(() => {
|
|
35
|
+
this.#poll().catch(() => void 0);
|
|
36
|
+
}, ms);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Stop polling for new events.
|
|
40
|
+
*
|
|
41
|
+
* Safe to call when not started.
|
|
42
|
+
*/
|
|
43
|
+
stop() {
|
|
44
|
+
if (this.#timer !== void 0) {
|
|
45
|
+
clearInterval(this.#timer);
|
|
46
|
+
this.#timer = void 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Perform a one-shot sync: fetch events since the current watermark,
|
|
51
|
+
* apply them through the state machine, and push diffs to the mirror.
|
|
52
|
+
* @returns The number of events that were fetched and applied.
|
|
53
|
+
*/
|
|
54
|
+
async sync() {
|
|
55
|
+
return this.#poll();
|
|
56
|
+
}
|
|
57
|
+
// ── Internal ────────────────────────────────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* One poll cycle: fetch → apply → diff → mirror.
|
|
60
|
+
*/
|
|
61
|
+
async #poll() {
|
|
62
|
+
if (this.#running) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
this.#running = true;
|
|
66
|
+
try {
|
|
67
|
+
const events = await this.#options.fetchEventsSince(this.#watermark);
|
|
68
|
+
if (events.length === 0) {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
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);
|
|
79
|
+
}
|
|
80
|
+
return events.length;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const onError = this.#options.onError ?? console.error;
|
|
83
|
+
onError(error);
|
|
84
|
+
return 0;
|
|
85
|
+
} finally {
|
|
86
|
+
this.#running = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { EventsSync };
|
|
@@ -0,0 +1,24 @@
|
|
|
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 };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createSqlJsAdapter } from '../adapters/sqljs.mjs';
|
|
2
|
+
import { applyDiffToDb as applyDiffToDatabase, escapeIdentifier } from './applyDiffToDb-DQ1xZp5J.mjs';
|
|
3
|
+
import { EventLog } from './EventLog-zMy7AYP4.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 = new EventLog();
|
|
18
|
+
#changeListeners = /* @__PURE__ */ new Set();
|
|
19
|
+
/**
|
|
20
|
+
* Convenience factory that creates a {@link LocalMirror} backed by a
|
|
21
|
+
* {@link createSqlJsAdapter sql.js adapter} without needing to import
|
|
22
|
+
* and wire sql.js manually.
|
|
23
|
+
*
|
|
24
|
+
* The caller provides an initialised sql.js database — this method wraps
|
|
25
|
+
* it in an adapter and constructs the mirror.
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* import initSqlJs from "sql.js";
|
|
29
|
+
*
|
|
30
|
+
* const SQL = await initSqlJs();
|
|
31
|
+
* const mirror = LocalMirror.create(new SQL.Database(), {
|
|
32
|
+
* tables: { todos: { primaryKey: "id" } },
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
static create(sqlJsDatabase, options) {
|
|
37
|
+
const adapter = createSqlJsAdapter(sqlJsDatabase);
|
|
38
|
+
return new LocalMirror({ db: adapter, tables: options?.tables });
|
|
39
|
+
}
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.#db = options.db;
|
|
42
|
+
this.#tables = { ...options.tables };
|
|
43
|
+
ensureMetaTable(this.#db);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Subscribe to data-change notifications. Fires after every {@link applyDiff}.
|
|
47
|
+
* Returns an unsubscribe function.
|
|
48
|
+
*/
|
|
49
|
+
onChange(callback) {
|
|
50
|
+
this.#changeListeners.add(callback);
|
|
51
|
+
return () => {
|
|
52
|
+
this.#changeListeners.delete(callback);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
56
|
+
/**
|
|
57
|
+
* The in-memory event log tracking every diff applied to this mirror.
|
|
58
|
+
* Use {@link EventLog.getSince} for catch-up replication across tabs
|
|
59
|
+
* or service-worker instances.
|
|
60
|
+
*/
|
|
61
|
+
get eventLog() {
|
|
62
|
+
return this.#eventLog;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The raw SQLite adapter. Advanced consumers (e.g. the React hook)
|
|
66
|
+
* can use it for ad-hoc queries or bulk operations.
|
|
67
|
+
*/
|
|
68
|
+
get db() {
|
|
69
|
+
return this.#db;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Apply a server-side diff to the local SQLite mirror.
|
|
73
|
+
*
|
|
74
|
+
* The diff is applied in a transaction and recorded in the event log
|
|
75
|
+
* so other tabs or the SW can catch up.
|
|
76
|
+
*/
|
|
77
|
+
applyDiff(diff) {
|
|
78
|
+
if (diff.changes.length === 0) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const pkColumn = this.#tables[diff.table]?.primaryKey ?? "id";
|
|
82
|
+
this.#ensureTableSchema(diff);
|
|
83
|
+
applyDiffToDatabase(this.#db, diff, pkColumn);
|
|
84
|
+
this.#eventLog.append("table-diff", diff, [diff]);
|
|
85
|
+
for (const listener of this.#changeListeners) {
|
|
86
|
+
try {
|
|
87
|
+
listener();
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Run an arbitrary SQL query against the local mirror and return
|
|
94
|
+
* typed results.
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* const users = mirror.query<{ id: string; name: string }>(
|
|
98
|
+
* "SELECT id, name FROM users WHERE active = ?",
|
|
99
|
+
* [true],
|
|
100
|
+
* );
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
query(sql, params) {
|
|
104
|
+
return this.#db.query(sql, params);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Delete every row from all known tables (preserves the event log
|
|
108
|
+
* and schema). Useful when re-syncing from scratch.
|
|
109
|
+
*/
|
|
110
|
+
clearData() {
|
|
111
|
+
const tables = this.#db.query(
|
|
112
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__lunora_%' AND name NOT LIKE 'sqlite_%'`
|
|
113
|
+
);
|
|
114
|
+
this.#db.transaction(() => {
|
|
115
|
+
for (const { name } of tables) {
|
|
116
|
+
this.#db.exec(`DELETE FROM ${escapeIdentifier(name)}`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Dispose the mirror and close the database connection.
|
|
122
|
+
*/
|
|
123
|
+
close() {
|
|
124
|
+
this.#db.close();
|
|
125
|
+
this.#eventLog.clear();
|
|
126
|
+
}
|
|
127
|
+
// ── Schema helpers ─────────────────────────────────────────────────
|
|
128
|
+
/**
|
|
129
|
+
* Register a table schema so the mirror can create the table on
|
|
130
|
+
* first use.
|
|
131
|
+
*/
|
|
132
|
+
registerTable(name, definition) {
|
|
133
|
+
this.#tables[name] = definition;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Return the list of mirrored table names.
|
|
137
|
+
*/
|
|
138
|
+
get mirroredTables() {
|
|
139
|
+
return Object.keys(this.#tables);
|
|
140
|
+
}
|
|
141
|
+
// ── Internal ───────────────────────────────────────────────────────
|
|
142
|
+
/**
|
|
143
|
+
* Derive the UNION of non-PK column names across every non-delete change.
|
|
144
|
+
* @param diff The table diff whose changes are scanned.
|
|
145
|
+
* @param pk The primary-key column to exclude from the result.
|
|
146
|
+
*/
|
|
147
|
+
static #collectDiffColumns(diff, pk) {
|
|
148
|
+
const requiredColumns = /* @__PURE__ */ new Set();
|
|
149
|
+
for (const change of diff.changes) {
|
|
150
|
+
if (change.type === "delete") {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const key of Object.keys(change.data)) {
|
|
154
|
+
if (key !== pk) {
|
|
155
|
+
requiredColumns.add(key);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return requiredColumns;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Ensure the target table exists with all columns needed by the diff.
|
|
163
|
+
*
|
|
164
|
+
* - If the table doesn't exist yet, CREATE it with columns derived from the diff data (PK + every non-delete column).
|
|
165
|
+
* - 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).
|
|
166
|
+
*/
|
|
167
|
+
#ensureTableSchema(diff) {
|
|
168
|
+
const pk = this.#tables[diff.table]?.primaryKey ?? "id";
|
|
169
|
+
const requiredColumns = LocalMirror.#collectDiffColumns(diff, pk);
|
|
170
|
+
const existing = this.#db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, [diff.table]);
|
|
171
|
+
if (existing.length === 0) {
|
|
172
|
+
let columnDefs = `${escapeIdentifier(pk)} TEXT PRIMARY KEY NOT NULL`;
|
|
173
|
+
for (const key of requiredColumns) {
|
|
174
|
+
columnDefs += `, ${escapeIdentifier(key)} TEXT`;
|
|
175
|
+
}
|
|
176
|
+
this.#db.exec(`CREATE TABLE IF NOT EXISTS ${escapeIdentifier(diff.table)} (${columnDefs})`);
|
|
177
|
+
} else if (requiredColumns.size > 0) {
|
|
178
|
+
const existingColumns = new Set(this.#db.query(`PRAGMA table_info(${escapeIdentifier(diff.table)})`).map((row) => row.name));
|
|
179
|
+
for (const key of requiredColumns) {
|
|
180
|
+
if (!existingColumns.has(key)) {
|
|
181
|
+
this.#db.exec(`ALTER TABLE ${escapeIdentifier(diff.table)} ADD COLUMN ${escapeIdentifier(key)} TEXT`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export { LocalMirror };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
const defineMaterializer = (definition) => {
|
|
2
|
+
let state = definition.initial();
|
|
3
|
+
return {
|
|
4
|
+
def: definition,
|
|
5
|
+
get state() {
|
|
6
|
+
return Object.freeze(state);
|
|
7
|
+
},
|
|
8
|
+
setState(newState) {
|
|
9
|
+
state = newState;
|
|
10
|
+
},
|
|
11
|
+
apply(entry) {
|
|
12
|
+
state = definition.handle(state, entry);
|
|
13
|
+
},
|
|
14
|
+
reset() {
|
|
15
|
+
state = definition.initial();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
class MaterializerRuntime {
|
|
20
|
+
#materializers;
|
|
21
|
+
#snapshotStore;
|
|
22
|
+
#doClient;
|
|
23
|
+
#unknownEventHandling;
|
|
24
|
+
/**
|
|
25
|
+
* The highest event seq that has been applied to all materializers.
|
|
26
|
+
* Starts at `0` and advances monotonically.
|
|
27
|
+
*/
|
|
28
|
+
#appliedSeq = 0;
|
|
29
|
+
constructor(materializers, options = {}) {
|
|
30
|
+
this.#materializers = [...materializers];
|
|
31
|
+
this.#snapshotStore = options.snapshotStore;
|
|
32
|
+
this.#doClient = options.doClient;
|
|
33
|
+
this.#unknownEventHandling = options.unknownEventHandling ?? "warn";
|
|
34
|
+
}
|
|
35
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
36
|
+
/**
|
|
37
|
+
* The sequence number of the last event applied to all materializers.
|
|
38
|
+
*/
|
|
39
|
+
get appliedSeq() {
|
|
40
|
+
return this.#appliedSeq;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
applyEntries(entries) {
|
|
49
|
+
let count = 0;
|
|
50
|
+
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;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!stateChanged) {
|
|
66
|
+
this.#handleUnknownEvent(entry);
|
|
67
|
+
}
|
|
68
|
+
this.#appliedSeq = entry.seq + 1;
|
|
69
|
+
count += 1;
|
|
70
|
+
}
|
|
71
|
+
return count;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Apply the configured {@link UnknownEventHandling} strategy for an event
|
|
75
|
+
* that no materializer handled.
|
|
76
|
+
*/
|
|
77
|
+
#handleUnknownEvent(entry) {
|
|
78
|
+
const strategy = this.#unknownEventHandling;
|
|
79
|
+
if (typeof strategy === "function") {
|
|
80
|
+
strategy(entry);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
switch (strategy) {
|
|
84
|
+
case "ignore": {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case "fail": {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`MaterializerRuntime: unhandled event type "${entry.type}" (seq ${String(entry.seq)}). Configure \`unknownEventHandling\` to handle this event or change the strategy.`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
default: {
|
|
93
|
+
console.warn(
|
|
94
|
+
`[MaterializerRuntime] unhandled event type "${entry.type}" (seq ${String(entry.seq)}). The event was skipped. Configure \`unknownEventHandling\` if this is expected.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Attempt to recover materialized state from a snapshot store.
|
|
101
|
+
*
|
|
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`.
|
|
106
|
+
*/
|
|
107
|
+
async recoverFromSnapshots() {
|
|
108
|
+
if (!this.#snapshotStore) {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
let maxSeq = 0;
|
|
112
|
+
for (const m of this.#materializers) {
|
|
113
|
+
const raw = await this.#snapshotStore.load(m.def.name);
|
|
114
|
+
if (raw !== null && typeof raw === "object") {
|
|
115
|
+
const snapshot = raw;
|
|
116
|
+
if (snapshot.state !== void 0) {
|
|
117
|
+
m.setState(snapshot.state);
|
|
118
|
+
}
|
|
119
|
+
if (snapshot.appliedSeq > maxSeq) {
|
|
120
|
+
maxSeq = snapshot.appliedSeq;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (maxSeq > this.#appliedSeq) {
|
|
125
|
+
this.#appliedSeq = maxSeq;
|
|
126
|
+
}
|
|
127
|
+
return maxSeq;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Persist the current state of all materializers as snapshots.
|
|
131
|
+
*/
|
|
132
|
+
async persistSnapshots() {
|
|
133
|
+
if (!this.#snapshotStore) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const m of this.#materializers) {
|
|
137
|
+
await this.#snapshotStore.save(m.def.name, {
|
|
138
|
+
appliedSeq: this.#appliedSeq,
|
|
139
|
+
state: m.state
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ── DO-backed lifecycle (when a doClient is provided) ──────────────
|
|
144
|
+
/**
|
|
145
|
+
* Bootstrap the runtime from the EventLogDO.
|
|
146
|
+
*
|
|
147
|
+
* 1. Recover materialized state from snapshots (if a snapshotStore is
|
|
148
|
+
* configured).
|
|
149
|
+
* 2. Fetch all entries since the recovered watermark from the DO.
|
|
150
|
+
* 3. Apply them through the materializers.
|
|
151
|
+
*
|
|
152
|
+
* Call this once on startup / after the DO binding is available.
|
|
153
|
+
* @returns The number of entries applied during catch-up.
|
|
154
|
+
*/
|
|
155
|
+
async initialize() {
|
|
156
|
+
if (!this.#doClient) {
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
const snapshotSeq = await this.recoverFromSnapshots();
|
|
160
|
+
const entries = await this.#doClient.getSince(snapshotSeq);
|
|
161
|
+
if (entries.length === 0) {
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
return this.applyEntries(entries);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Append an event to the EventLogDO and apply it through all
|
|
168
|
+
* materializers.
|
|
169
|
+
*
|
|
170
|
+
* This is a convenience over calling `doClient.append(...)` +
|
|
171
|
+
* `runtime.applyEntries(...)` yourself — it persists the event
|
|
172
|
+
* **then** applies the returned entry (with its assigned seq).
|
|
173
|
+
* @returns The persisted entry with its DO-assigned `seq`.
|
|
174
|
+
*/
|
|
175
|
+
async appendEvent(input) {
|
|
176
|
+
if (!this.#doClient) {
|
|
177
|
+
throw new Error("MaterializerRuntime.appendEvent requires a doClient — pass one in the constructor options.");
|
|
178
|
+
}
|
|
179
|
+
const persisted = await this.#doClient.append([input]);
|
|
180
|
+
const entry = persisted[0];
|
|
181
|
+
if (!entry) {
|
|
182
|
+
throw new Error("MaterializerRuntime.appendEvent: DO returned empty result");
|
|
183
|
+
}
|
|
184
|
+
this.applyEntries([entry]);
|
|
185
|
+
return entry;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Reset all materializers to their initial state and clear snapshots.
|
|
189
|
+
*/
|
|
190
|
+
reset() {
|
|
191
|
+
this.#appliedSeq = 0;
|
|
192
|
+
for (const m of this.#materializers) {
|
|
193
|
+
m.reset();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The list of registered materializers.
|
|
198
|
+
*/
|
|
199
|
+
get materializers() {
|
|
200
|
+
return this.#materializers;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export { MaterializerRuntime, defineMaterializer };
|