@kudos-protocol/storage-sqlite 0.0.1
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 +21 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/schema.d.ts +686 -0
- package/dist/schema.js +62 -0
- package/dist/schema.js.map +1 -0
- package/dist/sqlite-storage.d.ts +22 -0
- package/dist/sqlite-storage.js +312 -0
- package/dist/sqlite-storage.js.map +1 -0
- package/drizzle/0000_classy_lake.sql +53 -0
- package/drizzle/0001_wooden_the_professor.sql +2 -0
- package/drizzle/meta/0000_snapshot.json +361 -0
- package/drizzle/meta/0001_snapshot.json +375 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +7 -0
- package/package.json +34 -0
- package/src/__tests__/sqlite-storage.test.ts +1131 -0
- package/src/index.ts +2 -0
- package/src/schema.ts +82 -0
- package/src/sqlite-storage.ts +425 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { SqliteStorage } from "../sqlite-storage.js";
|
|
6
|
+
import type { Event } from "@kudos-protocol/pool-core";
|
|
7
|
+
import { normalizeEvent } from "@kudos-protocol/pool-core";
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const migrationsPath = path.resolve(__dirname, "..", "..", "drizzle");
|
|
11
|
+
const fixturesPath = path.resolve(
|
|
12
|
+
__dirname,
|
|
13
|
+
"..",
|
|
14
|
+
"..",
|
|
15
|
+
"..",
|
|
16
|
+
"..",
|
|
17
|
+
"test",
|
|
18
|
+
"conformance",
|
|
19
|
+
"fixtures",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
function createStorage(opts?: { outbox?: boolean }): SqliteStorage {
|
|
23
|
+
return new SqliteStorage({ path: ":memory:", migrationsPath, outbox: opts?.outbox });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeEvent(overrides: Partial<Event> & { recipient: string }): Event {
|
|
27
|
+
return normalizeEvent(
|
|
28
|
+
{
|
|
29
|
+
recipient: overrides.recipient,
|
|
30
|
+
id: overrides.id,
|
|
31
|
+
ts: overrides.ts,
|
|
32
|
+
scopeId: overrides.scopeId ?? undefined,
|
|
33
|
+
kudos: overrides.kudos,
|
|
34
|
+
emoji: overrides.emoji ?? undefined,
|
|
35
|
+
title: overrides.title ?? undefined,
|
|
36
|
+
visibility: overrides.visibility,
|
|
37
|
+
meta: overrides.meta ?? undefined,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
sender: overrides.sender ?? "email:alice@example.com",
|
|
41
|
+
now: () => overrides.ts ?? "2026-03-03T12:00:00.000Z",
|
|
42
|
+
generateId: overrides.id ? () => overrides.id! : undefined,
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let storage: SqliteStorage;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
storage = createStorage();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
storage.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ─── Schema & Init ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe("schema & init", () => {
|
|
60
|
+
it("creates all tables", async () => {
|
|
61
|
+
// If we get here without error, migrations ran successfully
|
|
62
|
+
const result = await storage.readEvents({ poolId: "empty", limit: 10 });
|
|
63
|
+
expect(result.events).toEqual([]);
|
|
64
|
+
expect(result.hasMore).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("migrations are idempotent", () => {
|
|
68
|
+
// Creating a second storage on the same file should not throw
|
|
69
|
+
// (uses :memory: so this is a separate db, but tests migrate path)
|
|
70
|
+
const s2 = createStorage();
|
|
71
|
+
s2.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("WAL mode is set", () => {
|
|
75
|
+
// Access the underlying sqlite instance via a fresh connection
|
|
76
|
+
const s = createStorage();
|
|
77
|
+
// WAL mode is set on :memory: but returns 'memory' for in-memory dbs
|
|
78
|
+
// Just verify we can create and use the storage
|
|
79
|
+
expect(s).toBeDefined();
|
|
80
|
+
s.close();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ─── Basic Insert ─────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe("basic insert", () => {
|
|
87
|
+
it("inserts a single event", async () => {
|
|
88
|
+
const event = makeEvent({
|
|
89
|
+
id: "kudos:test-001",
|
|
90
|
+
recipient: "email:bob@example.com",
|
|
91
|
+
kudos: 42,
|
|
92
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const result = await storage.appendEvents("pool-1", [event]);
|
|
96
|
+
expect(result.inserted).toBe(1);
|
|
97
|
+
expect(result.skipped).toBe(0);
|
|
98
|
+
expect(result.events).toHaveLength(1);
|
|
99
|
+
expect(result.events[0].id).toBe("kudos:test-001");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("inserts multiple events", async () => {
|
|
103
|
+
const events = [
|
|
104
|
+
makeEvent({
|
|
105
|
+
id: "kudos:multi-001",
|
|
106
|
+
recipient: "email:bob@example.com",
|
|
107
|
+
kudos: 10,
|
|
108
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
109
|
+
}),
|
|
110
|
+
makeEvent({
|
|
111
|
+
id: "kudos:multi-002",
|
|
112
|
+
recipient: "email:carol@example.com",
|
|
113
|
+
kudos: 20,
|
|
114
|
+
ts: "2026-03-03T11:00:00.000Z",
|
|
115
|
+
}),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const result = await storage.appendEvents("pool-1", events);
|
|
119
|
+
expect(result.inserted).toBe(2);
|
|
120
|
+
expect(result.skipped).toBe(0);
|
|
121
|
+
expect(result.events).toHaveLength(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns correct event shape", async () => {
|
|
125
|
+
const event = makeEvent({
|
|
126
|
+
id: "kudos:shape-001",
|
|
127
|
+
recipient: "email:bob@example.com",
|
|
128
|
+
kudos: 5,
|
|
129
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
130
|
+
emoji: "star",
|
|
131
|
+
title: "Great work",
|
|
132
|
+
visibility: "PUBLIC_ALL",
|
|
133
|
+
meta: '{"key":"value"}',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const result = await storage.appendEvents("pool-1", [event]);
|
|
137
|
+
const e = result.events[0];
|
|
138
|
+
expect(e.id).toBe("kudos:shape-001");
|
|
139
|
+
expect(e.recipient).toBe("email:bob@example.com");
|
|
140
|
+
expect(e.sender).toBe("email:alice@example.com");
|
|
141
|
+
expect(e.ts).toBe("2026-03-03T10:00:00.000Z");
|
|
142
|
+
expect(e.scopeId).toBeNull();
|
|
143
|
+
expect(e.kudos).toBe(5);
|
|
144
|
+
expect(e.emoji).toBe("star");
|
|
145
|
+
expect(e.title).toBe("Great work");
|
|
146
|
+
expect(e.visibility).toBe("PUBLIC_ALL");
|
|
147
|
+
expect(e.meta).toBe('{"key":"value"}');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("updates projection counts on insert", async () => {
|
|
151
|
+
const events = [
|
|
152
|
+
makeEvent({
|
|
153
|
+
id: "kudos:proj-001",
|
|
154
|
+
recipient: "email:bob@example.com",
|
|
155
|
+
kudos: 10,
|
|
156
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
157
|
+
}),
|
|
158
|
+
makeEvent({
|
|
159
|
+
id: "kudos:proj-002",
|
|
160
|
+
recipient: "email:bob@example.com",
|
|
161
|
+
kudos: 20,
|
|
162
|
+
ts: "2026-03-03T11:00:00.000Z",
|
|
163
|
+
}),
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
await storage.appendEvents("pool-1", events);
|
|
167
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
168
|
+
expect(summary.totalKudos).toBe(30);
|
|
169
|
+
expect(summary.summary[0].kudos).toBe(30);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ─── Idempotency ──────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
describe("idempotency", () => {
|
|
176
|
+
it("skips duplicate event IDs", async () => {
|
|
177
|
+
const event = makeEvent({
|
|
178
|
+
id: "kudos:dup-001",
|
|
179
|
+
recipient: "email:bob@example.com",
|
|
180
|
+
kudos: 100,
|
|
181
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await storage.appendEvents("pool-1", [event]);
|
|
185
|
+
const result = await storage.appendEvents("pool-1", [event]);
|
|
186
|
+
expect(result.inserted).toBe(0);
|
|
187
|
+
expect(result.skipped).toBe(1);
|
|
188
|
+
expect(result.events).toHaveLength(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("does not double-count projections for duplicates", async () => {
|
|
192
|
+
const event = makeEvent({
|
|
193
|
+
id: "kudos:dup-proj-001",
|
|
194
|
+
recipient: "email:bob@example.com",
|
|
195
|
+
kudos: 50,
|
|
196
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await storage.appendEvents("pool-1", [event]);
|
|
200
|
+
await storage.appendEvents("pool-1", [event]);
|
|
201
|
+
|
|
202
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
203
|
+
expect(summary.totalKudos).toBe(50);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns input event for duplicates (not re-read)", async () => {
|
|
207
|
+
const event = makeEvent({
|
|
208
|
+
id: "kudos:dup-input-001",
|
|
209
|
+
recipient: "email:bob@example.com",
|
|
210
|
+
kudos: 100,
|
|
211
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await storage.appendEvents("pool-1", [event]);
|
|
215
|
+
const result = await storage.appendEvents("pool-1", [event]);
|
|
216
|
+
expect(result.events[0]).toBe(event); // same reference
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ─── scopeId Latest-Wins ──────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
describe("scopeId latest-wins", () => {
|
|
223
|
+
it("newer event replaces older in projections", async () => {
|
|
224
|
+
const old = makeEvent({
|
|
225
|
+
id: "kudos:scope-old",
|
|
226
|
+
recipient: "email:bob@example.com",
|
|
227
|
+
scopeId: "dp:2026-03-03",
|
|
228
|
+
kudos: 50,
|
|
229
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
230
|
+
});
|
|
231
|
+
const newer = makeEvent({
|
|
232
|
+
id: "kudos:scope-new",
|
|
233
|
+
recipient: "email:bob@example.com",
|
|
234
|
+
scopeId: "dp:2026-03-03",
|
|
235
|
+
kudos: 200,
|
|
236
|
+
ts: "2026-03-03T14:00:00.000Z",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await storage.appendEvents("pool-1", [old]);
|
|
240
|
+
await storage.appendEvents("pool-1", [newer]);
|
|
241
|
+
|
|
242
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
243
|
+
expect(summary.totalKudos).toBe(200);
|
|
244
|
+
expect(summary.summary[0].kudos).toBe(200);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("older event does not replace newer in projections", async () => {
|
|
248
|
+
const newer = makeEvent({
|
|
249
|
+
id: "kudos:scope-newer",
|
|
250
|
+
recipient: "email:bob@example.com",
|
|
251
|
+
scopeId: "dp:2026-03-03",
|
|
252
|
+
kudos: 200,
|
|
253
|
+
ts: "2026-03-03T14:00:00.000Z",
|
|
254
|
+
});
|
|
255
|
+
const older = makeEvent({
|
|
256
|
+
id: "kudos:scope-older",
|
|
257
|
+
recipient: "email:bob@example.com",
|
|
258
|
+
scopeId: "dp:2026-03-03",
|
|
259
|
+
kudos: 50,
|
|
260
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await storage.appendEvents("pool-1", [newer]);
|
|
264
|
+
await storage.appendEvents("pool-1", [older]);
|
|
265
|
+
|
|
266
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
267
|
+
expect(summary.totalKudos).toBe(200);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("tombstone reduces totals via scopeId delta", async () => {
|
|
271
|
+
const original = makeEvent({
|
|
272
|
+
id: "kudos:tomb-001",
|
|
273
|
+
recipient: "email:bob@example.com",
|
|
274
|
+
scopeId: "dp:2026-03-03",
|
|
275
|
+
kudos: 100,
|
|
276
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
277
|
+
});
|
|
278
|
+
const tombstone = makeEvent({
|
|
279
|
+
id: "kudos:tomb-002",
|
|
280
|
+
recipient: "email:bob@example.com",
|
|
281
|
+
scopeId: "dp:2026-03-03",
|
|
282
|
+
kudos: 0,
|
|
283
|
+
ts: "2026-03-03T14:00:00.000Z",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await storage.appendEvents("pool-1", [original]);
|
|
287
|
+
await storage.appendEvents("pool-1", [tombstone]);
|
|
288
|
+
|
|
289
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
290
|
+
expect(summary.totalKudos).toBe(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("handles multiple scopes independently", async () => {
|
|
294
|
+
const scope1a = makeEvent({
|
|
295
|
+
id: "kudos:ms-001",
|
|
296
|
+
recipient: "email:bob@example.com",
|
|
297
|
+
scopeId: "scope-a",
|
|
298
|
+
kudos: 10,
|
|
299
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
300
|
+
});
|
|
301
|
+
const scope1b = makeEvent({
|
|
302
|
+
id: "kudos:ms-002",
|
|
303
|
+
recipient: "email:bob@example.com",
|
|
304
|
+
scopeId: "scope-a",
|
|
305
|
+
kudos: 30,
|
|
306
|
+
ts: "2026-03-03T14:00:00.000Z",
|
|
307
|
+
});
|
|
308
|
+
const scope2 = makeEvent({
|
|
309
|
+
id: "kudos:ms-003",
|
|
310
|
+
recipient: "email:bob@example.com",
|
|
311
|
+
scopeId: "scope-b",
|
|
312
|
+
kudos: 50,
|
|
313
|
+
ts: "2026-03-03T12:00:00.000Z",
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await storage.appendEvents("pool-1", [scope1a, scope2]);
|
|
317
|
+
await storage.appendEvents("pool-1", [scope1b]);
|
|
318
|
+
|
|
319
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
320
|
+
// scope-a: 30 (latest wins over 10), scope-b: 50
|
|
321
|
+
expect(summary.totalKudos).toBe(80);
|
|
322
|
+
expect(summary.summary[0].kudos).toBe(80);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("breaks ties by eventId (lexicographic)", async () => {
|
|
326
|
+
const evA = makeEvent({
|
|
327
|
+
id: "kudos:tie-aaa",
|
|
328
|
+
recipient: "email:bob@example.com",
|
|
329
|
+
scopeId: "dp:tie",
|
|
330
|
+
kudos: 10,
|
|
331
|
+
ts: "2026-03-03T12:00:00.000Z",
|
|
332
|
+
});
|
|
333
|
+
const evB = makeEvent({
|
|
334
|
+
id: "kudos:tie-zzz",
|
|
335
|
+
recipient: "email:bob@example.com",
|
|
336
|
+
scopeId: "dp:tie",
|
|
337
|
+
kudos: 99,
|
|
338
|
+
ts: "2026-03-03T12:00:00.000Z", // same timestamp
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await storage.appendEvents("pool-1", [evA]);
|
|
342
|
+
await storage.appendEvents("pool-1", [evB]);
|
|
343
|
+
|
|
344
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
345
|
+
// evB has lexicographically larger eventId, so it wins
|
|
346
|
+
expect(summary.totalKudos).toBe(99);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("no-scope events are always additive", async () => {
|
|
350
|
+
const e1 = makeEvent({
|
|
351
|
+
id: "kudos:noscope-001",
|
|
352
|
+
recipient: "email:bob@example.com",
|
|
353
|
+
kudos: 10,
|
|
354
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
355
|
+
});
|
|
356
|
+
const e2 = makeEvent({
|
|
357
|
+
id: "kudos:noscope-002",
|
|
358
|
+
recipient: "email:bob@example.com",
|
|
359
|
+
kudos: 20,
|
|
360
|
+
ts: "2026-03-03T11:00:00.000Z",
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
await storage.appendEvents("pool-1", [e1, e2]);
|
|
364
|
+
|
|
365
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
366
|
+
expect(summary.totalKudos).toBe(30);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ─── readEvents Ordering & Pagination ─────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
describe("readEvents ordering & pagination", () => {
|
|
373
|
+
it("returns events in ts DESC order", async () => {
|
|
374
|
+
const events = [
|
|
375
|
+
makeEvent({
|
|
376
|
+
id: "kudos:ord-001",
|
|
377
|
+
recipient: "email:bob@example.com",
|
|
378
|
+
kudos: 10,
|
|
379
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
380
|
+
}),
|
|
381
|
+
makeEvent({
|
|
382
|
+
id: "kudos:ord-002",
|
|
383
|
+
recipient: "email:bob@example.com",
|
|
384
|
+
kudos: 20,
|
|
385
|
+
ts: "2026-03-03T14:00:00.000Z",
|
|
386
|
+
}),
|
|
387
|
+
makeEvent({
|
|
388
|
+
id: "kudos:ord-003",
|
|
389
|
+
recipient: "email:bob@example.com",
|
|
390
|
+
kudos: 30,
|
|
391
|
+
ts: "2026-03-03T12:00:00.000Z",
|
|
392
|
+
}),
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
await storage.appendEvents("pool-1", events);
|
|
396
|
+
const result = await storage.readEvents({ poolId: "pool-1", limit: 10 });
|
|
397
|
+
|
|
398
|
+
expect(result.events.map((e) => e.id)).toEqual([
|
|
399
|
+
"kudos:ord-002",
|
|
400
|
+
"kudos:ord-003",
|
|
401
|
+
"kudos:ord-001",
|
|
402
|
+
]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("paginates with cursor", async () => {
|
|
406
|
+
const events = Array.from({ length: 5 }, (_, i) =>
|
|
407
|
+
makeEvent({
|
|
408
|
+
id: `kudos:page-00${i + 1}`,
|
|
409
|
+
recipient: "email:bob@example.com",
|
|
410
|
+
kudos: (i + 1) * 10,
|
|
411
|
+
ts: `2026-03-03T${String(10 + i).padStart(2, "0")}:00:00.000Z`,
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
await storage.appendEvents("pool-1", events);
|
|
416
|
+
|
|
417
|
+
// Page 1
|
|
418
|
+
const page1 = await storage.readEvents({ poolId: "pool-1", limit: 2 });
|
|
419
|
+
expect(page1.events).toHaveLength(2);
|
|
420
|
+
expect(page1.hasMore).toBe(true);
|
|
421
|
+
expect(page1.nextCursor).not.toBeNull();
|
|
422
|
+
expect(page1.events[0].id).toBe("kudos:page-005");
|
|
423
|
+
expect(page1.events[1].id).toBe("kudos:page-004");
|
|
424
|
+
|
|
425
|
+
// Page 2
|
|
426
|
+
const page2 = await storage.readEvents({
|
|
427
|
+
poolId: "pool-1",
|
|
428
|
+
limit: 2,
|
|
429
|
+
cursor: page1.nextCursor!,
|
|
430
|
+
});
|
|
431
|
+
expect(page2.events).toHaveLength(2);
|
|
432
|
+
expect(page2.hasMore).toBe(true);
|
|
433
|
+
expect(page2.events[0].id).toBe("kudos:page-003");
|
|
434
|
+
expect(page2.events[1].id).toBe("kudos:page-002");
|
|
435
|
+
|
|
436
|
+
// Page 3
|
|
437
|
+
const page3 = await storage.readEvents({
|
|
438
|
+
poolId: "pool-1",
|
|
439
|
+
limit: 2,
|
|
440
|
+
cursor: page2.nextCursor!,
|
|
441
|
+
});
|
|
442
|
+
expect(page3.events).toHaveLength(1);
|
|
443
|
+
expect(page3.hasMore).toBe(false);
|
|
444
|
+
expect(page3.nextCursor).toBeNull();
|
|
445
|
+
expect(page3.events[0].id).toBe("kudos:page-001");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("filters with since", async () => {
|
|
449
|
+
const events = [
|
|
450
|
+
makeEvent({
|
|
451
|
+
id: "kudos:since-001",
|
|
452
|
+
recipient: "email:bob@example.com",
|
|
453
|
+
kudos: 10,
|
|
454
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
455
|
+
}),
|
|
456
|
+
makeEvent({
|
|
457
|
+
id: "kudos:since-002",
|
|
458
|
+
recipient: "email:bob@example.com",
|
|
459
|
+
kudos: 20,
|
|
460
|
+
ts: "2026-03-03T14:00:00.000Z",
|
|
461
|
+
}),
|
|
462
|
+
];
|
|
463
|
+
|
|
464
|
+
await storage.appendEvents("pool-1", events);
|
|
465
|
+
const result = await storage.readEvents({
|
|
466
|
+
poolId: "pool-1",
|
|
467
|
+
limit: 10,
|
|
468
|
+
since: "2026-03-03T12:00:00.000Z",
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
expect(result.events).toHaveLength(1);
|
|
472
|
+
expect(result.events[0].id).toBe("kudos:since-002");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("filters with until", async () => {
|
|
476
|
+
const events = [
|
|
477
|
+
makeEvent({
|
|
478
|
+
id: "kudos:until-001",
|
|
479
|
+
recipient: "email:bob@example.com",
|
|
480
|
+
kudos: 10,
|
|
481
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
482
|
+
}),
|
|
483
|
+
makeEvent({
|
|
484
|
+
id: "kudos:until-002",
|
|
485
|
+
recipient: "email:bob@example.com",
|
|
486
|
+
kudos: 20,
|
|
487
|
+
ts: "2026-03-03T14:00:00.000Z",
|
|
488
|
+
}),
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
await storage.appendEvents("pool-1", events);
|
|
492
|
+
const result = await storage.readEvents({
|
|
493
|
+
poolId: "pool-1",
|
|
494
|
+
limit: 10,
|
|
495
|
+
until: "2026-03-03T12:00:00.000Z",
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
expect(result.events).toHaveLength(1);
|
|
499
|
+
expect(result.events[0].id).toBe("kudos:until-001");
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("filters tombstones by default", async () => {
|
|
503
|
+
const events = [
|
|
504
|
+
makeEvent({
|
|
505
|
+
id: "kudos:tomb-filter-001",
|
|
506
|
+
recipient: "email:bob@example.com",
|
|
507
|
+
scopeId: "dp:tomb",
|
|
508
|
+
kudos: 0,
|
|
509
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
510
|
+
}),
|
|
511
|
+
makeEvent({
|
|
512
|
+
id: "kudos:tomb-filter-002",
|
|
513
|
+
recipient: "email:bob@example.com",
|
|
514
|
+
kudos: 10,
|
|
515
|
+
ts: "2026-03-03T11:00:00.000Z",
|
|
516
|
+
}),
|
|
517
|
+
];
|
|
518
|
+
|
|
519
|
+
await storage.appendEvents("pool-1", events);
|
|
520
|
+
|
|
521
|
+
const withoutTomb = await storage.readEvents({
|
|
522
|
+
poolId: "pool-1",
|
|
523
|
+
limit: 10,
|
|
524
|
+
});
|
|
525
|
+
expect(withoutTomb.events).toHaveLength(1);
|
|
526
|
+
|
|
527
|
+
const withTomb = await storage.readEvents({
|
|
528
|
+
poolId: "pool-1",
|
|
529
|
+
limit: 10,
|
|
530
|
+
includeTombstones: true,
|
|
531
|
+
});
|
|
532
|
+
expect(withTomb.events).toHaveLength(2);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("combines since and until filters", async () => {
|
|
536
|
+
const events = [
|
|
537
|
+
makeEvent({
|
|
538
|
+
id: "kudos:range-001",
|
|
539
|
+
recipient: "email:bob@example.com",
|
|
540
|
+
kudos: 10,
|
|
541
|
+
ts: "2026-03-03T08:00:00.000Z",
|
|
542
|
+
}),
|
|
543
|
+
makeEvent({
|
|
544
|
+
id: "kudos:range-002",
|
|
545
|
+
recipient: "email:bob@example.com",
|
|
546
|
+
kudos: 20,
|
|
547
|
+
ts: "2026-03-03T12:00:00.000Z",
|
|
548
|
+
}),
|
|
549
|
+
makeEvent({
|
|
550
|
+
id: "kudos:range-003",
|
|
551
|
+
recipient: "email:bob@example.com",
|
|
552
|
+
kudos: 30,
|
|
553
|
+
ts: "2026-03-03T16:00:00.000Z",
|
|
554
|
+
}),
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
await storage.appendEvents("pool-1", events);
|
|
558
|
+
const result = await storage.readEvents({
|
|
559
|
+
poolId: "pool-1",
|
|
560
|
+
limit: 10,
|
|
561
|
+
since: "2026-03-03T10:00:00.000Z",
|
|
562
|
+
until: "2026-03-03T14:00:00.000Z",
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
expect(result.events).toHaveLength(1);
|
|
566
|
+
expect(result.events[0].id).toBe("kudos:range-002");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("returns empty for unknown pool", async () => {
|
|
570
|
+
const result = await storage.readEvents({
|
|
571
|
+
poolId: "nonexistent",
|
|
572
|
+
limit: 10,
|
|
573
|
+
});
|
|
574
|
+
expect(result.events).toEqual([]);
|
|
575
|
+
expect(result.hasMore).toBe(false);
|
|
576
|
+
expect(result.nextCursor).toBeNull();
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// ─── readSummary ──────────────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
describe("readSummary", () => {
|
|
583
|
+
it("aggregates kudos across recipients", async () => {
|
|
584
|
+
const events = [
|
|
585
|
+
makeEvent({
|
|
586
|
+
id: "kudos:sum-001",
|
|
587
|
+
recipient: "email:bob@example.com",
|
|
588
|
+
kudos: 100,
|
|
589
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
590
|
+
}),
|
|
591
|
+
makeEvent({
|
|
592
|
+
id: "kudos:sum-002",
|
|
593
|
+
recipient: "email:carol@example.com",
|
|
594
|
+
kudos: 200,
|
|
595
|
+
ts: "2026-03-03T11:00:00.000Z",
|
|
596
|
+
}),
|
|
597
|
+
makeEvent({
|
|
598
|
+
id: "kudos:sum-003",
|
|
599
|
+
recipient: "email:bob@example.com",
|
|
600
|
+
kudos: 50,
|
|
601
|
+
ts: "2026-03-03T12:00:00.000Z",
|
|
602
|
+
}),
|
|
603
|
+
];
|
|
604
|
+
|
|
605
|
+
await storage.appendEvents("pool-1", events);
|
|
606
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
607
|
+
|
|
608
|
+
expect(summary.totalKudos).toBe(350);
|
|
609
|
+
expect(summary.summary).toHaveLength(2);
|
|
610
|
+
// Sorted by kudos DESC
|
|
611
|
+
expect(summary.summary[0].recipient).toBe("email:carol@example.com");
|
|
612
|
+
expect(summary.summary[0].kudos).toBe(200);
|
|
613
|
+
expect(summary.summary[1].recipient).toBe("email:bob@example.com");
|
|
614
|
+
expect(summary.summary[1].kudos).toBe(150);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("respects limit", async () => {
|
|
618
|
+
const events = [
|
|
619
|
+
makeEvent({
|
|
620
|
+
id: "kudos:lim-001",
|
|
621
|
+
recipient: "email:bob@example.com",
|
|
622
|
+
kudos: 100,
|
|
623
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
624
|
+
}),
|
|
625
|
+
makeEvent({
|
|
626
|
+
id: "kudos:lim-002",
|
|
627
|
+
recipient: "email:carol@example.com",
|
|
628
|
+
kudos: 200,
|
|
629
|
+
ts: "2026-03-03T11:00:00.000Z",
|
|
630
|
+
}),
|
|
631
|
+
makeEvent({
|
|
632
|
+
id: "kudos:lim-003",
|
|
633
|
+
recipient: "email:dave@example.com",
|
|
634
|
+
kudos: 50,
|
|
635
|
+
ts: "2026-03-03T12:00:00.000Z",
|
|
636
|
+
}),
|
|
637
|
+
];
|
|
638
|
+
|
|
639
|
+
await storage.appendEvents("pool-1", events);
|
|
640
|
+
const summary = await storage.readSummary("pool-1", 2);
|
|
641
|
+
expect(summary.summary).toHaveLength(2);
|
|
642
|
+
expect(summary.totalKudos).toBe(350);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("includes emojis", async () => {
|
|
646
|
+
const events = [
|
|
647
|
+
makeEvent({
|
|
648
|
+
id: "kudos:emj-001",
|
|
649
|
+
recipient: "email:bob@example.com",
|
|
650
|
+
kudos: 10,
|
|
651
|
+
emoji: "star",
|
|
652
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
653
|
+
}),
|
|
654
|
+
makeEvent({
|
|
655
|
+
id: "kudos:emj-002",
|
|
656
|
+
recipient: "email:bob@example.com",
|
|
657
|
+
kudos: 10,
|
|
658
|
+
emoji: "heart",
|
|
659
|
+
ts: "2026-03-03T11:00:00.000Z",
|
|
660
|
+
}),
|
|
661
|
+
makeEvent({
|
|
662
|
+
id: "kudos:emj-003",
|
|
663
|
+
recipient: "email:bob@example.com",
|
|
664
|
+
kudos: 10,
|
|
665
|
+
emoji: "star", // duplicate emoji
|
|
666
|
+
ts: "2026-03-03T12:00:00.000Z",
|
|
667
|
+
}),
|
|
668
|
+
];
|
|
669
|
+
|
|
670
|
+
await storage.appendEvents("pool-1", events);
|
|
671
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
672
|
+
|
|
673
|
+
expect(summary.summary[0].emojis).toHaveLength(2);
|
|
674
|
+
expect(summary.summary[0].emojis).toContain("star");
|
|
675
|
+
expect(summary.summary[0].emojis).toContain("heart");
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("returns empty for unknown pool", async () => {
|
|
679
|
+
const summary = await storage.readSummary("nonexistent", 10);
|
|
680
|
+
expect(summary.totalKudos).toBe(0);
|
|
681
|
+
expect(summary.summary).toEqual([]);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("totalKudos includes all recipients", async () => {
|
|
685
|
+
const events = [
|
|
686
|
+
makeEvent({
|
|
687
|
+
id: "kudos:tot-001",
|
|
688
|
+
recipient: "email:bob@example.com",
|
|
689
|
+
kudos: 100,
|
|
690
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
691
|
+
}),
|
|
692
|
+
makeEvent({
|
|
693
|
+
id: "kudos:tot-002",
|
|
694
|
+
recipient: "email:carol@example.com",
|
|
695
|
+
kudos: 200,
|
|
696
|
+
ts: "2026-03-03T11:00:00.000Z",
|
|
697
|
+
}),
|
|
698
|
+
];
|
|
699
|
+
|
|
700
|
+
await storage.appendEvents("pool-1", events);
|
|
701
|
+
// Even with limit=1, totalKudos should be the full total
|
|
702
|
+
const summary = await storage.readSummary("pool-1", 1);
|
|
703
|
+
expect(summary.totalKudos).toBe(300);
|
|
704
|
+
expect(summary.summary).toHaveLength(1);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// ─── Conformance Fixtures ─────────────────────────────────────────────────
|
|
709
|
+
|
|
710
|
+
describe("conformance fixtures", () => {
|
|
711
|
+
function loadFixture(name: string) {
|
|
712
|
+
const raw = readFileSync(path.join(fixturesPath, name), "utf-8");
|
|
713
|
+
return JSON.parse(raw);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
it("idempotency", async () => {
|
|
717
|
+
const fixture = loadFixture("idempotency.json");
|
|
718
|
+
const { poolId, sender } = fixture.setup;
|
|
719
|
+
|
|
720
|
+
for (const step of fixture.steps) {
|
|
721
|
+
if (step.action === "appendEvents") {
|
|
722
|
+
const events = step.request.events.map((e: Record<string, unknown>) =>
|
|
723
|
+
normalizeEvent(e as Parameters<typeof normalizeEvent>[0], { sender }),
|
|
724
|
+
);
|
|
725
|
+
const result = await storage.appendEvents(poolId, events);
|
|
726
|
+
expect(result.events.length).toBe(step.expect.accepted);
|
|
727
|
+
} else if (step.action === "getPoolSummary") {
|
|
728
|
+
const summary = await storage.readSummary(poolId, 50);
|
|
729
|
+
expect(summary.totalKudos).toBe(step.expect.totalKudos);
|
|
730
|
+
for (const expected of step.expect.summary) {
|
|
731
|
+
const found = summary.summary.find(
|
|
732
|
+
(s: { recipient: string }) => s.recipient === expected.recipient,
|
|
733
|
+
);
|
|
734
|
+
expect(found).toBeDefined();
|
|
735
|
+
expect(found!.kudos).toBe(expected.kudos);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("scope-id-latest-wins", async () => {
|
|
742
|
+
const fixture = loadFixture("scope-id-latest-wins.json");
|
|
743
|
+
const { poolId, sender } = fixture.setup;
|
|
744
|
+
|
|
745
|
+
for (const step of fixture.steps) {
|
|
746
|
+
if (step.action === "appendEvents") {
|
|
747
|
+
const events = step.request.events.map((e: Record<string, unknown>) =>
|
|
748
|
+
normalizeEvent(e as Parameters<typeof normalizeEvent>[0], { sender }),
|
|
749
|
+
);
|
|
750
|
+
const result = await storage.appendEvents(poolId, events);
|
|
751
|
+
expect(result.events.length).toBe(step.expect.accepted);
|
|
752
|
+
} else if (step.action === "getPoolSummary") {
|
|
753
|
+
const summary = await storage.readSummary(poolId, 50);
|
|
754
|
+
expect(summary.totalKudos).toBe(step.expect.totalKudos);
|
|
755
|
+
for (const expected of step.expect.summary) {
|
|
756
|
+
const found = summary.summary.find(
|
|
757
|
+
(s: { recipient: string }) => s.recipient === expected.recipient,
|
|
758
|
+
);
|
|
759
|
+
expect(found).toBeDefined();
|
|
760
|
+
expect(found!.kudos).toBe(expected.kudos);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("out-of-order-timestamps", async () => {
|
|
767
|
+
const fixture = loadFixture("out-of-order-timestamps.json");
|
|
768
|
+
const { poolId, sender } = fixture.setup;
|
|
769
|
+
|
|
770
|
+
for (const step of fixture.steps) {
|
|
771
|
+
if (step.action === "appendEvents") {
|
|
772
|
+
const events = step.request.events.map((e: Record<string, unknown>) =>
|
|
773
|
+
normalizeEvent(e as Parameters<typeof normalizeEvent>[0], { sender }),
|
|
774
|
+
);
|
|
775
|
+
const result = await storage.appendEvents(poolId, events);
|
|
776
|
+
expect(result.events.length).toBe(step.expect.accepted);
|
|
777
|
+
} else if (step.action === "listEvents") {
|
|
778
|
+
const result = await storage.readEvents({
|
|
779
|
+
poolId,
|
|
780
|
+
limit: step.request.limit,
|
|
781
|
+
});
|
|
782
|
+
expect(result.hasMore).toBe(step.expect.hasMore);
|
|
783
|
+
for (let i = 0; i < step.expect.events.length; i++) {
|
|
784
|
+
expect(result.events[i].id).toBe(step.expect.events[i].id);
|
|
785
|
+
}
|
|
786
|
+
} else if (step.action === "getPoolSummary") {
|
|
787
|
+
const summary = await storage.readSummary(poolId, 50);
|
|
788
|
+
expect(summary.totalKudos).toBe(step.expect.totalKudos);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("cursor-paging", async () => {
|
|
794
|
+
const fixture = loadFixture("cursor-paging.json");
|
|
795
|
+
const { poolId, sender, seed } = fixture.setup;
|
|
796
|
+
|
|
797
|
+
// Seed events
|
|
798
|
+
const seedEvents = seed.events.map((e: Record<string, unknown>) =>
|
|
799
|
+
normalizeEvent(e as Parameters<typeof normalizeEvent>[0], { sender }),
|
|
800
|
+
);
|
|
801
|
+
await storage.appendEvents(poolId, seedEvents);
|
|
802
|
+
|
|
803
|
+
let lastCursor: { ts: string; id: string } | undefined;
|
|
804
|
+
|
|
805
|
+
for (const step of fixture.steps) {
|
|
806
|
+
if (step.action === "listEvents") {
|
|
807
|
+
const cursor =
|
|
808
|
+
step.request.cursor === "$previousNextCursor"
|
|
809
|
+
? lastCursor
|
|
810
|
+
: undefined;
|
|
811
|
+
const result = await storage.readEvents({
|
|
812
|
+
poolId,
|
|
813
|
+
limit: step.request.limit,
|
|
814
|
+
cursor,
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
expect(result.hasMore).toBe(step.expect.hasMore);
|
|
818
|
+
expect(result.events.length).toBe(step.expect.events.length);
|
|
819
|
+
|
|
820
|
+
for (let i = 0; i < step.expect.events.length; i++) {
|
|
821
|
+
expect(result.events[i].id).toBe(step.expect.events[i].id);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (step.expect.nextCursor === null) {
|
|
825
|
+
expect(result.nextCursor).toBeNull();
|
|
826
|
+
} else if (result.nextCursor) {
|
|
827
|
+
lastCursor = result.nextCursor;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// ─── Transaction Atomicity ────────────────────────────────────────────────
|
|
835
|
+
|
|
836
|
+
describe("transaction atomicity", () => {
|
|
837
|
+
it("rolls back events and projections together on failure", async () => {
|
|
838
|
+
// Insert one event successfully first
|
|
839
|
+
const goodEvent = makeEvent({
|
|
840
|
+
id: "kudos:atom-001",
|
|
841
|
+
recipient: "email:bob@example.com",
|
|
842
|
+
kudos: 10,
|
|
843
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
844
|
+
});
|
|
845
|
+
await storage.appendEvents("pool-1", [goodEvent]);
|
|
846
|
+
|
|
847
|
+
// Now create a batch where we'll cause a failure mid-transaction
|
|
848
|
+
// by accessing the underlying sqlite to add a constraint that will fail
|
|
849
|
+
const events = [
|
|
850
|
+
makeEvent({
|
|
851
|
+
id: "kudos:atom-002",
|
|
852
|
+
recipient: "email:bob@example.com",
|
|
853
|
+
kudos: 20,
|
|
854
|
+
ts: "2026-03-03T11:00:00.000Z",
|
|
855
|
+
}),
|
|
856
|
+
makeEvent({
|
|
857
|
+
id: "kudos:atom-003",
|
|
858
|
+
recipient: "email:bob@example.com",
|
|
859
|
+
kudos: 30,
|
|
860
|
+
ts: "2026-03-03T12:00:00.000Z",
|
|
861
|
+
}),
|
|
862
|
+
];
|
|
863
|
+
|
|
864
|
+
// Monkey-patch: save the real method, then make it throw after first call
|
|
865
|
+
const origAppend = storage.appendEvents.bind(storage);
|
|
866
|
+
let callCount = 0;
|
|
867
|
+
const origProto = Object.getPrototypeOf(storage);
|
|
868
|
+
const origUpdateProjections =
|
|
869
|
+
origProto.updateProjections.bind(storage);
|
|
870
|
+
|
|
871
|
+
origProto.updateProjections = function (
|
|
872
|
+
this: typeof storage,
|
|
873
|
+
...args: Parameters<typeof origUpdateProjections>
|
|
874
|
+
) {
|
|
875
|
+
callCount++;
|
|
876
|
+
if (callCount >= 2) {
|
|
877
|
+
throw new Error("Simulated projection failure");
|
|
878
|
+
}
|
|
879
|
+
return origUpdateProjections.apply(this, args);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
try {
|
|
883
|
+
await expect(
|
|
884
|
+
storage.appendEvents("pool-1", events),
|
|
885
|
+
).rejects.toThrow("Simulated projection failure");
|
|
886
|
+
} finally {
|
|
887
|
+
origProto.updateProjections = origUpdateProjections;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Verify nothing from the failed batch was persisted
|
|
891
|
+
const readResult = await storage.readEvents({
|
|
892
|
+
poolId: "pool-1",
|
|
893
|
+
limit: 10,
|
|
894
|
+
});
|
|
895
|
+
expect(readResult.events).toHaveLength(1);
|
|
896
|
+
expect(readResult.events[0].id).toBe("kudos:atom-001");
|
|
897
|
+
|
|
898
|
+
// Projections should be unchanged
|
|
899
|
+
const summary = await storage.readSummary("pool-1", 10);
|
|
900
|
+
expect(summary.totalKudos).toBe(10);
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// ─── Outbox ──────────────────────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
describe("outbox", () => {
|
|
907
|
+
let outboxStorage: SqliteStorage;
|
|
908
|
+
|
|
909
|
+
beforeEach(() => {
|
|
910
|
+
outboxStorage = createStorage({ outbox: true });
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
afterEach(() => {
|
|
914
|
+
outboxStorage.close();
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("outbox rows written when outbox=true", async () => {
|
|
918
|
+
const event = makeEvent({
|
|
919
|
+
id: "kudos:ob-001",
|
|
920
|
+
recipient: "email:bob@example.com",
|
|
921
|
+
kudos: 10,
|
|
922
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
await outboxStorage.appendEvents("pool-1", [event]);
|
|
926
|
+
const rows = await outboxStorage.leasePending(100, 5, "test-lease", 60);
|
|
927
|
+
expect(rows).toHaveLength(1);
|
|
928
|
+
expect(rows[0].eventId).toBe("kudos:ob-001");
|
|
929
|
+
expect(rows[0].poolId).toBe("pool-1");
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it("outbox rows NOT written when outbox=false", async () => {
|
|
933
|
+
// Use the default storage (outbox=false)
|
|
934
|
+
const event = makeEvent({
|
|
935
|
+
id: "kudos:ob-002",
|
|
936
|
+
recipient: "email:bob@example.com",
|
|
937
|
+
kudos: 10,
|
|
938
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
await storage.appendEvents("pool-1", [event]);
|
|
942
|
+
const rows = await storage.leasePending(100, 5, "test-lease", 60);
|
|
943
|
+
expect(rows).toHaveLength(0);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("outbox payload contains correct JSON", async () => {
|
|
947
|
+
const event = makeEvent({
|
|
948
|
+
id: "kudos:ob-003",
|
|
949
|
+
recipient: "email:bob@example.com",
|
|
950
|
+
kudos: 42,
|
|
951
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
952
|
+
emoji: "star",
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
await outboxStorage.appendEvents("pool-1", [event]);
|
|
956
|
+
const rows = await outboxStorage.leasePending(100, 5, "test-lease", 60);
|
|
957
|
+
const parsed = JSON.parse(rows[0].payload);
|
|
958
|
+
expect(parsed.id).toBe("kudos:ob-003");
|
|
959
|
+
expect(parsed.kudos).toBe(42);
|
|
960
|
+
expect(parsed.emoji).toBe("star");
|
|
961
|
+
expect(parsed.recipient).toBe("email:bob@example.com");
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it("outbox rows only for new inserts, not dupes", async () => {
|
|
965
|
+
const event = makeEvent({
|
|
966
|
+
id: "kudos:ob-004",
|
|
967
|
+
recipient: "email:bob@example.com",
|
|
968
|
+
kudos: 10,
|
|
969
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
await outboxStorage.appendEvents("pool-1", [event]);
|
|
973
|
+
await outboxStorage.appendEvents("pool-1", [event]); // dupe
|
|
974
|
+
|
|
975
|
+
// Mark first batch delivered so we can count properly
|
|
976
|
+
const rows = await outboxStorage.leasePending(100, 5, "lease-1", 60);
|
|
977
|
+
expect(rows).toHaveLength(1); // only one outbox row, not two
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it("outbox rows in same transaction (atomicity)", async () => {
|
|
981
|
+
const event = makeEvent({
|
|
982
|
+
id: "kudos:ob-005",
|
|
983
|
+
recipient: "email:bob@example.com",
|
|
984
|
+
kudos: 10,
|
|
985
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// Monkey-patch to force projection failure
|
|
989
|
+
const origProto = Object.getPrototypeOf(outboxStorage);
|
|
990
|
+
const origUpdate = origProto.updateProjections.bind(outboxStorage);
|
|
991
|
+
origProto.updateProjections = function () {
|
|
992
|
+
throw new Error("Simulated failure");
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
try {
|
|
996
|
+
await expect(
|
|
997
|
+
outboxStorage.appendEvents("pool-1", [event]),
|
|
998
|
+
).rejects.toThrow("Simulated failure");
|
|
999
|
+
} finally {
|
|
1000
|
+
origProto.updateProjections = origUpdate;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Outbox should also be empty since transaction rolled back
|
|
1004
|
+
const rows = await outboxStorage.leasePending(100, 5, "test-lease", 60);
|
|
1005
|
+
expect(rows).toHaveLength(0);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("leasePending returns rows in createdAt order", async () => {
|
|
1009
|
+
const e1 = makeEvent({
|
|
1010
|
+
id: "kudos:ob-ord-001",
|
|
1011
|
+
recipient: "email:bob@example.com",
|
|
1012
|
+
kudos: 10,
|
|
1013
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
1014
|
+
});
|
|
1015
|
+
const e2 = makeEvent({
|
|
1016
|
+
id: "kudos:ob-ord-002",
|
|
1017
|
+
recipient: "email:bob@example.com",
|
|
1018
|
+
kudos: 20,
|
|
1019
|
+
ts: "2026-03-03T11:00:00.000Z",
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
await outboxStorage.appendEvents("pool-1", [e1, e2]);
|
|
1023
|
+
const rows = await outboxStorage.leasePending(100, 5, "test-lease", 60);
|
|
1024
|
+
|
|
1025
|
+
expect(rows).toHaveLength(2);
|
|
1026
|
+
// First row should have the first event (lower createdAt)
|
|
1027
|
+
expect(rows[0].eventId).toBe("kudos:ob-ord-001");
|
|
1028
|
+
expect(rows[1].eventId).toBe("kudos:ob-ord-002");
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it("leasePending respects maxAttempts filter", async () => {
|
|
1032
|
+
const event = makeEvent({
|
|
1033
|
+
id: "kudos:ob-max-001",
|
|
1034
|
+
recipient: "email:bob@example.com",
|
|
1035
|
+
kudos: 10,
|
|
1036
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
await outboxStorage.appendEvents("pool-1", [event]);
|
|
1040
|
+
|
|
1041
|
+
// Fail the row 5 times
|
|
1042
|
+
for (let i = 0; i < 5; i++) {
|
|
1043
|
+
const rows = await outboxStorage.leasePending(100, 10, `lease-${i}`, 60);
|
|
1044
|
+
if (rows.length > 0) {
|
|
1045
|
+
await outboxStorage.markFailed(rows.map((r) => r.id), "test error", `lease-${i}`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Now with maxAttempts=5, it should not be returned
|
|
1050
|
+
const rows = await outboxStorage.leasePending(100, 5, "final-lease", 60);
|
|
1051
|
+
expect(rows).toHaveLength(0);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it("leasePending sets lease_id and leased_at", async () => {
|
|
1055
|
+
const event = makeEvent({
|
|
1056
|
+
id: "kudos:ob-lease-001",
|
|
1057
|
+
recipient: "email:bob@example.com",
|
|
1058
|
+
kudos: 10,
|
|
1059
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
await outboxStorage.appendEvents("pool-1", [event]);
|
|
1063
|
+
const rows = await outboxStorage.leasePending(100, 5, "my-lease-id", 60);
|
|
1064
|
+
|
|
1065
|
+
expect(rows).toHaveLength(1);
|
|
1066
|
+
// The row was leased — verify by trying to lease again (should return empty since lease is fresh)
|
|
1067
|
+
const rows2 = await outboxStorage.leasePending(100, 5, "another-lease", 60);
|
|
1068
|
+
expect(rows2).toHaveLength(0);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it("expired lease re-leasable", async () => {
|
|
1072
|
+
const event = makeEvent({
|
|
1073
|
+
id: "kudos:ob-expire-001",
|
|
1074
|
+
recipient: "email:bob@example.com",
|
|
1075
|
+
kudos: 10,
|
|
1076
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
await outboxStorage.appendEvents("pool-1", [event]);
|
|
1080
|
+
// Lease with a very short TTL
|
|
1081
|
+
await outboxStorage.leasePending(100, 5, "old-lease", 60);
|
|
1082
|
+
|
|
1083
|
+
// With leaseTtlSeconds=0, the lease is immediately considered expired
|
|
1084
|
+
const rows = await outboxStorage.leasePending(100, 5, "new-lease", 0);
|
|
1085
|
+
expect(rows).toHaveLength(1);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
it("markDelivered sets delivered=1", async () => {
|
|
1089
|
+
const event = makeEvent({
|
|
1090
|
+
id: "kudos:ob-del-001",
|
|
1091
|
+
recipient: "email:bob@example.com",
|
|
1092
|
+
kudos: 10,
|
|
1093
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
await outboxStorage.appendEvents("pool-1", [event]);
|
|
1097
|
+
const rows = await outboxStorage.leasePending(100, 5, "test-lease", 60);
|
|
1098
|
+
await outboxStorage.markDelivered(rows.map((r) => r.id), "test-lease");
|
|
1099
|
+
|
|
1100
|
+
// Should not be returned by leasePending anymore
|
|
1101
|
+
const remaining = await outboxStorage.leasePending(100, 5, "test-lease-2", 0);
|
|
1102
|
+
expect(remaining).toHaveLength(0);
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it("markFailed increments attempts and records error", async () => {
|
|
1106
|
+
const event = makeEvent({
|
|
1107
|
+
id: "kudos:ob-fail-001",
|
|
1108
|
+
recipient: "email:bob@example.com",
|
|
1109
|
+
kudos: 10,
|
|
1110
|
+
ts: "2026-03-03T10:00:00.000Z",
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
await outboxStorage.appendEvents("pool-1", [event]);
|
|
1114
|
+
const rows = await outboxStorage.leasePending(100, 5, "test-lease", 60);
|
|
1115
|
+
await outboxStorage.markFailed(rows.map((r) => r.id), "Connection timeout", "test-lease");
|
|
1116
|
+
|
|
1117
|
+
// Re-lease with TTL=0 to get the row back
|
|
1118
|
+
const rows2 = await outboxStorage.leasePending(100, 5, "test-lease-2", 0);
|
|
1119
|
+
expect(rows2).toHaveLength(1);
|
|
1120
|
+
expect(rows2[0].attempts).toBe(1);
|
|
1121
|
+
expect(rows2[0].lastError).toBe("Connection timeout");
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
// ─── ping ─────────────────────────────────────────────────────────────────
|
|
1126
|
+
|
|
1127
|
+
describe("ping", () => {
|
|
1128
|
+
it("resolves without error on a healthy database", async () => {
|
|
1129
|
+
await expect(storage.ping()).resolves.toBeUndefined();
|
|
1130
|
+
});
|
|
1131
|
+
});
|