@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/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { SqliteStorage } from "./sqlite-storage.js";
2
+ export type { SqliteStorageOptions } from "./sqlite-storage.js";
package/src/schema.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core";
2
+ import { sql } from "drizzle-orm";
3
+
4
+ export const events = sqliteTable(
5
+ "events",
6
+ {
7
+ poolId: text("pool_id").notNull(),
8
+ eventId: text("event_id").notNull(),
9
+ recipient: text("recipient").notNull(),
10
+ sender: text("sender").notNull(),
11
+ ts: text("ts").notNull(),
12
+ scopeId: text("scope_id"),
13
+ kudos: integer("kudos").notNull().default(1),
14
+ emoji: text("emoji"),
15
+ title: text("title"),
16
+ visibility: text("visibility").notNull().default("PRIVATE"),
17
+ meta: text("meta"),
18
+ insertedAt: text("inserted_at")
19
+ .notNull()
20
+ .default(sql`(datetime('now'))`),
21
+ },
22
+ (table) => [
23
+ primaryKey({ columns: [table.poolId, table.eventId] }),
24
+ index("idx_events_pool_ts_id").on(table.poolId, table.ts, table.eventId),
25
+ ],
26
+ );
27
+
28
+ export const poolScopeLatest = sqliteTable(
29
+ "pool_scope_latest",
30
+ {
31
+ poolId: text("pool_id").notNull(),
32
+ recipient: text("recipient").notNull(),
33
+ scopeId: text("scope_id").notNull(),
34
+ eventId: text("event_id").notNull(),
35
+ kudos: integer("kudos").notNull(),
36
+ ts: text("ts").notNull(),
37
+ },
38
+ (table) => [
39
+ primaryKey({ columns: [table.poolId, table.recipient, table.scopeId] }),
40
+ ],
41
+ );
42
+
43
+ export const poolRecipientTotals = sqliteTable(
44
+ "pool_recipient_totals",
45
+ {
46
+ poolId: text("pool_id").notNull(),
47
+ recipient: text("recipient").notNull(),
48
+ kudos: integer("kudos").notNull().default(0),
49
+ emojis: text("emojis").notNull().default("[]"),
50
+ },
51
+ (table) => [
52
+ primaryKey({ columns: [table.poolId, table.recipient] }),
53
+ index("idx_recipient_totals_by_kudos").on(table.poolId, table.kudos),
54
+ ],
55
+ );
56
+
57
+ export const poolTotals = sqliteTable("pool_totals", {
58
+ poolId: text("pool_id").primaryKey(),
59
+ kudos: integer("kudos").notNull().default(0),
60
+ });
61
+
62
+ export const outbox = sqliteTable(
63
+ "outbox",
64
+ {
65
+ id: integer("id").primaryKey({ autoIncrement: true }),
66
+ poolId: text("pool_id").notNull(),
67
+ eventId: text("event_id").notNull(),
68
+ payload: text("payload").notNull(),
69
+ createdAt: text("created_at")
70
+ .notNull()
71
+ .default(sql`(datetime('now'))`),
72
+ delivered: integer("delivered").notNull().default(0),
73
+ deliveredAt: text("delivered_at"),
74
+ attempts: integer("attempts").notNull().default(0),
75
+ lastError: text("last_error"),
76
+ leasedAt: text("leased_at"),
77
+ leaseId: text("lease_id"),
78
+ },
79
+ (table) => [
80
+ index("idx_outbox_pending").on(table.delivered, table.createdAt),
81
+ ],
82
+ );
@@ -0,0 +1,425 @@
1
+ import Database from "better-sqlite3";
2
+ import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
3
+ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
4
+ import { eq, and, desc, ne, gte, lt, or, sql, inArray } from "drizzle-orm";
5
+ import { fileURLToPath } from "node:url";
6
+ import path from "node:path";
7
+ import type { Event, CursorPayload, RecipientSummary } from "@kudos-protocol/pool-core";
8
+ import type {
9
+ StoragePort,
10
+ AppendResult,
11
+ ReadEventsOptions,
12
+ ReadEventsResult,
13
+ ReadSummaryResult,
14
+ OutboxPort,
15
+ OutboxRow,
16
+ } from "@kudos-protocol/ports";
17
+ import * as schema from "./schema.js";
18
+
19
+ export interface SqliteStorageOptions {
20
+ path: string;
21
+ migrationsPath?: string;
22
+ outbox?: boolean;
23
+ }
24
+
25
+ export class SqliteStorage implements StoragePort, OutboxPort {
26
+ private sqlite: Database.Database;
27
+ private db: BetterSQLite3Database<typeof schema>;
28
+ private outboxEnabled: boolean;
29
+
30
+ constructor(options: SqliteStorageOptions) {
31
+ this.sqlite = new Database(options.path);
32
+ this.sqlite.pragma("journal_mode = WAL");
33
+ this.db = drizzle(this.sqlite, { schema });
34
+ this.outboxEnabled = options.outbox ?? false;
35
+
36
+ const migrationsFolder =
37
+ options.migrationsPath ??
38
+ path.resolve(
39
+ path.dirname(fileURLToPath(import.meta.url)),
40
+ "..",
41
+ "drizzle",
42
+ );
43
+ migrate(this.db, { migrationsFolder });
44
+ }
45
+
46
+ close(): void {
47
+ this.sqlite.close();
48
+ }
49
+
50
+ async ping(): Promise<void> {
51
+ this.db.select({ poolId: schema.poolTotals.poolId }).from(schema.poolTotals).limit(1).all();
52
+ }
53
+
54
+ async appendEvents(poolId: string, events: Event[]): Promise<AppendResult> {
55
+ return this.db.transaction((tx) => {
56
+ let inserted = 0;
57
+ let skipped = 0;
58
+ const accepted: Event[] = [];
59
+
60
+ for (const event of events) {
61
+ const result = tx
62
+ .insert(schema.events)
63
+ .values({
64
+ poolId,
65
+ eventId: event.id,
66
+ recipient: event.recipient,
67
+ sender: event.sender,
68
+ ts: event.ts,
69
+ scopeId: event.scopeId,
70
+ kudos: event.kudos,
71
+ emoji: event.emoji,
72
+ title: event.title,
73
+ visibility: event.visibility,
74
+ meta: event.meta,
75
+ })
76
+ .onConflictDoNothing({
77
+ target: [schema.events.poolId, schema.events.eventId],
78
+ })
79
+ .run();
80
+
81
+ if (result.changes === 0) {
82
+ skipped++;
83
+ accepted.push(event);
84
+ } else {
85
+ inserted++;
86
+ accepted.push(event);
87
+ this.updateProjections(tx, poolId, event);
88
+
89
+ if (this.outboxEnabled) {
90
+ tx.insert(schema.outbox)
91
+ .values({
92
+ poolId,
93
+ eventId: event.id,
94
+ payload: JSON.stringify(event),
95
+ })
96
+ .run();
97
+ }
98
+ }
99
+ }
100
+
101
+ return { inserted, skipped, events: accepted };
102
+ });
103
+ }
104
+
105
+ private updateProjections(
106
+ tx: Parameters<Parameters<BetterSQLite3Database<typeof schema>["transaction"]>[0]>[0],
107
+ poolId: string,
108
+ event: Event,
109
+ ): void {
110
+ let delta: number;
111
+
112
+ if (event.scopeId === null) {
113
+ // Branch A: no scopeId — always additive
114
+ delta = event.kudos;
115
+ } else {
116
+ // Branch B: scopeId present — latest-wins
117
+ const current = tx
118
+ .select()
119
+ .from(schema.poolScopeLatest)
120
+ .where(
121
+ and(
122
+ eq(schema.poolScopeLatest.poolId, poolId),
123
+ eq(schema.poolScopeLatest.recipient, event.recipient),
124
+ eq(schema.poolScopeLatest.scopeId, event.scopeId),
125
+ ),
126
+ )
127
+ .get();
128
+
129
+ if (!current) {
130
+ // B1: no existing row — insert, delta = event.kudos
131
+ delta = event.kudos;
132
+ tx.insert(schema.poolScopeLatest)
133
+ .values({
134
+ poolId,
135
+ recipient: event.recipient,
136
+ scopeId: event.scopeId,
137
+ eventId: event.id,
138
+ kudos: event.kudos,
139
+ ts: event.ts,
140
+ })
141
+ .run();
142
+ } else {
143
+ // Deterministic tie-break: newer ts wins, or lexicographic eventId
144
+ const isNewer =
145
+ event.ts > current.ts ||
146
+ (event.ts === current.ts && event.id > current.eventId);
147
+
148
+ if (isNewer) {
149
+ // B2: new event is newer — update scope_latest, delta = new - old
150
+ delta = event.kudos - current.kudos;
151
+ tx.update(schema.poolScopeLatest)
152
+ .set({
153
+ eventId: event.id,
154
+ kudos: event.kudos,
155
+ ts: event.ts,
156
+ })
157
+ .where(
158
+ and(
159
+ eq(schema.poolScopeLatest.poolId, poolId),
160
+ eq(schema.poolScopeLatest.recipient, event.recipient),
161
+ eq(schema.poolScopeLatest.scopeId, event.scopeId),
162
+ ),
163
+ )
164
+ .run();
165
+ } else {
166
+ // B3: old event still newest — no change
167
+ delta = 0;
168
+ }
169
+ }
170
+ }
171
+
172
+ // Apply delta to projection tables
173
+ if (delta !== 0) {
174
+ // Upsert poolRecipientTotals
175
+ tx.insert(schema.poolRecipientTotals)
176
+ .values({
177
+ poolId,
178
+ recipient: event.recipient,
179
+ kudos: delta,
180
+ emojis: "[]",
181
+ })
182
+ .onConflictDoUpdate({
183
+ target: [
184
+ schema.poolRecipientTotals.poolId,
185
+ schema.poolRecipientTotals.recipient,
186
+ ],
187
+ set: {
188
+ kudos: sql`${schema.poolRecipientTotals.kudos} + ${delta}`,
189
+ },
190
+ })
191
+ .run();
192
+
193
+ // Upsert poolTotals
194
+ tx.insert(schema.poolTotals)
195
+ .values({
196
+ poolId,
197
+ kudos: delta,
198
+ })
199
+ .onConflictDoUpdate({
200
+ target: schema.poolTotals.poolId,
201
+ set: {
202
+ kudos: sql`${schema.poolTotals.kudos} + ${delta}`,
203
+ },
204
+ })
205
+ .run();
206
+ }
207
+
208
+ // Update emojis if present
209
+ if (event.emoji !== null) {
210
+ // Ensure the recipient totals row exists
211
+ tx.insert(schema.poolRecipientTotals)
212
+ .values({
213
+ poolId,
214
+ recipient: event.recipient,
215
+ kudos: 0,
216
+ emojis: "[]",
217
+ })
218
+ .onConflictDoNothing({
219
+ target: [
220
+ schema.poolRecipientTotals.poolId,
221
+ schema.poolRecipientTotals.recipient,
222
+ ],
223
+ })
224
+ .run();
225
+
226
+ const row = tx
227
+ .select({ emojis: schema.poolRecipientTotals.emojis })
228
+ .from(schema.poolRecipientTotals)
229
+ .where(
230
+ and(
231
+ eq(schema.poolRecipientTotals.poolId, poolId),
232
+ eq(schema.poolRecipientTotals.recipient, event.recipient),
233
+ ),
234
+ )
235
+ .get();
236
+
237
+ const emojis: string[] = JSON.parse(row?.emojis ?? "[]");
238
+ if (!emojis.includes(event.emoji)) {
239
+ emojis.push(event.emoji);
240
+ tx.update(schema.poolRecipientTotals)
241
+ .set({ emojis: JSON.stringify(emojis) })
242
+ .where(
243
+ and(
244
+ eq(schema.poolRecipientTotals.poolId, poolId),
245
+ eq(schema.poolRecipientTotals.recipient, event.recipient),
246
+ ),
247
+ )
248
+ .run();
249
+ }
250
+ }
251
+ }
252
+
253
+ async readEvents(options: ReadEventsOptions): Promise<ReadEventsResult> {
254
+ const conditions = [eq(schema.events.poolId, options.poolId)];
255
+
256
+ if (!options.includeTombstones) {
257
+ conditions.push(ne(schema.events.kudos, 0));
258
+ }
259
+
260
+ if (options.since) {
261
+ conditions.push(gte(schema.events.ts, options.since));
262
+ }
263
+
264
+ if (options.until) {
265
+ conditions.push(lt(schema.events.ts, options.until));
266
+ }
267
+
268
+ if (options.cursor) {
269
+ conditions.push(
270
+ or(
271
+ lt(schema.events.ts, options.cursor.ts),
272
+ and(
273
+ eq(schema.events.ts, options.cursor.ts),
274
+ lt(schema.events.eventId, options.cursor.id),
275
+ ),
276
+ )!,
277
+ );
278
+ }
279
+
280
+ const rows = this.db
281
+ .select()
282
+ .from(schema.events)
283
+ .where(and(...conditions))
284
+ .orderBy(desc(schema.events.ts), desc(schema.events.eventId))
285
+ .limit(options.limit + 1)
286
+ .all();
287
+
288
+ const hasMore = rows.length > options.limit;
289
+ const page = hasMore ? rows.slice(0, options.limit) : rows;
290
+ const mappedEvents = page.map(rowToEvent);
291
+
292
+ let nextCursor: CursorPayload | null = null;
293
+ if (hasMore && mappedEvents.length > 0) {
294
+ const last = mappedEvents[mappedEvents.length - 1];
295
+ nextCursor = { ts: last.ts, id: last.id };
296
+ }
297
+
298
+ return { events: mappedEvents, nextCursor, hasMore };
299
+ }
300
+
301
+ async readSummary(poolId: string, limit: number): Promise<ReadSummaryResult> {
302
+ const totalsRow = this.db
303
+ .select({ kudos: schema.poolTotals.kudos })
304
+ .from(schema.poolTotals)
305
+ .where(eq(schema.poolTotals.poolId, poolId))
306
+ .get();
307
+
308
+ const totalKudos = totalsRow?.kudos ?? 0;
309
+
310
+ const rows = this.db
311
+ .select()
312
+ .from(schema.poolRecipientTotals)
313
+ .where(eq(schema.poolRecipientTotals.poolId, poolId))
314
+ .orderBy(desc(schema.poolRecipientTotals.kudos))
315
+ .limit(limit)
316
+ .all();
317
+
318
+ const summary: RecipientSummary[] = rows.map((row) => ({
319
+ recipient: row.recipient,
320
+ kudos: row.kudos,
321
+ emojis: JSON.parse(row.emojis),
322
+ percent: 0,
323
+ }));
324
+
325
+ return { totalKudos, summary };
326
+ }
327
+
328
+ // ─── OutboxPort Implementation ──────────────────────────────────────────
329
+
330
+ async leasePending(
331
+ limit: number,
332
+ maxAttempts: number,
333
+ leaseId: string,
334
+ leaseTtlSeconds: number,
335
+ ): Promise<OutboxRow[]> {
336
+ return this.db.transaction((tx) => {
337
+ const rows = tx
338
+ .select({
339
+ id: schema.outbox.id,
340
+ poolId: schema.outbox.poolId,
341
+ eventId: schema.outbox.eventId,
342
+ payload: schema.outbox.payload,
343
+ createdAt: schema.outbox.createdAt,
344
+ attempts: schema.outbox.attempts,
345
+ lastError: schema.outbox.lastError,
346
+ })
347
+ .from(schema.outbox)
348
+ .where(
349
+ and(
350
+ eq(schema.outbox.delivered, 0),
351
+ lt(schema.outbox.attempts, maxAttempts),
352
+ or(
353
+ sql`${schema.outbox.leasedAt} IS NULL`,
354
+ sql`${schema.outbox.leasedAt} <= datetime('now', '-${sql.raw(String(leaseTtlSeconds))} seconds')`,
355
+ ),
356
+ ),
357
+ )
358
+ .orderBy(schema.outbox.createdAt)
359
+ .limit(limit)
360
+ .all();
361
+
362
+ if (rows.length === 0) return [];
363
+
364
+ const ids = rows.map((r) => r.id);
365
+ tx.update(schema.outbox)
366
+ .set({
367
+ leaseId,
368
+ leasedAt: sql`datetime('now')`,
369
+ })
370
+ .where(inArray(schema.outbox.id, ids))
371
+ .run();
372
+
373
+ return rows;
374
+ });
375
+ }
376
+
377
+ async markDelivered(ids: number[], leaseId: string): Promise<void> {
378
+ if (ids.length === 0) return;
379
+ this.db
380
+ .update(schema.outbox)
381
+ .set({
382
+ delivered: 1,
383
+ deliveredAt: sql`datetime('now')`,
384
+ })
385
+ .where(
386
+ and(
387
+ inArray(schema.outbox.id, ids),
388
+ eq(schema.outbox.leaseId, leaseId),
389
+ ),
390
+ )
391
+ .run();
392
+ }
393
+
394
+ async markFailed(ids: number[], error: string, leaseId: string): Promise<void> {
395
+ if (ids.length === 0) return;
396
+ this.db
397
+ .update(schema.outbox)
398
+ .set({
399
+ attempts: sql`${schema.outbox.attempts} + 1`,
400
+ lastError: error,
401
+ })
402
+ .where(
403
+ and(
404
+ inArray(schema.outbox.id, ids),
405
+ eq(schema.outbox.leaseId, leaseId),
406
+ ),
407
+ )
408
+ .run();
409
+ }
410
+ }
411
+
412
+ function rowToEvent(row: typeof schema.events.$inferSelect): Event {
413
+ return {
414
+ id: row.eventId,
415
+ recipient: row.recipient,
416
+ sender: row.sender,
417
+ ts: row.ts,
418
+ scopeId: row.scopeId ?? null,
419
+ kudos: row.kudos,
420
+ emoji: row.emoji ?? null,
421
+ title: row.title ?? null,
422
+ visibility: row.visibility as Event["visibility"],
423
+ meta: row.meta ?? null,
424
+ };
425
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["src/**/*.test.ts", "src/**/__tests__"]
9
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: false,
6
+ include: ["src/**/*.test.ts"],
7
+ },
8
+ });