@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.
Files changed (49) hide show
  1. package/LICENSE.md +214 -0
  2. package/dist/adapters/better-sqlite3.mjs +1 -29
  3. package/dist/adapters/sqlite-wasm.d.mts +2 -2
  4. package/dist/adapters/sqlite-wasm.d.ts +2 -2
  5. package/dist/adapters/sqlite-wasm.mjs +1 -44
  6. package/dist/adapters/sqljs.mjs +1 -55
  7. package/dist/index.d.mts +43 -16
  8. package/dist/index.d.ts +43 -16
  9. package/dist/index.mjs +1 -20
  10. package/dist/packem_shared/EventEmitter-uo75adUL.mjs +1 -0
  11. package/dist/packem_shared/EventLog-B1-yhArT.mjs +1 -0
  12. package/dist/packem_shared/EventLogDO-CaZvpgJN.mjs +1 -0
  13. package/dist/packem_shared/EventLogDOClient-DGDMj5L5.mjs +1 -0
  14. package/dist/packem_shared/EventSource-BC0hKJSA.mjs +1 -0
  15. package/dist/packem_shared/EventsSync-DWyGGZQZ.mjs +1 -0
  16. package/dist/packem_shared/InMemorySnapshotStore-C4taIG5K.mjs +1 -0
  17. package/dist/packem_shared/LocalMirror-BIURA-He.mjs +4 -0
  18. package/dist/packem_shared/MaterializerRuntime-D0HOAe24.mjs +1 -0
  19. package/dist/packem_shared/SubscriptionManager-AhPw3lFc.mjs +1 -0
  20. package/dist/packem_shared/applyDiff-Da160K7_.mjs +1 -0
  21. package/dist/packem_shared/applyDiffToDb-C6ek5Elp.mjs +1 -0
  22. package/dist/packem_shared/classifyChanges-ioMfjjbU.mjs +1 -0
  23. package/dist/packem_shared/defineEvents-DHo-VK7G.mjs +1 -0
  24. package/dist/packem_shared/eventsContext-Dxow9Y7S.mjs +1 -0
  25. package/dist/packem_shared/isClientSeq-D2Xm0_lj.mjs +1 -0
  26. package/dist/packem_shared/{local-mirror.d-CyGOpUES.d.ts → local-mirror.d-ByrYTq4z.d.ts} +2 -2
  27. package/dist/packem_shared/{local-mirror.d-DTavX_y0.d.mts → local-mirror.d-Cip3QuMf.d.mts} +2 -2
  28. package/dist/packem_shared/subscribeToMirror-CX10AaP3.mjs +1 -0
  29. package/dist/react.d.mts +41 -14
  30. package/dist/react.d.ts +41 -14
  31. package/dist/react.mjs +1 -15
  32. package/package.json +1 -1
  33. package/dist/packem_shared/EventEmitter-CMZfct03.mjs +0 -92
  34. package/dist/packem_shared/EventLog-CnK-3Wge.mjs +0 -264
  35. package/dist/packem_shared/EventLogDO-DqlsVx0H.mjs +0 -381
  36. package/dist/packem_shared/EventLogDOClient-F4FO8Si4.mjs +0 -92
  37. package/dist/packem_shared/EventSource-D5yO9_aI.mjs +0 -221
  38. package/dist/packem_shared/EventsSync-BP36tC9O.mjs +0 -123
  39. package/dist/packem_shared/InMemorySnapshotStore-BHVAD-Bp.mjs +0 -24
  40. package/dist/packem_shared/LocalMirror-a-5jEqFN.mjs +0 -219
  41. package/dist/packem_shared/MaterializerRuntime-BoIrsMYB.mjs +0 -224
  42. package/dist/packem_shared/SubscriptionManager-C5xbw0pg.mjs +0 -75
  43. package/dist/packem_shared/applyDiff-98tKzmiW.mjs +0 -67
  44. package/dist/packem_shared/applyDiffToDb-DQ1xZp5J.mjs +0 -58
  45. package/dist/packem_shared/classifyChanges-RcqLBpLs.mjs +0 -41
  46. package/dist/packem_shared/defineEvents-DiBkPTh_.mjs +0 -28
  47. package/dist/packem_shared/eventsContext-Bk_p48hj.mjs +0 -6
  48. package/dist/packem_shared/isClientSeq-C46BkzqJ.mjs +0 -5
  49. package/dist/packem_shared/subscribeToMirror-CiaM-nQ7.mjs +0 -45
