@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.
Files changed (39) hide show
  1. package/LICENSE.md +231 -0
  2. package/README.md +192 -29
  3. package/dist/adapters/better-sqlite3.d.mts +27 -0
  4. package/dist/adapters/better-sqlite3.d.ts +27 -0
  5. package/dist/adapters/better-sqlite3.mjs +29 -0
  6. package/dist/adapters/sqlite-wasm.d.mts +25 -0
  7. package/dist/adapters/sqlite-wasm.d.ts +25 -0
  8. package/dist/adapters/sqlite-wasm.mjs +56 -0
  9. package/dist/adapters/sqljs.d.mts +19 -0
  10. package/dist/adapters/sqljs.d.ts +19 -0
  11. package/dist/adapters/sqljs.mjs +55 -0
  12. package/dist/index.d.mts +865 -0
  13. package/dist/index.d.ts +865 -0
  14. package/dist/index.mjs +20 -0
  15. package/dist/packem_shared/EventEmitter-CMZfct03.mjs +92 -0
  16. package/dist/packem_shared/EventLog-zMy7AYP4.mjs +162 -0
  17. package/dist/packem_shared/EventLogDO-CZYUvvSr.mjs +235 -0
  18. package/dist/packem_shared/EventLogDOClient-DGiEdi96.mjs +86 -0
  19. package/dist/packem_shared/EventSource-DfV4VoRD.mjs +195 -0
  20. package/dist/packem_shared/EventsSync-DkVbU0WV.mjs +91 -0
  21. package/dist/packem_shared/InMemorySnapshotStore-BHVAD-Bp.mjs +24 -0
  22. package/dist/packem_shared/LocalMirror-GeJ26eNe.mjs +188 -0
  23. package/dist/packem_shared/MaterializerRuntime-HqNXqJxp.mjs +204 -0
  24. package/dist/packem_shared/SubscriptionManager-C5xbw0pg.mjs +75 -0
  25. package/dist/packem_shared/applyDiff-BtbIl1D3.mjs +40 -0
  26. package/dist/packem_shared/applyDiffToDb-DQ1xZp5J.mjs +58 -0
  27. package/dist/packem_shared/classifyChanges-aZmkxgVI.mjs +38 -0
  28. package/dist/packem_shared/defineEvents-DiBkPTh_.mjs +28 -0
  29. package/dist/packem_shared/eventsContext-Bk_p48hj.mjs +6 -0
  30. package/dist/packem_shared/isClientSeq-C46BkzqJ.mjs +5 -0
  31. package/dist/packem_shared/local-mirror.d-Bp19ueGy.d.mts +412 -0
  32. package/dist/packem_shared/local-mirror.d-GhuZAKgm.d.ts +412 -0
  33. package/dist/packem_shared/subscribeToMirror-CiaM-nQ7.mjs +45 -0
  34. package/dist/packem_shared/types.d-VfJ76cK4.d.mts +23 -0
  35. package/dist/packem_shared/types.d-VfJ76cK4.d.ts +23 -0
  36. package/dist/react.d.mts +65 -0
  37. package/dist/react.d.ts +65 -0
  38. package/dist/react.mjs +15 -0
  39. 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 };