@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.
@@ -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
+ });