@@ -1,264 +0,0 @@
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,381 +0,0 @@
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
- };
29
- const toArray = (cursor) => {
30
- if (typeof cursor.toArray === "function") {
31
- return cursor.toArray();
32
- }
33
- if (typeof cursor[Symbol.iterator] === "function") {
34
- return [...cursor];
35
- }
36
- return [];
37
- };
38
- const rowsToEntries = (cursor) => {
39
- const rows = toArray(cursor);
40
- return rows.map((row) => {
41
- return {
42
- seq: row.seq,
43
- type: row.type,
44
- payload: JSON.parse(row.payload),
45
- timestamp: row.timestamp,
46
- clientId: row.client_id ?? void 0,
47
- sessionId: row.session_id ?? void 0,
48
- parentSeqNum: row.parent_seq ?? void 0
49
- };
50
- });
51
- };
52
- class EventLogDO {
53
- state;
54
- env;
55
- /** Whether the `events` table has been created. */
56
- #initialized = false;
57
- constructor(state, env) {
58
- this.state = state;
59
- this.env = env;
60
- }
61
- // ── Fetch (RPC dispatch) ──────────────────────────────────────────
62
- async fetch(request) {
63
- this.#ensureTable();
64
- const url = new URL(request.url);
65
- try {
66
- if (request.method === "POST" && url.pathname === "/append") {
67
- return await this.#handleAppend(request);
68
- }
69
- if (request.method === "GET" && url.pathname === "/since") {
70
- return this.#handleSince(url);
71
- }
72
- if (request.method === "GET" && url.pathname === "/range") {
73
- return this.#handleRange(url);
74
- }
75
- if (request.method === "GET" && url.pathname === "/size") {
76
- return this.#handleSize();
77
- }
78
- if (request.method === "GET" && url.pathname === "/state") {
79
- return this.#handleState();
80
- }
81
- } catch (error) {
82
- console.error("[event-log-do] request failed:", error);
83
- return Response.json(
84
- {
85
- error: {
86
- code: "INTERNAL_ERROR",
87
- message: "internal error"
88
- }
89
- },
90
- { status: 500, headers: { "content-type": "application/json" } }
91
- );
92
- }
93
- return Response.json(
94
- { error: { code: "NOT_FOUND", message: "unknown route" } },
95
- {
96
- status: 404,
97
- headers: { "content-type": "application/json" }
98
- }
99
- );
100
- }
101
- // ── Handlers ──────────────────────────────────────────────────────
102
- /** POST /append — insert events, return entries with assigned seqs. */
103
- async #handleAppend(request) {
104
- let body;
105
- try {
106
- body = await request.json();
107
- } catch {
108
- return Response.json(
109
- { error: { code: "BAD_REQUEST", message: "invalid JSON body" } },
110
- {
111
- status: 400,
112
- headers: { "content-type": "application/json" }
113
- }
114
- );
115
- }
116
- const validationError = EventLogDO.#validateAppendRequest(body);
117
- if (validationError) {
118
- return Response.json(
119
- { error: { code: "BAD_REQUEST", message: validationError } },
120
- {
121
- status: 400,
122
- headers: { "content-type": "application/json" }
123
- }
124
- );
125
- }
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
- }
170
- const now = Date.now();
171
- const entries = [];
172
- for (const eventRecord of body.events) {
173
- const seq = EventLogDO.#nextSeq(sql);
174
- const entry = {
175
- seq,
176
- type: eventRecord.type,
177
- payload: eventRecord.payload,
178
- timestamp: eventRecord.timestamp ?? now,
179
- clientId: eventRecord.clientId,
180
- sessionId: eventRecord.sessionId,
181
- parentSeqNum: eventRecord.parentSeqNum
182
- };
183
- EventLogDO.#insertEvent(sql, entry);
184
- entries.push(entry);
185
- }
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;
240
- }
241
- /** GET /since?seq=N — return entries with seq >= N. */
242
- #handleSince(url) {
243
- const seqParameter = url.searchParams.get("seq");
244
- const sinceSeq = seqParameter === null ? 0 : Number(seqParameter);
245
- if (!Number.isFinite(sinceSeq) || sinceSeq < 0) {
246
- return Response.json(
247
- { error: { code: "BAD_REQUEST", message: "invalid seq" } },
248
- {
249
- status: 400,
250
- headers: { "content-type": "application/json" }
251
- }
252
- );
253
- }
254
- const { sql } = this.state.storage;
255
- const cursor = sql.exec(
256
- "SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? ORDER BY seq ASC",
257
- sinceSeq
258
- );
259
- const entries = rowsToEntries(cursor);
260
- return Response.json(
261
- { entries },
262
- {
263
- status: 200,
264
- headers: { "content-type": "application/json" }
265
- }
266
- );
267
- }
268
- /** GET /range?from=N&amp;limit=M — paginated read (default limit 50). */
269
- #handleRange(url) {
270
- const fromParameter = url.searchParams.get("from");
271
- const fromSeq = fromParameter === null ? 0 : Number(fromParameter);
272
- const limitParameter = url.searchParams.get("limit");
273
- const limit = limitParameter === null ? 50 : Number(limitParameter);
274
- if (!Number.isFinite(fromSeq) || fromSeq < 0 || !Number.isFinite(limit) || limit < 1 || limit > 1e3) {
275
- return Response.json(
276
- { error: { code: "BAD_REQUEST", message: "invalid from/limit" } },
277
- {
278
- status: 400,
279
- headers: { "content-type": "application/json" }
280
- }
281
- );
282
- }
283
- const { sql } = this.state.storage;
284
- const cursor = sql.exec(
285
- "SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events WHERE seq >= ? ORDER BY seq ASC LIMIT ?",
286
- fromSeq,
287
- limit + 1
288
- // Fetch one extra to detect hasMore
289
- );
290
- const allRows = rowsToEntries(cursor);
291
- const hasMore = allRows.length > limit;
292
- const entries = hasMore ? allRows.slice(0, limit) : allRows;
293
- const response = { entries, hasMore };
294
- return Response.json(response, {
295
- status: 200,
296
- headers: { "content-type": "application/json" }
297
- });
298
- }
299
- /** GET /size — return the number of stored events. */
300
- #handleSize() {
301
- const { sql } = this.state.storage;
302
- const cursor = sql.exec("SELECT COUNT(*) AS count FROM events");
303
- const rows = toArray(cursor);
304
- const count = rows[0]?.count ?? 0;
305
- return Response.json(
306
- { count },
307
- {
308
- status: 200,
309
- headers: { "content-type": "application/json" }
310
- }
311
- );
312
- }
313
- /** GET /state — return the full event log state. */
314
- #handleState() {
315
- const { sql } = this.state.storage;
316
- const cursor = sql.exec("SELECT seq, type, payload, timestamp, client_id, session_id, parent_seq FROM events ORDER BY seq ASC");
317
- const entries = rowsToEntries(cursor);
318
- const nextSeq = (entries.at(-1)?.seq ?? -1) + 1;
319
- return Response.json({ entries, nextSeq }, { status: 200, headers: { "content-type": "application/json" } });
320
- }
321
- // ── Internal ──────────────────────────────────────────────────────
322
- #ensureTable() {
323
- if (this.#initialized) {
324
- return;
325
- }
326
- const { sql } = this.state.storage;
327
- sql.exec(
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)"
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
- );
333
- this.#initialized = true;
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
- }
354
- /** Get the next available sequence number. */
355
- static #nextSeq(sql) {
356
- const cursor = sql.exec("SELECT COALESCE(MAX(seq), -1) + 1 AS next_seq FROM events");
357
- const rows = toArray(cursor);
358
- return rows[0]?.next_seq ?? 0;
359
- }
360
- /** Insert a single event entry. */
361
- /* eslint-disable unicorn/no-null -- SQLite bindings use `null` for missing optional columns */
362
- static #insertEvent(sql, entry) {
363
- let parentSeq = null;
364
- if (typeof entry.parentSeqNum === "number") {
365
- parentSeq = entry.parentSeqNum;
366
- }
367
- sql.exec(
368
- "INSERT INTO events (seq, type, payload, timestamp, client_id, session_id, parent_seq) VALUES (?, ?, ?, ?, ?, ?, ?)",
369
- entry.seq,
370
- entry.type,
371
- JSON.stringify(entry.payload),
372
- entry.timestamp,
373
- entry.clientId ?? null,
374
- entry.sessionId ?? null,
375
- parentSeq
376
- );
377
- }
378
- /* eslint-enable unicorn/no-null */
379
- }
380
-
381
- export { EventLogDO };