-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdb.ts
More file actions
498 lines (444 loc) · 16.3 KB
/
Copy pathdb.ts
File metadata and controls
498 lines (444 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
import Database from 'better-sqlite3';
import { dbQueryDuration, dbErrorsTotal, lastProcessedLedger, cursorUpdatedAt } from './metrics';
import { CONFIG } from './config';
import type { ILNEvent, Invoice } from './types';
// ─── Query logging ────────────────────────────────────────────────────────────
const SLOW_QUERY_THRESHOLD_MS = 100;
let _queryCount = 0;
let _totalQueryTime = 0;
/** Wrap a synchronous DB operation with timing and slow-query logging. */
function measure<T>(label: string, fn: () => T): T {
const start = Date.now();
try {
return fn();
} finally {
const elapsed = Date.now() - start;
_queryCount++;
_totalQueryTime += elapsed;
if (elapsed > SLOW_QUERY_THRESHOLD_MS) {
console.warn(`[DB] Slow query (${elapsed}ms): ${label}`);
}
}
}
// ─── Singleton connection ─────────────────────────────────────────────────────
let _db: Database.Database | null = null;
/** Return the singleton database connection, creating and migrating it on first call. */
export function getDb(): Database.Database {
if (!_db) {
_db = createDb(CONFIG.dbPath);
}
return _db;
}
/** Create a new database at the given path (use ":memory:" for tests). */
export function createDb(path: string): Database.Database {
const db = new Database(path);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
runMigrations(db);
return db;
}
/** Override the singleton. Used in tests to inject an in-memory database. */
export function setDb(db: Database.Database): void {
_db = db;
}
// ─── Schema migrations ────────────────────────────────────────────────────────
function runMigrations(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY,
freelancer TEXT NOT NULL,
payer TEXT NOT NULL,
amount TEXT NOT NULL,
due_date INTEGER NOT NULL,
discount_rate INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'Pending',
funder TEXT,
funded_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
invoice_id INTEGER NOT NULL,
ledger INTEGER NOT NULL,
ledger_closed_at TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cursor (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_ledger INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
CREATE INDEX IF NOT EXISTS idx_invoices_freelancer ON invoices(freelancer);
CREATE INDEX IF NOT EXISTS idx_invoices_payer ON invoices(payer);
CREATE INDEX IF NOT EXISTS idx_invoices_funder ON invoices(funder);
CREATE INDEX IF NOT EXISTS idx_invoices_created_at ON invoices(created_at);
CREATE INDEX IF NOT EXISTS idx_invoices_due_date ON invoices(due_date);
CREATE INDEX IF NOT EXISTS idx_invoices_status_funder ON invoices(status, funder);
CREATE INDEX IF NOT EXISTS idx_events_invoice_id ON events(invoice_id);
CREATE INDEX IF NOT EXISTS idx_events_ledger ON events(ledger);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
`);
}
// ─── Invoice CRUD ─────────────────────────────────────────────────────────────
/**
* Insert a new invoice or update an existing one.
* On conflict (same id), only mutable fields are updated.
* `created_at` is never overwritten.
*/
export function upsertInvoice(invoice: Omit<Invoice, 'created_at' | 'updated_at'>): void {
const now = Date.now();
try {
const end = dbQueryDuration.startTimer();
getDb()
.prepare(
`INSERT INTO invoices
(id, freelancer, payer, amount, due_date, discount_rate,
status, funder, funded_at, created_at, updated_at)
VALUES
(@id, @freelancer, @payer, @amount, @due_date, @discount_rate,
@status, @funder, @funded_at, @created_at, @updated_at)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
funder = excluded.funder,
funded_at = excluded.funded_at,
updated_at = excluded.updated_at`
)
.run({
...invoice,
funder: invoice.funder ?? null,
funded_at: invoice.funded_at ?? null,
created_at: now,
updated_at: now,
});
end();
} catch (err) {
try {
dbErrorsTotal.inc();
} catch {
/* metrics failure is non-fatal */
}
throw err;
}
}
/** Return a single invoice by ID, or undefined if not found. */
export function getInvoiceById(id: number): Invoice | undefined {
try {
const end = dbQueryDuration.startTimer();
const row = getDb().prepare('SELECT * FROM invoices WHERE id = ?').get(id) as
| Invoice
| undefined;
end();
return row;
} catch (err) {
try {
dbErrorsTotal.inc();
} catch {
/* metrics failure is non-fatal */
}
throw err;
}
}
export interface InvoiceFilter {
status?: string;
freelancer?: string;
payer?: string;
funder?: string;
}
/** Return all invoices matching the given filter (all fields are ANDed). */
export function queryInvoices(filter: InvoiceFilter): Invoice[] {
const db = getDb();
const clauses: string[] = [];
const params: (string | number)[] = [];
if (filter.status) {
clauses.push('status = ?');
params.push(filter.status);
}
if (filter.freelancer) {
clauses.push('freelancer = ?');
params.push(filter.freelancer);
}
if (filter.payer) {
clauses.push('payer = ?');
params.push(filter.payer);
}
if (filter.funder) {
clauses.push('funder = ?');
params.push(filter.funder);
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
return db.prepare(`SELECT * FROM invoices ${where} ORDER BY id ASC`).all(...params) as Invoice[];
}
/**
* Paginated version of queryInvoices.
* Returns up to `limit` invoices after the given cursor (exclusive).
* Provides `hasMore` flag and opaque `nextCursor` for client use.
*/
export function queryInvoicesPaginated(
filter: InvoiceFilter,
limit: number,
cursor?: string
): { invoices: Invoice[]; hasMore: boolean; nextCursor?: string } {
const db = getDb();
const clauses: string[] = [];
const params: (string | number)[] = [];
if (filter.status) {
clauses.push('status = ?');
params.push(filter.status);
}
if (filter.freelancer) {
clauses.push('freelancer = ?');
params.push(filter.freelancer);
}
if (filter.payer) {
clauses.push('payer = ?');
params.push(filter.payer);
}
if (filter.funder) {
clauses.push('funder = ?');
params.push(filter.funder);
}
// Decode the opaque cursor (base64 encoded id)
let cursorId: number | undefined;
if (cursor) {
try {
const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
cursorId = Number(decoded);
if (Number.isNaN(cursorId)) {
cursorId = undefined;
}
} catch {
cursorId = undefined;
}
}
if (cursorId !== undefined) {
clauses.push('id > ?');
params.push(cursorId);
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
// Fetch one extra row to determine hasMore
const rows = db
.prepare(`SELECT * FROM invoices ${where} ORDER BY id ASC LIMIT ?`)
.all(...params, limit + 1) as Invoice[];
// Note: measuring the above `.all()` is tricky without wrapping the call
// in a try/catch; we chose to rely on surrounding try/catch in callers.
const hasMore = rows.length > limit;
const sliced = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore
? Buffer.from(String(sliced[sliced.length - 1].id)).toString('base64')
: undefined;
return { invoices: sliced, hasMore, nextCursor };
}
export interface ProtocolStats {
totalInvoices: number;
totalVolume: string;
totalYield: string;
defaultRate: number;
}
/**
* LP stats as stored in SQLite.
*
* This is a DB-specific projection of @iln/shared's LPStats. Field names
* differ (`deployed` vs `totalFunded`, `yield` vs `totalEarned`), types are
* strings (i128 stored as TEXT), and it adds `defaultRate` / `invoiceCount`
* which are derived metrics not present in the contract struct.
*/
export interface LPStats {
deployed: string;
yield: string;
invoiceCount: number;
defaultRate: number;
}
export interface FreelancerStats {
submitted: number;
funded: number;
totalReceived: string;
avgDiscount: number;
}
export interface LPStat {
address: string;
yield: string;
invoiceCount: number;
}
export function getProtocolStats(): ProtocolStats {
const db = getDb();
const row = measure('getProtocolStats', () =>
db
.prepare(
`SELECT
COUNT(*) AS totalInvoices,
COALESCE(SUM(CAST(amount AS INTEGER)), 0) AS totalVolume,
COALESCE(SUM(CASE WHEN status = 'Paid' THEN CAST(amount AS INTEGER) * discount_rate / 10000 ELSE 0 END), 0) AS totalYield,
CASE
WHEN SUM(CASE WHEN status IN ('Paid','Defaulted') THEN 1 ELSE 0 END) > 0
THEN CAST(SUM(CASE WHEN status = 'Defaulted' THEN 1 ELSE 0 END) AS REAL)
/ SUM(CASE WHEN status IN ('Paid','Defaulted') THEN 1 ELSE 0 END)
ELSE 0
END AS defaultRate
FROM invoices`
)
.get()
) as ProtocolStats;
return {
totalInvoices: row.totalInvoices,
totalVolume: row.totalVolume.toString(),
totalYield: row.totalYield.toString(),
defaultRate: row.defaultRate,
};
}
export function getLPStats(address: string): LPStats {
const db = getDb();
const row = measure(`getLPStats(${address})`, () =>
db
.prepare(
`SELECT
COUNT(*) AS invoiceCount,
COALESCE(SUM(CAST(amount AS INTEGER)), 0) AS deployed,
COALESCE(SUM(CASE WHEN status = 'Paid' THEN CAST(amount AS INTEGER) * discount_rate / 10000 ELSE 0 END), 0) AS yield,
CASE
WHEN SUM(CASE WHEN status IN ('Paid','Defaulted') THEN 1 ELSE 0 END) > 0
THEN CAST(SUM(CASE WHEN status = 'Defaulted' THEN 1 ELSE 0 END) AS REAL)
/ SUM(CASE WHEN status IN ('Paid','Defaulted') THEN 1 ELSE 0 END)
ELSE 0
END AS defaultRate
FROM invoices
WHERE funder = ?`
)
.get(address)
) as LPStats;
return {
deployed: row.deployed.toString(),
yield: row.yield.toString(),
invoiceCount: row.invoiceCount,
defaultRate: row.defaultRate,
};
}
export function getFreelancerStats(address: string): FreelancerStats {
const db = getDb();
const row = measure(`getFreelancerStats(${address})`, () =>
db
.prepare(
`SELECT
COUNT(*) AS submitted,
COALESCE(SUM(CASE WHEN status IN ('Funded','Paid','Defaulted') THEN 1 ELSE 0 END), 0) AS funded,
COALESCE(SUM(CASE WHEN status IN ('Funded','Paid','Defaulted') THEN CAST(amount AS INTEGER) - (CAST(amount AS INTEGER) * discount_rate / 10000) ELSE 0 END), 0) AS totalReceived,
CASE WHEN COUNT(*) > 0 THEN CAST(SUM(discount_rate) AS REAL) / COUNT(*) ELSE 0 END AS avgDiscount
FROM invoices
WHERE freelancer = ?`
)
.get(address)
) as FreelancerStats;
return {
submitted: row.submitted,
funded: row.funded,
totalReceived: row.totalReceived.toString(),
avgDiscount: Math.round(row.avgDiscount * 100) / 100,
};
}
export function getInvoiceHistory(
address: string,
role: 'freelancer' | 'payer' | 'funder'
): Invoice[] {
return queryInvoices({ [role]: address });
}
export function getTopLPs(limit: number, period: string): LPStat[] {
const db = getDb();
const now = Date.now();
const since =
period === 'week'
? now - 7 * 24 * 60 * 60 * 1000
: period === 'month'
? now - 30 * 24 * 60 * 60 * 1000
: 0;
const whereSince =
since > 0
? 'WHERE funder IS NOT NULL AND (CASE WHEN funded_at IS NOT NULL THEN funded_at * 1000 ELSE created_at END) >= ?'
: 'WHERE funder IS NOT NULL';
const params: (number | string)[] = since > 0 ? [since, limit] : [limit];
const rows = measure(`getTopLPs(${limit}, ${period})`, () =>
db
.prepare(
`SELECT
funder AS address,
COALESCE(SUM(CASE WHEN status = 'Paid' THEN CAST(amount AS INTEGER) * discount_rate / 10000 ELSE 0 END), 0) AS yield,
COUNT(*) AS invoiceCount
FROM invoices
${whereSince}
GROUP BY funder
ORDER BY yield DESC, invoiceCount DESC
LIMIT ?`
)
.all(...params)
) as LPStat[];
return rows.map((r) => ({
address: r.address,
yield: r.yield.toString(),
invoiceCount: r.invoiceCount,
}));
}
// ─── Event queries ────────────────────────────────────────────────────────────
/** Return events, optionally filtered by invoice_id. */
export function getEvents(invoiceId?: number): ILNEvent[] {
const db = getDb();
if (invoiceId !== undefined) {
return db
.prepare('SELECT * FROM events WHERE invoice_id = ? ORDER BY ledger ASC')
.all(invoiceId) as ILNEvent[];
}
return db.prepare('SELECT * FROM events ORDER BY ledger ASC LIMIT 1000').all() as ILNEvent[];
}
// ─── Event deduplication ──────────────────────────────────────────────────────
/** Return true if this event has already been processed. */
export function hasEvent(eventId: string): boolean {
return getDb().prepare('SELECT 1 FROM events WHERE event_id = ?').get(eventId) !== undefined;
}
/**
* Insert an event record.
* Uses INSERT OR IGNORE so duplicate events are silently dropped.
*/
export function insertEvent(event: ILNEvent): void {
getDb()
.prepare(
`INSERT OR IGNORE INTO events
(event_id, event_type, invoice_id, ledger, ledger_closed_at, created_at)
VALUES
(@event_id, @event_type, @invoice_id, @ledger, @ledger_closed_at, @created_at)`
)
.run(event);
}
// ─── Cursor management ────────────────────────────────────────────────────────
/** Return the last processed ledger sequence, or 0 if never set. */
export function getCursorLedger(): number {
const row = getDb().prepare('SELECT last_ledger FROM cursor WHERE id = 1').get() as
| { last_ledger: number }
| undefined;
return row?.last_ledger ?? 0;
}
/** Return the Unix ms timestamp of the last processed ledger, or null if never synced. */
export function getCursorUpdatedAt(): number | null {
const row = getDb().prepare('SELECT updated_at FROM cursor WHERE id = 1').get() as
| { updated_at: number }
| undefined;
return row?.updated_at ?? null;
}
/** Persist the last processed ledger sequence. */
export function setCursorLedger(ledger: number): void {
const updatedAt = Date.now();
getDb()
.prepare(
`INSERT INTO cursor (id, last_ledger, updated_at)
VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET
last_ledger = excluded.last_ledger,
updated_at = excluded.updated_at`
)
.run(ledger, updatedAt);
try {
lastProcessedLedger.set(ledger);
cursorUpdatedAt.set(updatedAt);
} catch {
/* metrics failure is non-fatal */
}
}