@lunora/replica 1.0.0-alpha.7 → 1.0.0-alpha.71
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 +214 -0
- package/dist/adapters/better-sqlite3.mjs +1 -29
- package/dist/adapters/sqlite-wasm.d.mts +2 -2
- package/dist/adapters/sqlite-wasm.d.ts +2 -2
- package/dist/adapters/sqlite-wasm.mjs +1 -44
- package/dist/adapters/sqljs.mjs +1 -55
- package/dist/index.d.mts +43 -16
- package/dist/index.d.ts +43 -16
- package/dist/index.mjs +1 -20
- package/dist/packem_shared/EventEmitter-uo75adUL.mjs +1 -0
- package/dist/packem_shared/EventLog-B1-yhArT.mjs +1 -0
- package/dist/packem_shared/EventLogDO-CaZvpgJN.mjs +1 -0
- package/dist/packem_shared/EventLogDOClient-DGDMj5L5.mjs +1 -0
- package/dist/packem_shared/EventSource-BC0hKJSA.mjs +1 -0
- package/dist/packem_shared/EventsSync-DWyGGZQZ.mjs +1 -0
- package/dist/packem_shared/InMemorySnapshotStore-C4taIG5K.mjs +1 -0
- package/dist/packem_shared/LocalMirror-BIURA-He.mjs +4 -0
- package/dist/packem_shared/MaterializerRuntime-D0HOAe24.mjs +1 -0
- package/dist/packem_shared/SubscriptionManager-AhPw3lFc.mjs +1 -0
- package/dist/packem_shared/applyDiff-Da160K7_.mjs +1 -0
- package/dist/packem_shared/applyDiffToDb-C6ek5Elp.mjs +1 -0
- package/dist/packem_shared/classifyChanges-ioMfjjbU.mjs +1 -0
- package/dist/packem_shared/defineEvents-DHo-VK7G.mjs +1 -0
- package/dist/packem_shared/eventsContext-Dxow9Y7S.mjs +1 -0
- package/dist/packem_shared/isClientSeq-D2Xm0_lj.mjs +1 -0
- package/dist/packem_shared/{local-mirror.d-CyGOpUES.d.ts → local-mirror.d-ByrYTq4z.d.ts} +2 -2
- package/dist/packem_shared/{local-mirror.d-DTavX_y0.d.mts → local-mirror.d-Cip3QuMf.d.mts} +2 -2
- package/dist/packem_shared/subscribeToMirror-CX10AaP3.mjs +1 -0
- package/dist/react.d.mts +41 -14
- package/dist/react.d.ts +41 -14
- package/dist/react.mjs +1 -15
- package/package.json +1 -1
- package/dist/packem_shared/EventEmitter-CMZfct03.mjs +0 -92
- package/dist/packem_shared/EventLog-CnK-3Wge.mjs +0 -264
- package/dist/packem_shared/EventLogDO-DqlsVx0H.mjs +0 -381
- package/dist/packem_shared/EventLogDOClient-F4FO8Si4.mjs +0 -92
- package/dist/packem_shared/EventSource-D5yO9_aI.mjs +0 -221
- package/dist/packem_shared/EventsSync-BP36tC9O.mjs +0 -123
- package/dist/packem_shared/InMemorySnapshotStore-BHVAD-Bp.mjs +0 -24
- package/dist/packem_shared/LocalMirror-a-5jEqFN.mjs +0 -219
- package/dist/packem_shared/MaterializerRuntime-BoIrsMYB.mjs +0 -224
- package/dist/packem_shared/SubscriptionManager-C5xbw0pg.mjs +0 -75
- package/dist/packem_shared/applyDiff-98tKzmiW.mjs +0 -67
- package/dist/packem_shared/applyDiffToDb-DQ1xZp5J.mjs +0 -58
- package/dist/packem_shared/classifyChanges-RcqLBpLs.mjs +0 -41
- package/dist/packem_shared/defineEvents-DiBkPTh_.mjs +0 -28
- package/dist/packem_shared/eventsContext-Bk_p48hj.mjs +0 -6
- package/dist/packem_shared/isClientSeq-C46BkzqJ.mjs +0 -5
- 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 };
|