@lunora/replica 1.0.0-alpha.3 → 1.0.0-alpha.5
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/dist/adapters/sqlite-wasm.d.mts +18 -8
- package/dist/adapters/sqlite-wasm.d.ts +18 -8
- package/dist/adapters/sqlite-wasm.mjs +12 -24
- package/dist/index.d.mts +79 -18
- package/dist/index.d.ts +79 -18
- package/dist/index.mjs +9 -9
- package/dist/packem_shared/EventLog-CnK-3Wge.mjs +264 -0
- package/dist/packem_shared/{EventLogDO-CZYUvvSr.mjs → EventLogDO-DqlsVx0H.mjs} +155 -9
- package/dist/packem_shared/{EventLogDOClient-DGiEdi96.mjs → EventLogDOClient-F4FO8Si4.mjs} +8 -2
- package/dist/packem_shared/{EventSource-DfV4VoRD.mjs → EventSource-D5yO9_aI.mjs} +48 -22
- package/dist/packem_shared/{EventsSync-DkVbU0WV.mjs → EventsSync-BP36tC9O.mjs} +49 -17
- package/dist/packem_shared/{LocalMirror-GeJ26eNe.mjs → LocalMirror-a-5jEqFN.mjs} +34 -3
- package/dist/packem_shared/{MaterializerRuntime-HqNXqJxp.mjs → MaterializerRuntime-BoIrsMYB.mjs} +65 -45
- package/dist/packem_shared/applyDiff-98tKzmiW.mjs +67 -0
- package/dist/packem_shared/{classifyChanges-aZmkxgVI.mjs → classifyChanges-RcqLBpLs.mjs} +6 -3
- package/dist/packem_shared/{local-mirror.d-Cd8tAg-W.d.ts → local-mirror.d-ByIjd7sW.d.ts} +94 -3
- package/dist/packem_shared/{local-mirror.d-BUeOe5KC.d.mts → local-mirror.d-DL1XJBB3.d.mts} +94 -3
- package/dist/react.d.mts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +2 -2
- package/package.json +1 -1
- package/dist/packem_shared/EventLog-zMy7AYP4.mjs +0 -162
- package/dist/packem_shared/applyDiff-BtbIl1D3.mjs +0 -40
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
class EventLog {
|
|
2
|
+
#entries = [];
|
|
3
|
+
/**
|
|
4
|
+
* Logical index of the first LIVE entry within `#entries` (REPLICA-06
|
|
5
|
+
* perf): entries at `[0, #headOffset)` have already been evicted by the
|
|
6
|
+
* cap but are not yet physically removed from the backing array. Bumping
|
|
7
|
+
* this offset is O(1), so a capped append never pays the O(n) cost of
|
|
8
|
+
* shifting the retained array — see `#enforceCap`.
|
|
9
|
+
*/
|
|
10
|
+
#headOffset = 0;
|
|
11
|
+
#nextSeq = 0;
|
|
12
|
+
// eslint-disable-next-line unicorn/no-null -- public contract uses `null` for an empty log head
|
|
13
|
+
#headSeq = null;
|
|
14
|
+
#maxEntries;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.#maxEntries = EventLog.#validateMaxEntries(options?.maxEntries);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Validate `maxEntries` as an invariant: `undefined` (uncapped) or a
|
|
20
|
+
* non-negative safe integer. Negative values would evict everything,
|
|
21
|
+
* fractional/NaN/Infinity values would leave the log over capacity or
|
|
22
|
+
* effectively disable the cap.
|
|
23
|
+
*/
|
|
24
|
+
static #validateMaxEntries(maxEntries) {
|
|
25
|
+
if (maxEntries === void 0) {
|
|
26
|
+
return void 0;
|
|
27
|
+
}
|
|
28
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 0) {
|
|
29
|
+
throw new RangeError("maxEntries must be a non-negative safe integer");
|
|
30
|
+
}
|
|
31
|
+
return maxEntries;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Entries currently considered live (excludes the evicted-but-not-yet-
|
|
35
|
+
* compacted prefix). ALWAYS returns a fresh copy — never the internal
|
|
36
|
+
* `#entries` array by reference — so a caller iterating the result (e.g.
|
|
37
|
+
* `EventSource.replayFromLog` replaying a log into itself) can't observe
|
|
38
|
+
* later appends made to `#entries` while iterating.
|
|
39
|
+
*/
|
|
40
|
+
#liveEntries() {
|
|
41
|
+
return this.#entries.slice(this.#headOffset);
|
|
42
|
+
}
|
|
43
|
+
// ── Mutators ──────────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Evict the oldest entries so the LIVE entry count never exceeds
|
|
46
|
+
* `#maxEntries`. `headSeq`/`nextSeq` are untouched — they're independent
|
|
47
|
+
* counters, not derived from the entries array — so appends after an
|
|
48
|
+
* eviction continue the same sequence.
|
|
49
|
+
*
|
|
50
|
+
* Eviction itself is O(1) (bump `#headOffset`) rather than an O(n)
|
|
51
|
+
* `splice(0, n)` on every capped append. The dead prefix is compacted
|
|
52
|
+
* away in one pass once it reaches half the backing array, which bounds
|
|
53
|
+
* memory growth while keeping the amortized cost of both append and
|
|
54
|
+
* compaction O(1) per entry.
|
|
55
|
+
*/
|
|
56
|
+
#enforceCap() {
|
|
57
|
+
if (this.#maxEntries === void 0) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const liveCount = this.#entries.length - this.#headOffset;
|
|
61
|
+
const overflow = liveCount - this.#maxEntries;
|
|
62
|
+
if (overflow > 0) {
|
|
63
|
+
this.#headOffset += overflow;
|
|
64
|
+
}
|
|
65
|
+
if (this.#headOffset > 0 && this.#headOffset >= this.#entries.length - this.#headOffset) {
|
|
66
|
+
this.#entries = this.#entries.slice(this.#headOffset);
|
|
67
|
+
this.#headOffset = 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
append(typeOrEvent, payload, tableDiffs, options) {
|
|
71
|
+
const type = typeof typeOrEvent === "string" ? typeOrEvent : typeOrEvent.type;
|
|
72
|
+
const pl = typeof typeOrEvent === "string" ? payload : typeOrEvent.payload;
|
|
73
|
+
let diffs;
|
|
74
|
+
let resolvedOptions;
|
|
75
|
+
if (typeof typeOrEvent === "string") {
|
|
76
|
+
diffs = tableDiffs;
|
|
77
|
+
resolvedOptions = options;
|
|
78
|
+
} else {
|
|
79
|
+
diffs = void 0;
|
|
80
|
+
resolvedOptions = payload;
|
|
81
|
+
}
|
|
82
|
+
const parentSeqNumber = resolvedOptions?.parentSeqNum ?? this.#headSeq ?? void 0;
|
|
83
|
+
const seq = this.#nextSeq;
|
|
84
|
+
this.#nextSeq += 1;
|
|
85
|
+
const entry = {
|
|
86
|
+
seq,
|
|
87
|
+
type,
|
|
88
|
+
payload: pl,
|
|
89
|
+
timestamp: resolvedOptions?.timestamp ?? Date.now(),
|
|
90
|
+
tableDiffs: diffs,
|
|
91
|
+
clientId: resolvedOptions?.clientId,
|
|
92
|
+
sessionId: resolvedOptions?.sessionId,
|
|
93
|
+
parentSeqNum: parentSeqNumber
|
|
94
|
+
};
|
|
95
|
+
this.#entries.push(entry);
|
|
96
|
+
this.#headSeq = entry.seq;
|
|
97
|
+
this.#enforceCap();
|
|
98
|
+
return entry;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Atomically append multiple events to the log.
|
|
102
|
+
*
|
|
103
|
+
* All events are assigned sequential global sequence numbers and
|
|
104
|
+
* automatically wired as a causal chain (each event's `parentSeqNum`
|
|
105
|
+
* points to the previous event in the batch, or to the log head for
|
|
106
|
+
* the first event).
|
|
107
|
+
* @param events An array of events to commit atomically.
|
|
108
|
+
* @returns The newly created entries in order.
|
|
109
|
+
*/
|
|
110
|
+
commitAll(events) {
|
|
111
|
+
if (events.length === 0) {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const entries = [];
|
|
115
|
+
for (const event of events) {
|
|
116
|
+
const { type } = event;
|
|
117
|
+
const payload = "payload" in event ? event.payload : void 0;
|
|
118
|
+
const ts = "timestamp" in event ? event.timestamp : Date.now();
|
|
119
|
+
const parentSeqNumber = entries.at(-1)?.seq ?? this.#headSeq ?? void 0;
|
|
120
|
+
const seq = this.#nextSeq;
|
|
121
|
+
this.#nextSeq += 1;
|
|
122
|
+
const entry = {
|
|
123
|
+
seq,
|
|
124
|
+
type,
|
|
125
|
+
payload,
|
|
126
|
+
timestamp: ts,
|
|
127
|
+
parentSeqNum: parentSeqNumber
|
|
128
|
+
};
|
|
129
|
+
this.#entries.push(entry);
|
|
130
|
+
entries.push(entry);
|
|
131
|
+
}
|
|
132
|
+
this.#headSeq = entries.at(-1)?.seq ?? this.#headSeq;
|
|
133
|
+
this.#enforceCap();
|
|
134
|
+
return entries;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Replace the log contents with a previously captured snapshot.
|
|
138
|
+
* This is the restore counterpart of {@link EventLog#snapshot}.
|
|
139
|
+
* Restores `headSeq` from the snapshot so auto-parenting continues
|
|
140
|
+
* after restore.
|
|
141
|
+
*
|
|
142
|
+
* Runs `#enforceCap()` after restoring so a snapshot captured under a
|
|
143
|
+
* different (or no) `maxEntries` can never leave this log over its
|
|
144
|
+
* configured capacity.
|
|
145
|
+
*/
|
|
146
|
+
load(snapshot) {
|
|
147
|
+
this.#entries = [...snapshot.entries];
|
|
148
|
+
this.#headOffset = 0;
|
|
149
|
+
this.#nextSeq = snapshot.nextSeq;
|
|
150
|
+
this.#headSeq = snapshot.headSeq;
|
|
151
|
+
this.#enforceCap();
|
|
152
|
+
}
|
|
153
|
+
// ── Queries ───────────────────────────────────────────────────────
|
|
154
|
+
/**
|
|
155
|
+
* Return **all** entries whose `seq >= sinceSeq`.
|
|
156
|
+
* Useful for catch-up: "give me everything since my last watermark".
|
|
157
|
+
*/
|
|
158
|
+
getSince(sinceSeq) {
|
|
159
|
+
const live = this.#liveEntries();
|
|
160
|
+
if (sinceSeq <= 0) {
|
|
161
|
+
return live;
|
|
162
|
+
}
|
|
163
|
+
const first = live.findIndex((entry) => entry.seq >= sinceSeq);
|
|
164
|
+
return first === -1 ? [] : live.slice(first);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Paginated read starting at `fromSeq`.
|
|
168
|
+
* @returns `{ entries, hasMore }` where `hasMore` is `true` when more
|
|
169
|
+
* entries exist beyond the requested page.
|
|
170
|
+
*/
|
|
171
|
+
getFrom(fromSeq, limit = 50) {
|
|
172
|
+
const entries = this.#entries;
|
|
173
|
+
const start = this.#headOffset;
|
|
174
|
+
const first = entries.findIndex((entry, index) => index >= start && entry.seq >= fromSeq);
|
|
175
|
+
if (first === -1) {
|
|
176
|
+
return { entries: [], hasMore: false };
|
|
177
|
+
}
|
|
178
|
+
const end = Math.min(first + limit, entries.length);
|
|
179
|
+
return {
|
|
180
|
+
entries: entries.slice(first, end),
|
|
181
|
+
hasMore: end < entries.length
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Return all entries as a snapshot suitable for serialisation.
|
|
186
|
+
*/
|
|
187
|
+
snapshot() {
|
|
188
|
+
return {
|
|
189
|
+
entries: this.#liveEntries(),
|
|
190
|
+
nextSeq: this.#nextSeq,
|
|
191
|
+
headSeq: this.#headSeq
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/** Number of entries currently in the log. */
|
|
195
|
+
get size() {
|
|
196
|
+
return this.#entries.length - this.#headOffset;
|
|
197
|
+
}
|
|
198
|
+
/** The next sequence number that will be assigned. */
|
|
199
|
+
get nextSeq() {
|
|
200
|
+
return this.#nextSeq;
|
|
201
|
+
}
|
|
202
|
+
/** Return `true` when there are no entries. */
|
|
203
|
+
get isEmpty() {
|
|
204
|
+
return this.size === 0;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The sequence number of the last (most recent) entry, or `null`
|
|
208
|
+
* when the log is empty. Used internally for auto-parenting and
|
|
209
|
+
* exposed for consumers that need the causal head.
|
|
210
|
+
*/
|
|
211
|
+
get headSeq() {
|
|
212
|
+
return this.#headSeq;
|
|
213
|
+
}
|
|
214
|
+
/** Remove all entries (primarily for testing). */
|
|
215
|
+
clear() {
|
|
216
|
+
this.#entries = [];
|
|
217
|
+
this.#headOffset = 0;
|
|
218
|
+
this.#nextSeq = 0;
|
|
219
|
+
this.#headSeq = null;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Discard all entries with `seq < floorSeq` (REPLICA-06).
|
|
223
|
+
*
|
|
224
|
+
* `headSeq`/`nextSeq` are untouched (they're independent counters), so
|
|
225
|
+
* appends after a truncation continue the same sequence uninterrupted.
|
|
226
|
+
*
|
|
227
|
+
* **Caller-driven, not automatic**: only call this after the truncated
|
|
228
|
+
* range has already been durably captured elsewhere (a snapshot, a
|
|
229
|
+
* server-side `EventLogDO`) — truncating without such a floor makes any
|
|
230
|
+
* future `getSince`/`getFrom`/`EventSource.replayFromLog` call for a
|
|
231
|
+
* watermark below `floorSeq` silently miss the discarded entries. This is
|
|
232
|
+
* the hook the caller ties to snapshot persistence; the log itself has no
|
|
233
|
+
* concept of "already durably persisted".
|
|
234
|
+
*
|
|
235
|
+
* `floorSeq` must be a non-negative safe integer — `NaN` would make
|
|
236
|
+
* every comparison false and silently clear the entire log.
|
|
237
|
+
*/
|
|
238
|
+
truncateBelow(floorSeq) {
|
|
239
|
+
if (!Number.isSafeInteger(floorSeq) || floorSeq < 0) {
|
|
240
|
+
throw new RangeError("floorSeq must be a non-negative safe integer");
|
|
241
|
+
}
|
|
242
|
+
const live = this.#liveEntries();
|
|
243
|
+
const cutoff = live.findIndex((entry) => entry.seq >= floorSeq);
|
|
244
|
+
this.#entries = cutoff === -1 ? [] : live.slice(cutoff);
|
|
245
|
+
this.#headOffset = 0;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Return an async generator that yields every entry starting from
|
|
249
|
+
* `fromSeq` (default `0` = all entries).
|
|
250
|
+
*
|
|
251
|
+
* Because `EventLog` is purely in-memory, the generator yields all
|
|
252
|
+
* matching entries synchronously on first iteration and then completes.
|
|
253
|
+
* For a streaming / push-based variant see {@link EventSource.events}.
|
|
254
|
+
*/
|
|
255
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- kept async so callers can uniformly `for await` over any event stream
|
|
256
|
+
async *events(fromSeq = 0) {
|
|
257
|
+
const entries = this.getSince(fromSeq);
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
yield entry;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export { EventLog };
|
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
const IDEMPOTENCY_CONFLICT = /* @__PURE__ */ Symbol("lunora.replica.event-log-do.idempotency-conflict");
|
|
2
|
+
const createIdempotencyConflictError = (message) => {
|
|
3
|
+
const error = new Error(message);
|
|
4
|
+
Object.defineProperty(error, IDEMPOTENCY_CONFLICT, { value: true });
|
|
5
|
+
return error;
|
|
6
|
+
};
|
|
7
|
+
const isIdempotencyConflictError = (error) => error instanceof Error && IDEMPOTENCY_CONFLICT in error;
|
|
8
|
+
const canonicalizeForFingerprint = (value) => {
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
return value.map((item) => canonicalizeForFingerprint(item));
|
|
11
|
+
}
|
|
12
|
+
if (value !== null && typeof value === "object") {
|
|
13
|
+
const record = value;
|
|
14
|
+
const sortedKeys = Object.keys(record).toSorted((a, b) => a.localeCompare(b));
|
|
15
|
+
const result = {};
|
|
16
|
+
for (const key of sortedKeys) {
|
|
17
|
+
result[key] = canonicalizeForFingerprint(record[key]);
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
const fingerprintBatch = async (events) => {
|
|
24
|
+
const canonical = JSON.stringify(canonicalizeForFingerprint(events));
|
|
25
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
26
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
27
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
28
|
+
};
|
|
1
29
|
const toArray = (cursor) => {
|
|
2
30
|
if (typeof cursor.toArray === "function") {
|
|
3
31
|
return cursor.toArray();
|
|
@@ -85,34 +113,130 @@ class EventLogDO {
|
|
|
85
113
|
}
|
|
86
114
|
);
|
|
87
115
|
}
|
|
88
|
-
|
|
116
|
+
const validationError = EventLogDO.#validateAppendRequest(body);
|
|
117
|
+
if (validationError) {
|
|
89
118
|
return Response.json(
|
|
90
|
-
{ error: { code: "BAD_REQUEST", message:
|
|
119
|
+
{ error: { code: "BAD_REQUEST", message: validationError } },
|
|
91
120
|
{
|
|
92
121
|
status: 400,
|
|
93
122
|
headers: { "content-type": "application/json" }
|
|
94
123
|
}
|
|
95
124
|
);
|
|
96
125
|
}
|
|
97
|
-
const entries = [];
|
|
98
126
|
const { sql } = this.state.storage;
|
|
127
|
+
const { batchId } = body;
|
|
128
|
+
const runBatch = () => EventLogDO.#runAppendBatch(sql, body, batchId);
|
|
129
|
+
const { transaction } = this.state.storage;
|
|
130
|
+
let entries;
|
|
131
|
+
try {
|
|
132
|
+
entries = typeof transaction === "function" ? await transaction(runBatch) : await runBatch();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (isIdempotencyConflictError(error)) {
|
|
135
|
+
return Response.json({ error: { code: "CONFLICT", message: error.message } }, { status: 409, headers: { "content-type": "application/json" } });
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
const response = { entries };
|
|
140
|
+
return Response.json(response, {
|
|
141
|
+
status: 200,
|
|
142
|
+
headers: { "content-type": "application/json" }
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Execute one `/append` batch: idempotent lookup (fingerprint-checked) or
|
|
147
|
+
* insert + record. Extracted out of `#handleAppend` (which only wires up
|
|
148
|
+
* the HTTP request/response and the transaction wrapper) to keep that
|
|
149
|
+
* method's cognitive complexity within budget.
|
|
150
|
+
*
|
|
151
|
+
* Idempotent replay: a batch already persisted under `batchId` returns
|
|
152
|
+
* the originally-persisted entries instead of inserting a duplicate copy
|
|
153
|
+
* — but ONLY when the incoming request's fingerprint matches what was
|
|
154
|
+
* persisted under that key. A `batchId` reused for a genuinely different
|
|
155
|
+
* event batch throws an idempotency-conflict error instead of silently
|
|
156
|
+
* dropping the new events and returning unrelated entries.
|
|
157
|
+
*/
|
|
158
|
+
static async #runAppendBatch(sql, body, batchId) {
|
|
159
|
+
let fingerprint;
|
|
160
|
+
if (typeof batchId === "string") {
|
|
161
|
+
fingerprint = await fingerprintBatch(body.events);
|
|
162
|
+
const existing = EventLogDO.#findBatch(sql, batchId);
|
|
163
|
+
if (existing) {
|
|
164
|
+
if (existing.fingerprint !== fingerprint) {
|
|
165
|
+
throw createIdempotencyConflictError(`batchId "${batchId}" was already used for a different event batch`);
|
|
166
|
+
}
|
|
167
|
+
return existing.entries;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
99
170
|
const now = Date.now();
|
|
171
|
+
const entries = [];
|
|
100
172
|
for (const eventRecord of body.events) {
|
|
101
173
|
const seq = EventLogDO.#nextSeq(sql);
|
|
102
174
|
const entry = {
|
|
103
175
|
seq,
|
|
104
176
|
type: eventRecord.type,
|
|
105
177
|
payload: eventRecord.payload,
|
|
106
|
-
timestamp: eventRecord.timestamp ?? now
|
|
178
|
+
timestamp: eventRecord.timestamp ?? now,
|
|
179
|
+
clientId: eventRecord.clientId,
|
|
180
|
+
sessionId: eventRecord.sessionId,
|
|
181
|
+
parentSeqNum: eventRecord.parentSeqNum
|
|
107
182
|
};
|
|
108
183
|
EventLogDO.#insertEvent(sql, entry);
|
|
109
184
|
entries.push(entry);
|
|
110
185
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
186
|
+
if (typeof batchId === "string" && fingerprint !== void 0) {
|
|
187
|
+
const firstSeq = entries[0]?.seq;
|
|
188
|
+
const lastSeq = entries.at(-1)?.seq;
|
|
189
|
+
if (firstSeq !== void 0 && lastSeq !== void 0) {
|
|
190
|
+
EventLogDO.#recordBatch(sql, batchId, firstSeq, lastSeq, fingerprint);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return entries;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Validate an `/append` request body up front so malformed input surfaces
|
|
197
|
+
* as a clean 400 instead of a 500 from the generic catch handler (e.g. a
|
|
198
|
+
* non-string `type` or a non-finite `timestamp` landing in a NOT NULL /
|
|
199
|
+
* INTEGER column).
|
|
200
|
+
* @returns An error message, or `undefined` when the body is valid.
|
|
201
|
+
*/
|
|
202
|
+
static #validateAppendRequest(body) {
|
|
203
|
+
if (!Array.isArray(body.events) || body.events.length === 0) {
|
|
204
|
+
return "events[] with a non-empty string `type` required";
|
|
205
|
+
}
|
|
206
|
+
if (body.batchId !== void 0 && (typeof body.batchId !== "string" || body.batchId.length === 0)) {
|
|
207
|
+
return "batchId must be a non-empty string";
|
|
208
|
+
}
|
|
209
|
+
for (const eventRecord of body.events) {
|
|
210
|
+
const error = EventLogDO.#validateEventRecord(eventRecord);
|
|
211
|
+
if (error !== void 0) {
|
|
212
|
+
return error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return void 0;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Validate a single `/append` event record — split out of
|
|
219
|
+
* {@link EventLogDO.#validateAppendRequest} to keep each function's
|
|
220
|
+
* cognitive complexity down.
|
|
221
|
+
* @returns An error message, or `undefined` when the record is valid.
|
|
222
|
+
*/
|
|
223
|
+
static #validateEventRecord(eventRecord) {
|
|
224
|
+
if (typeof eventRecord.type !== "string" || eventRecord.type.length === 0) {
|
|
225
|
+
return "events[] with a non-empty string `type` required";
|
|
226
|
+
}
|
|
227
|
+
if (eventRecord.timestamp !== void 0 && !Number.isFinite(eventRecord.timestamp)) {
|
|
228
|
+
return "events[].timestamp must be a finite number";
|
|
229
|
+
}
|
|
230
|
+
if (eventRecord.clientId !== void 0 && typeof eventRecord.clientId !== "string") {
|
|
231
|
+
return "events[].clientId must be a string";
|
|
232
|
+
}
|
|
233
|
+
if (eventRecord.sessionId !== void 0 && typeof eventRecord.sessionId !== "string") {
|
|
234
|
+
return "events[].sessionId must be a string";
|
|
235
|
+
}
|
|
236
|
+
if (eventRecord.parentSeqNum !== void 0 && (typeof eventRecord.parentSeqNum !== "number" || !Number.isInteger(eventRecord.parentSeqNum) || eventRecord.parentSeqNum < 0)) {
|
|
237
|
+
return "events[].parentSeqNum must be a non-negative integer";
|
|
238
|
+
}
|
|
239
|
+
return void 0;
|
|
116
240
|
}
|
|
117
241
|
/** GET /since?seq=N — return entries with seq >= N. */
|
|
118
242
|
#handleSince(url) {
|
|
@@ -203,8 +327,30 @@ class EventLogDO {
|
|
|
203
327
|
sql.exec(
|
|
204
328
|
"CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, payload TEXT NOT NULL, timestamp INTEGER NOT NULL, client_id TEXT, session_id TEXT, parent_seq INTEGER)"
|
|
205
329
|
);
|
|
330
|
+
sql.exec(
|
|
331
|
+
"CREATE TABLE IF NOT EXISTS event_batches (batch_id TEXT PRIMARY KEY, first_seq INTEGER NOT NULL, last_seq INTEGER NOT NULL, fingerprint TEXT NOT NULL)"
|
|
332
|
+
);
|
|
206
333
|
this.#initialized = true;
|
|
207
334
|
}
|
|
335
|
+
/** Look up a previously-persisted batch by its idempotency key. */
|
|
336
|
+
static #findBatch(sql, batchId) {
|
|
337
|
+
const batchCursor = sql.exec("SELECT first_seq, last_seq, fingerprint FROM event_batches WHERE batch_id = ?", batchId);
|
|
338
|
+
const batchRows = toArray(batchCursor);
|
|
339
|
+
const batchRow = batchRows[0];
|
|
340
|
+
if (!batchRow) {
|
|
341
|
+
return void 0;
|
|
342
|
+
}
|
|
343
|
+
const cursor = sql.exec(
|
|
344
|
+
"SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? AND seq <= ? ORDER BY seq ASC",
|
|
345
|
+
batchRow.first_seq,
|
|
346
|
+
batchRow.last_seq
|
|
347
|
+
);
|
|
348
|
+
return { entries: rowsToEntries(cursor), fingerprint: batchRow.fingerprint };
|
|
349
|
+
}
|
|
350
|
+
/** Record a persisted batch's seq range and request fingerprint under its idempotency key. */
|
|
351
|
+
static #recordBatch(sql, batchId, firstSeq, lastSeq, fingerprint) {
|
|
352
|
+
sql.exec("INSERT INTO event_batches (batch_id, first_seq, last_seq, fingerprint) VALUES (?, ?, ?, ?)", batchId, firstSeq, lastSeq, fingerprint);
|
|
353
|
+
}
|
|
208
354
|
/** Get the next available sequence number. */
|
|
209
355
|
static #nextSeq(sql) {
|
|
210
356
|
const cursor = sql.exec("SELECT COALESCE(MAX(seq), -1) + 1 AS next_seq FROM events");
|
|
@@ -6,10 +6,16 @@ class EventLogDOClient {
|
|
|
6
6
|
// ── Append ─────────────────────────────────────────────────────────
|
|
7
7
|
/**
|
|
8
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.
|
|
9
15
|
* @returns The persisted entries with their assigned `seq` numbers.
|
|
10
16
|
*/
|
|
11
|
-
async append(events) {
|
|
12
|
-
const body = JSON.stringify({ events });
|
|
17
|
+
async append(events, options) {
|
|
18
|
+
const body = JSON.stringify({ events, batchId: options?.batchId });
|
|
13
19
|
const response = await this.#fetch(
|
|
14
20
|
new Request("https://do/append", {
|
|
15
21
|
method: "POST",
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { EventEmitter } from './EventEmitter-CMZfct03.mjs';
|
|
2
|
-
import { EventLog } from './EventLog-
|
|
2
|
+
import { EventLog } from './EventLog-CnK-3Wge.mjs';
|
|
3
3
|
|
|
4
|
+
const UNHANDLED = /* @__PURE__ */ Symbol("lunora.replica.event-source.unhandled");
|
|
4
5
|
class EventSource {
|
|
5
6
|
// eslint-disable-next-line unicorn/prefer-event-target -- EventEmitter is the library's typed public API
|
|
6
7
|
emitter = new EventEmitter();
|
|
7
|
-
log
|
|
8
|
+
log;
|
|
8
9
|
#state;
|
|
9
10
|
#reducer;
|
|
10
11
|
#replayed = false;
|
|
@@ -22,6 +23,7 @@ class EventSource {
|
|
|
22
23
|
this.#state = { ...initialState };
|
|
23
24
|
this.#reducer = reducer;
|
|
24
25
|
this.#unknownEventHandling = options?.unknownEventHandling ?? "warn";
|
|
26
|
+
this.log = new EventLog({ maxEntries: options?.maxLogEntries });
|
|
25
27
|
}
|
|
26
28
|
// ── Public API ────────────────────────────────────────────────────
|
|
27
29
|
/**
|
|
@@ -49,8 +51,39 @@ class EventSource {
|
|
|
49
51
|
pl = typeOrEvent.payload;
|
|
50
52
|
resolvedOptions = payload;
|
|
51
53
|
}
|
|
52
|
-
const
|
|
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 });
|
|
54
87
|
return entry;
|
|
55
88
|
}
|
|
56
89
|
/**
|
|
@@ -65,7 +98,10 @@ class EventSource {
|
|
|
65
98
|
const entries = log.getSince(this.#lastAppliedSeq + 1);
|
|
66
99
|
for (const entry of entries) {
|
|
67
100
|
try {
|
|
68
|
-
|
|
101
|
+
const reduced = this.#reducer(this.#state, entry);
|
|
102
|
+
if (reduced !== UNHANDLED) {
|
|
103
|
+
this.#state = reduced;
|
|
104
|
+
}
|
|
69
105
|
this.log.append(entry.type, entry.payload, entry.tableDiffs, {
|
|
70
106
|
clientId: entry.clientId,
|
|
71
107
|
sessionId: entry.sessionId,
|
|
@@ -152,22 +188,12 @@ class EventSource {
|
|
|
152
188
|
}
|
|
153
189
|
}
|
|
154
190
|
// ── Internal ──────────────────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
}
|
|
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
|
+
*/
|
|
171
197
|
#handleUnknown(entry) {
|
|
172
198
|
const strategy = this.#unknownEventHandling;
|
|
173
199
|
if (typeof strategy === "function") {
|
|
@@ -192,4 +218,4 @@ class EventSource {
|
|
|
192
218
|
}
|
|
193
219
|
}
|
|
194
220
|
|
|
195
|
-
export { EventSource };
|
|
221
|
+
export { EventSource, UNHANDLED };
|
|
@@ -3,8 +3,13 @@ class EventsSync {
|
|
|
3
3
|
/** The highest `seq + 1` that has been applied. Starts at `0`. */
|
|
4
4
|
#watermark = 0;
|
|
5
5
|
#timer;
|
|
6
|
-
/**
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* The in-flight poll cycle, or `undefined` when idle. A concurrent
|
|
8
|
+
* `sync()`/timer tick AWAITS this instead of no-op'ing (REPLICA-08) —
|
|
9
|
+
* previously a concurrent call returned `0` immediately without waiting
|
|
10
|
+
* for the in-progress cycle to actually finish.
|
|
11
|
+
*/
|
|
12
|
+
#inFlight;
|
|
8
13
|
// ── Constructor ─────────────────────────────────────────────────────
|
|
9
14
|
constructor(options) {
|
|
10
15
|
this.#options = options;
|
|
@@ -56,34 +61,61 @@ class EventsSync {
|
|
|
56
61
|
}
|
|
57
62
|
// ── Internal ────────────────────────────────────────────────────────
|
|
58
63
|
/**
|
|
59
|
-
*
|
|
64
|
+
* Entry point for a poll cycle. A cycle already in flight is AWAITED
|
|
65
|
+
* (not restarted, not no-op'd) so a `sync()` racing a timer tick — or two
|
|
66
|
+
* concurrent `sync()` calls — observes the real outcome of the one cycle
|
|
67
|
+
* that actually runs (REPLICA-08).
|
|
60
68
|
*/
|
|
61
69
|
async #poll() {
|
|
62
|
-
if (this.#
|
|
63
|
-
return
|
|
70
|
+
if (this.#inFlight) {
|
|
71
|
+
return this.#inFlight;
|
|
64
72
|
}
|
|
65
|
-
this.#
|
|
73
|
+
const promise = this.#pollOnce().finally(() => {
|
|
74
|
+
this.#inFlight = void 0;
|
|
75
|
+
});
|
|
76
|
+
this.#inFlight = promise;
|
|
77
|
+
return promise;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One poll cycle: fetch → (apply → diff → mirror) per event.
|
|
81
|
+
*
|
|
82
|
+
* Each event is driven through the FULL pipeline — `applyEvents`,
|
|
83
|
+
* `getTableDiffs`, and `mirror.applyDiff` — atomically before the
|
|
84
|
+
* watermark advances past it (REPLICA-08). Advancing the watermark any
|
|
85
|
+
* earlier (e.g. right after `applyEvents`) would let a later throw from
|
|
86
|
+
* `getTableDiffs`/`mirror.applyDiff` skip mirror delivery for that event
|
|
87
|
+
* PERMANENTLY, since the next poll would never re-fetch it. Keeping the
|
|
88
|
+
* watermark pinned to the last event whose entire pipeline succeeded
|
|
89
|
+
* means the next poll re-fetches exactly the unapplied remainder — never
|
|
90
|
+
* re-applying a fully-succeeded event, never silently dropping one that
|
|
91
|
+
* partially failed.
|
|
92
|
+
*/
|
|
93
|
+
async #pollOnce() {
|
|
66
94
|
try {
|
|
67
95
|
const events = await this.#options.fetchEventsSince(this.#watermark);
|
|
68
96
|
if (events.length === 0) {
|
|
69
97
|
return 0;
|
|
70
98
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
let appliedCount = 0;
|
|
100
|
+
try {
|
|
101
|
+
for (const event of events) {
|
|
102
|
+
this.#options.applyEvents([event]);
|
|
103
|
+
const diffs = this.#options.getTableDiffs();
|
|
104
|
+
for (const diff of diffs) {
|
|
105
|
+
this.#options.mirror.applyDiff(diff);
|
|
106
|
+
}
|
|
107
|
+
this.#watermark = event.seq + 1;
|
|
108
|
+
appliedCount += 1;
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const onError = this.#options.onError ?? console.error;
|
|
112
|
+
onError(error);
|
|
79
113
|
}
|
|
80
|
-
return
|
|
114
|
+
return appliedCount;
|
|
81
115
|
} catch (error) {
|
|
82
116
|
const onError = this.#options.onError ?? console.error;
|
|
83
117
|
onError(error);
|
|
84
118
|
return 0;
|
|
85
|
-
} finally {
|
|
86
|
-
this.#running = false;
|
|
87
119
|
}
|
|
88
120
|
}
|
|
89
121
|
}
|