@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,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&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 };
|