@melra/storage-sqlite 0.3.0-alpha.10

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/dist/index.js ADDED
@@ -0,0 +1,735 @@
1
+ // Copyright 2026 XAGI Labs Private Limited
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { mkdirSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { EncryptedPayloadSchema, WorkflowDefinitionSchema, WorkflowEventSchema, WorkflowRunSchema, WorkflowSnapshotSchema, } from "@melra/protocol";
7
+ function toMemoryRecord(row) {
8
+ return {
9
+ id: row.id,
10
+ scope: row.scope,
11
+ key: row.key,
12
+ value: row.value,
13
+ source: row.source,
14
+ confidence: row.confidence,
15
+ tags: JSON.parse(row.tags),
16
+ ...(row.speaker === null ? {} : { speaker: row.speaker }),
17
+ ...(row.episodeId === null ? {} : { episodeId: row.episodeId }),
18
+ ...(row.sequence === null ? {} : { sequence: row.sequence }),
19
+ ...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt }),
20
+ ...(row.supersedesId === null ? {} : { supersedesId: row.supersedesId }),
21
+ ...(row.supersededBy === null ? {} : { supersededBy: row.supersededBy }),
22
+ createdAt: row.createdAt,
23
+ updatedAt: row.updatedAt,
24
+ };
25
+ }
26
+ function parseStored(data, schema, error) {
27
+ try {
28
+ return schema.parse(JSON.parse(data));
29
+ }
30
+ catch {
31
+ throw new Error(error);
32
+ }
33
+ }
34
+ export class SqliteStore {
35
+ path;
36
+ database;
37
+ constructor(path) {
38
+ this.path = path;
39
+ if (path !== ":memory:")
40
+ mkdirSync(dirname(path), { recursive: true });
41
+ this.database = new DatabaseSync(path);
42
+ this.database.exec("PRAGMA busy_timeout = 5000");
43
+ this.database.exec("PRAGMA journal_mode = WAL");
44
+ this.database.exec("PRAGMA foreign_keys = ON");
45
+ this.migrate();
46
+ }
47
+ migrate() {
48
+ this.database.exec(`
49
+ CREATE TABLE IF NOT EXISTS tasks (
50
+ id TEXT PRIMARY KEY,
51
+ data TEXT NOT NULL,
52
+ created_at TEXT NOT NULL,
53
+ updated_at TEXT NOT NULL
54
+ );
55
+ CREATE TABLE IF NOT EXISTS receipts (
56
+ id TEXT PRIMARY KEY,
57
+ task_id TEXT NOT NULL,
58
+ data TEXT NOT NULL,
59
+ created_at TEXT NOT NULL
60
+ );
61
+ CREATE INDEX IF NOT EXISTS receipts_task_id ON receipts(task_id);
62
+ CREATE TABLE IF NOT EXISTS certificates (
63
+ id TEXT PRIMARY KEY,
64
+ task_id TEXT NOT NULL UNIQUE,
65
+ data TEXT NOT NULL,
66
+ created_at TEXT NOT NULL
67
+ );
68
+ CREATE TABLE IF NOT EXISTS memories (
69
+ id TEXT PRIMARY KEY,
70
+ scope TEXT NOT NULL,
71
+ key TEXT NOT NULL,
72
+ value TEXT NOT NULL,
73
+ source TEXT NOT NULL,
74
+ confidence REAL NOT NULL,
75
+ tags TEXT NOT NULL DEFAULT '[]',
76
+ speaker TEXT,
77
+ episode_id TEXT,
78
+ sequence INTEGER,
79
+ expires_at TEXT,
80
+ supersedes_id TEXT,
81
+ superseded_by TEXT,
82
+ created_at TEXT NOT NULL,
83
+ updated_at TEXT NOT NULL
84
+ );
85
+ CREATE INDEX IF NOT EXISTS memories_scope ON memories(scope);
86
+ CREATE INDEX IF NOT EXISTS memories_key ON memories(key);
87
+ `);
88
+ this.addColumnIfMissing("memories", "tags", "TEXT NOT NULL DEFAULT '[]'");
89
+ this.addColumnIfMissing("memories", "speaker", "TEXT");
90
+ this.addColumnIfMissing("memories", "episode_id", "TEXT");
91
+ this.addColumnIfMissing("memories", "sequence", "INTEGER");
92
+ this.addColumnIfMissing("memories", "expires_at", "TEXT");
93
+ this.addColumnIfMissing("memories", "supersedes_id", "TEXT");
94
+ this.addColumnIfMissing("memories", "superseded_by", "TEXT");
95
+ this.database.exec(`
96
+ CREATE INDEX IF NOT EXISTS memories_expiry ON memories(expires_at);
97
+ CREATE INDEX IF NOT EXISTS memories_superseded_by ON memories(superseded_by);
98
+ CREATE INDEX IF NOT EXISTS memories_episode_sequence
99
+ ON memories(episode_id, sequence);
100
+ `);
101
+ this.transaction(() => {
102
+ this.database.exec(`
103
+ CREATE TABLE IF NOT EXISTS schema_migrations (
104
+ version INTEGER PRIMARY KEY,
105
+ applied_at TEXT NOT NULL
106
+ );
107
+ `);
108
+ const applied = this.database
109
+ .prepare("SELECT version FROM schema_migrations WHERE version = 1")
110
+ .get();
111
+ if (applied !== undefined)
112
+ return;
113
+ this.database.exec(`
114
+ CREATE TABLE IF NOT EXISTS task_payloads (
115
+ task_id TEXT PRIMARY KEY,
116
+ request_payload TEXT NOT NULL,
117
+ result_payload TEXT,
118
+ created_at TEXT NOT NULL,
119
+ updated_at TEXT NOT NULL
120
+ );
121
+ CREATE TABLE IF NOT EXISTS workflow_payloads (
122
+ workflow_id TEXT NOT NULL,
123
+ workflow_version INTEGER NOT NULL,
124
+ payload TEXT NOT NULL,
125
+ created_at TEXT NOT NULL,
126
+ PRIMARY KEY(workflow_id, workflow_version)
127
+ );
128
+ CREATE TABLE IF NOT EXISTS workflow_definitions (
129
+ id TEXT NOT NULL,
130
+ version INTEGER NOT NULL,
131
+ data TEXT NOT NULL,
132
+ created_at TEXT NOT NULL,
133
+ PRIMARY KEY(id, version)
134
+ );
135
+ CREATE TABLE IF NOT EXISTS workflow_runs (
136
+ id TEXT PRIMARY KEY,
137
+ definition_id TEXT NOT NULL,
138
+ definition_version INTEGER NOT NULL,
139
+ state_version INTEGER NOT NULL,
140
+ data TEXT NOT NULL,
141
+ created_at TEXT NOT NULL,
142
+ updated_at TEXT NOT NULL
143
+ );
144
+ CREATE TABLE IF NOT EXISTS workflow_events (
145
+ id TEXT PRIMARY KEY,
146
+ aggregate_id TEXT NOT NULL,
147
+ sequence INTEGER NOT NULL,
148
+ trace_id TEXT NOT NULL,
149
+ type TEXT NOT NULL,
150
+ data TEXT NOT NULL,
151
+ occurred_at TEXT NOT NULL,
152
+ UNIQUE(aggregate_id, sequence)
153
+ );
154
+ CREATE INDEX IF NOT EXISTS workflow_events_aggregate
155
+ ON workflow_events(aggregate_id, sequence);
156
+ CREATE TABLE IF NOT EXISTS workflow_snapshots (
157
+ workflow_id TEXT NOT NULL,
158
+ sequence INTEGER NOT NULL,
159
+ data TEXT NOT NULL,
160
+ created_at TEXT NOT NULL,
161
+ PRIMARY KEY(workflow_id, sequence)
162
+ );
163
+ CREATE TABLE IF NOT EXISTS idempotency_commits (
164
+ idempotency_key TEXT PRIMARY KEY,
165
+ task_id TEXT NOT NULL,
166
+ attempt INTEGER NOT NULL,
167
+ committed_at TEXT NOT NULL
168
+ );
169
+ `);
170
+ this.database
171
+ .prepare("INSERT INTO schema_migrations(version, applied_at) VALUES (1, ?)")
172
+ .run(new Date().toISOString());
173
+ });
174
+ // Migration 2 adds cross-process advance leases. Schema changes get a new
175
+ // migration rather than an edit to the version-1 statements above.
176
+ this.applyMigration(2, `
177
+ CREATE TABLE IF NOT EXISTS workflow_leases (
178
+ workflow_id TEXT PRIMARY KEY,
179
+ owner TEXT NOT NULL,
180
+ acquired_at TEXT NOT NULL,
181
+ expires_at TEXT NOT NULL
182
+ );
183
+ `);
184
+ }
185
+ applyMigration(version, statements) {
186
+ this.transaction(() => {
187
+ const applied = this.database
188
+ .prepare("SELECT version FROM schema_migrations WHERE version = ?")
189
+ .get(version);
190
+ if (applied !== undefined)
191
+ return;
192
+ this.database.exec(statements);
193
+ this.database
194
+ .prepare("INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)")
195
+ .run(version, new Date().toISOString());
196
+ });
197
+ }
198
+ transaction(action) {
199
+ this.database.exec("BEGIN IMMEDIATE");
200
+ try {
201
+ const result = action();
202
+ this.database.exec("COMMIT");
203
+ return result;
204
+ }
205
+ catch (error) {
206
+ this.database.exec("ROLLBACK");
207
+ throw error;
208
+ }
209
+ }
210
+ addColumnIfMissing(table, column, definition) {
211
+ const columns = this.database
212
+ .prepare(`PRAGMA table_info(${table})`)
213
+ .all();
214
+ if (!columns.some((entry) => entry.name === column)) {
215
+ this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
216
+ }
217
+ }
218
+ saveTask(task) {
219
+ this.database
220
+ .prepare(`
221
+ INSERT INTO tasks(id, data, created_at, updated_at)
222
+ VALUES (?, ?, ?, ?)
223
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
224
+ `)
225
+ .run(task.id, JSON.stringify(task), task.createdAt, task.updatedAt);
226
+ }
227
+ getTask(id) {
228
+ const row = this.database
229
+ .prepare("SELECT data FROM tasks WHERE id = ?")
230
+ .get(id);
231
+ return row === undefined ? undefined : JSON.parse(row.data);
232
+ }
233
+ listTasks(limit = 50) {
234
+ const rows = this.database
235
+ .prepare("SELECT data FROM tasks ORDER BY updated_at DESC LIMIT ?")
236
+ .all(limit);
237
+ return rows.map((row) => JSON.parse(row.data));
238
+ }
239
+ saveTaskPayload(taskId, payload, at) {
240
+ const parsed = EncryptedPayloadSchema.parse(payload);
241
+ this.database
242
+ .prepare(`
243
+ INSERT INTO task_payloads(
244
+ task_id, request_payload, result_payload, created_at, updated_at
245
+ )
246
+ VALUES (?, ?, NULL, ?, ?)
247
+ ON CONFLICT(task_id) DO UPDATE SET
248
+ request_payload = excluded.request_payload,
249
+ updated_at = excluded.updated_at
250
+ `)
251
+ .run(taskId, JSON.stringify(parsed), at, at);
252
+ }
253
+ getTaskPayload(taskId) {
254
+ const row = this.database
255
+ .prepare("SELECT request_payload AS data FROM task_payloads WHERE task_id = ?")
256
+ .get(taskId);
257
+ return row === undefined
258
+ ? undefined
259
+ : parseStored(row.data, EncryptedPayloadSchema, "stored_task_payload_invalid");
260
+ }
261
+ saveTaskExecutionResult(task, payload) {
262
+ const parsed = EncryptedPayloadSchema.parse(payload);
263
+ this.transaction(() => {
264
+ this.saveTask(task);
265
+ const result = this.database
266
+ .prepare(`
267
+ UPDATE task_payloads
268
+ SET result_payload = ?, updated_at = ?
269
+ WHERE task_id = ?
270
+ `)
271
+ .run(JSON.stringify(parsed), task.updatedAt, task.id);
272
+ if (Number(result.changes) !== 1) {
273
+ throw new Error("task_payload_not_found");
274
+ }
275
+ });
276
+ }
277
+ getTaskResult(taskId) {
278
+ const row = this.database
279
+ .prepare("SELECT result_payload AS data FROM task_payloads WHERE task_id = ?")
280
+ .get(taskId);
281
+ return row === undefined || row.data === null
282
+ ? undefined
283
+ : parseStored(row.data, EncryptedPayloadSchema, "stored_task_result_invalid");
284
+ }
285
+ deleteTaskPayload(taskId) {
286
+ this.database
287
+ .prepare("DELETE FROM task_payloads WHERE task_id = ?")
288
+ .run(taskId);
289
+ }
290
+ listInterruptedTasks() {
291
+ const rows = this.database
292
+ .prepare(`
293
+ SELECT data FROM tasks
294
+ WHERE json_extract(data, '$.status') IN ('running', 'verifying')
295
+ ORDER BY updated_at
296
+ `)
297
+ .all();
298
+ return rows.map((row) => JSON.parse(row.data));
299
+ }
300
+ getWorkflowPayload(id, version) {
301
+ const row = this.database
302
+ .prepare(`
303
+ SELECT payload AS data FROM workflow_payloads
304
+ WHERE workflow_id = ? AND workflow_version = ?
305
+ `)
306
+ .get(id, version);
307
+ return row === undefined
308
+ ? undefined
309
+ : parseStored(row.data, EncryptedPayloadSchema, "stored_workflow_payload_invalid");
310
+ }
311
+ createWorkflow(redactedDefinition, payload, run, events) {
312
+ const definition = WorkflowDefinitionSchema.parse(redactedDefinition);
313
+ const sealed = EncryptedPayloadSchema.parse(payload);
314
+ const projection = WorkflowRunSchema.parse(run);
315
+ const parsedEvents = events.map((item) => WorkflowEventSchema.parse(item));
316
+ if (definition.id !== projection.definitionId ||
317
+ definition.version !== projection.definitionVersion) {
318
+ throw new Error("workflow_definition_mismatch");
319
+ }
320
+ this.assertWorkflowEvents(projection.id, projection.traceId, 0, projection.stateVersion, parsedEvents);
321
+ this.transaction(() => {
322
+ this.database
323
+ .prepare(`
324
+ INSERT INTO workflow_definitions(id, version, data, created_at)
325
+ VALUES (?, ?, ?, ?)
326
+ `)
327
+ .run(definition.id, definition.version, JSON.stringify(definition), projection.createdAt);
328
+ this.database
329
+ .prepare(`
330
+ INSERT INTO workflow_payloads(
331
+ workflow_id, workflow_version, payload, created_at
332
+ )
333
+ VALUES (?, ?, ?, ?)
334
+ `)
335
+ .run(definition.id, definition.version, JSON.stringify(sealed), projection.createdAt);
336
+ this.database
337
+ .prepare(`
338
+ INSERT INTO workflow_runs(
339
+ id, definition_id, definition_version, state_version, data,
340
+ created_at, updated_at
341
+ )
342
+ VALUES (?, ?, ?, ?, ?, ?, ?)
343
+ `)
344
+ .run(projection.id, projection.definitionId, projection.definitionVersion, projection.stateVersion, JSON.stringify(projection), projection.createdAt, projection.updatedAt);
345
+ for (const item of parsedEvents)
346
+ this.insertWorkflowEvent(item);
347
+ });
348
+ }
349
+ getWorkflowDefinition(id, version) {
350
+ const row = this.database
351
+ .prepare(`
352
+ SELECT data FROM workflow_definitions WHERE id = ? AND version = ?
353
+ `)
354
+ .get(id, version);
355
+ return row === undefined
356
+ ? undefined
357
+ : parseStored(row.data, WorkflowDefinitionSchema, "stored_workflow_definition_invalid");
358
+ }
359
+ getWorkflowRun(id) {
360
+ const row = this.database
361
+ .prepare("SELECT data FROM workflow_runs WHERE id = ?")
362
+ .get(id);
363
+ return row === undefined
364
+ ? undefined
365
+ : parseStored(row.data, WorkflowRunSchema, "stored_workflow_run_invalid");
366
+ }
367
+ listWorkflowRuns(statuses = []) {
368
+ const rows = statuses.length === 0
369
+ ? this.database
370
+ .prepare("SELECT data FROM workflow_runs ORDER BY updated_at")
371
+ .all()
372
+ : this.database
373
+ .prepare(`
374
+ SELECT data FROM workflow_runs
375
+ WHERE json_extract(data, '$.status') IN (
376
+ ${statuses.map(() => "?").join(", ")}
377
+ )
378
+ ORDER BY updated_at
379
+ `)
380
+ .all(...statuses);
381
+ return rows.map((row) => parseStored(row.data, WorkflowRunSchema, "stored_workflow_run_invalid"));
382
+ }
383
+ listWorkflowEvents(id, afterSequence = 0) {
384
+ const rows = this.database
385
+ .prepare(`
386
+ SELECT data FROM workflow_events
387
+ WHERE aggregate_id = ? AND sequence > ?
388
+ ORDER BY sequence
389
+ `)
390
+ .all(id, afterSequence);
391
+ return rows.map((row) => parseStored(row.data, WorkflowEventSchema, "stored_workflow_event_invalid"));
392
+ }
393
+ saveWorkflowSnapshot(snapshot) {
394
+ const parsed = WorkflowSnapshotSchema.parse(snapshot);
395
+ this.database
396
+ .prepare(`
397
+ INSERT INTO workflow_snapshots(
398
+ workflow_id, sequence, data, created_at
399
+ )
400
+ VALUES (?, ?, ?, ?)
401
+ `)
402
+ .run(parsed.workflowId, parsed.sequence, JSON.stringify(parsed), parsed.createdAt);
403
+ }
404
+ getLatestWorkflowSnapshot(id) {
405
+ const row = this.database
406
+ .prepare(`
407
+ SELECT data FROM workflow_snapshots
408
+ WHERE workflow_id = ?
409
+ ORDER BY sequence DESC
410
+ LIMIT 1
411
+ `)
412
+ .get(id);
413
+ return row === undefined
414
+ ? undefined
415
+ : parseStored(row.data, WorkflowSnapshotSchema, "stored_workflow_snapshot_invalid");
416
+ }
417
+ transitionWorkflow(id, expectedStateVersion, run, events) {
418
+ const projection = WorkflowRunSchema.parse(run);
419
+ const parsedEvents = events.map((item) => WorkflowEventSchema.parse(item));
420
+ this.assertWorkflowEvents(id, projection.traceId, expectedStateVersion, projection.stateVersion, parsedEvents);
421
+ if (projection.id !== id)
422
+ throw new Error("workflow_projection_id_mismatch");
423
+ this.transaction(() => {
424
+ const current = this.database
425
+ .prepare(`SELECT state_version AS stateVersion, data
426
+ FROM workflow_runs WHERE id = ?`)
427
+ .get(id);
428
+ if (current === undefined)
429
+ throw new Error("workflow_not_found");
430
+ if (current.stateVersion !== expectedStateVersion) {
431
+ throw new Error("workflow_state_conflict");
432
+ }
433
+ const stored = parseStored(current.data, WorkflowRunSchema, "stored_workflow_run_invalid");
434
+ if (projection.definitionId !== stored.definitionId ||
435
+ projection.definitionVersion !== stored.definitionVersion ||
436
+ projection.traceId !== stored.traceId) {
437
+ throw new Error("workflow_projection_identity_mismatch");
438
+ }
439
+ for (const item of parsedEvents)
440
+ this.insertWorkflowEvent(item);
441
+ const updated = this.database
442
+ .prepare(`
443
+ UPDATE workflow_runs
444
+ SET state_version = ?, data = ?, updated_at = ?
445
+ WHERE id = ? AND state_version = ?
446
+ `)
447
+ .run(projection.stateVersion, JSON.stringify(projection), projection.updatedAt, id, expectedStateVersion);
448
+ if (Number(updated.changes) !== 1) {
449
+ throw new Error("workflow_state_conflict");
450
+ }
451
+ });
452
+ }
453
+ // Cross-process mutual exclusion for one workflow. `BEGIN IMMEDIATE` in
454
+ // `transaction` takes SQLite's write lock, so two server processes sharing a
455
+ // data directory serialize here and exactly one wins the row. Leases expire
456
+ // so a killed process cannot strand a workflow forever.
457
+ acquireWorkflowLease(workflowId, owner, now, expiresAt) {
458
+ return this.transaction(() => {
459
+ const existing = this.database
460
+ .prepare(`SELECT owner, expires_at AS expiresAt
461
+ FROM workflow_leases WHERE workflow_id = ?`)
462
+ .get(workflowId);
463
+ if (existing !== undefined &&
464
+ existing.owner !== owner &&
465
+ existing.expiresAt > now) {
466
+ return false;
467
+ }
468
+ this.database
469
+ .prepare(`
470
+ INSERT INTO workflow_leases(
471
+ workflow_id, owner, acquired_at, expires_at
472
+ )
473
+ VALUES (?, ?, ?, ?)
474
+ ON CONFLICT(workflow_id) DO UPDATE SET
475
+ owner = excluded.owner,
476
+ acquired_at = excluded.acquired_at,
477
+ expires_at = excluded.expires_at
478
+ `)
479
+ .run(workflowId, owner, now, expiresAt);
480
+ return true;
481
+ });
482
+ }
483
+ renewWorkflowLease(workflowId, owner, expiresAt) {
484
+ const result = this.database
485
+ .prepare(`UPDATE workflow_leases SET expires_at = ?
486
+ WHERE workflow_id = ? AND owner = ?`)
487
+ .run(expiresAt, workflowId, owner);
488
+ return Number(result.changes) === 1;
489
+ }
490
+ releaseWorkflowLease(workflowId, owner) {
491
+ this.database
492
+ .prepare("DELETE FROM workflow_leases WHERE workflow_id = ? AND owner = ?")
493
+ .run(workflowId, owner);
494
+ }
495
+ getWorkflowLease(workflowId) {
496
+ return this.database
497
+ .prepare(`
498
+ SELECT owner, acquired_at AS acquiredAt, expires_at AS expiresAt
499
+ FROM workflow_leases WHERE workflow_id = ?
500
+ `)
501
+ .get(workflowId);
502
+ }
503
+ commitIdempotency(key, taskId, attempt, at) {
504
+ const result = this.database
505
+ .prepare(`
506
+ INSERT OR IGNORE INTO idempotency_commits(
507
+ idempotency_key, task_id, attempt, committed_at
508
+ )
509
+ VALUES (?, ?, ?, ?)
510
+ `)
511
+ .run(key, taskId, attempt, at);
512
+ return Number(result.changes) === 1;
513
+ }
514
+ getIdempotencyCommit(key) {
515
+ return this.database
516
+ .prepare(`
517
+ SELECT task_id AS taskId, attempt, committed_at AS committedAt
518
+ FROM idempotency_commits
519
+ WHERE idempotency_key = ?
520
+ `)
521
+ .get(key);
522
+ }
523
+ assertWorkflowEvents(workflowId, traceId, previousStateVersion, nextStateVersion, events) {
524
+ if (events.some((item) => item.aggregateId !== workflowId || item.traceId !== traceId)) {
525
+ throw new Error("workflow_event_identity_invalid");
526
+ }
527
+ if (events.length === 0 ||
528
+ nextStateVersion !== previousStateVersion + events.length ||
529
+ events.some((item, index) => item.sequence !== previousStateVersion + index + 1)) {
530
+ throw new Error("workflow_event_sequence_invalid");
531
+ }
532
+ }
533
+ insertWorkflowEvent(event) {
534
+ this.database
535
+ .prepare(`
536
+ INSERT INTO workflow_events(
537
+ id, aggregate_id, sequence, trace_id, type, data, occurred_at
538
+ )
539
+ VALUES (?, ?, ?, ?, ?, ?, ?)
540
+ `)
541
+ .run(event.id, event.aggregateId, event.sequence, event.traceId, event.type, JSON.stringify(event), event.occurredAt);
542
+ }
543
+ saveReceipt(receipt) {
544
+ this.database
545
+ .prepare(`
546
+ INSERT INTO receipts(id, task_id, data, created_at)
547
+ VALUES (?, ?, ?, ?)
548
+ `)
549
+ .run(receipt.receiptId, receipt.taskId, JSON.stringify(receipt), receipt.endedAt);
550
+ }
551
+ getReceipt(id) {
552
+ const row = this.database
553
+ .prepare("SELECT data FROM receipts WHERE id = ?")
554
+ .get(id);
555
+ return row === undefined ? undefined : JSON.parse(row.data);
556
+ }
557
+ getReceiptsForTask(taskId) {
558
+ const rows = this.database
559
+ .prepare("SELECT data FROM receipts WHERE task_id = ? ORDER BY created_at")
560
+ .all(taskId);
561
+ return rows.map((row) => JSON.parse(row.data));
562
+ }
563
+ saveCertificate(certificate) {
564
+ this.database
565
+ .prepare(`
566
+ INSERT INTO certificates(id, task_id, data, created_at)
567
+ VALUES (?, ?, ?, ?)
568
+ ON CONFLICT(task_id) DO UPDATE SET
569
+ id = excluded.id,
570
+ data = excluded.data,
571
+ created_at = excluded.created_at
572
+ `)
573
+ .run(certificate.certificateId, certificate.taskId, JSON.stringify(certificate), certificate.createdAt);
574
+ }
575
+ getCertificateForTask(taskId) {
576
+ const row = this.database
577
+ .prepare("SELECT data FROM certificates WHERE task_id = ?")
578
+ .get(taskId);
579
+ return row === undefined
580
+ ? undefined
581
+ : JSON.parse(row.data);
582
+ }
583
+ putMemory(memory) {
584
+ this.database
585
+ .prepare(`
586
+ INSERT INTO memories(
587
+ id, scope, key, value, source, confidence, tags, speaker, episode_id,
588
+ sequence, expires_at, supersedes_id, superseded_by, created_at,
589
+ updated_at
590
+ )
591
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
592
+ ON CONFLICT(id) DO UPDATE SET
593
+ scope = excluded.scope,
594
+ key = excluded.key,
595
+ value = excluded.value,
596
+ source = excluded.source,
597
+ confidence = excluded.confidence,
598
+ tags = excluded.tags,
599
+ speaker = excluded.speaker,
600
+ episode_id = excluded.episode_id,
601
+ sequence = excluded.sequence,
602
+ expires_at = excluded.expires_at,
603
+ supersedes_id = excluded.supersedes_id,
604
+ superseded_by = excluded.superseded_by,
605
+ updated_at = excluded.updated_at
606
+ `)
607
+ .run(memory.id, memory.scope, memory.key, memory.value, memory.source, memory.confidence, JSON.stringify(memory.tags), memory.speaker ?? null, memory.episodeId ?? null, memory.sequence ?? null, memory.expiresAt ?? null, memory.supersedesId ?? null, memory.supersededBy ?? null, memory.createdAt, memory.updatedAt);
608
+ }
609
+ getMemory(id) {
610
+ const row = this.database
611
+ .prepare(`
612
+ SELECT id, scope, key, value, source, confidence, tags, speaker,
613
+ episode_id AS episodeId, sequence,
614
+ expires_at AS expiresAt, supersedes_id AS supersedesId,
615
+ superseded_by AS supersededBy,
616
+ created_at AS createdAt, updated_at AS updatedAt
617
+ FROM memories WHERE id = ?
618
+ `)
619
+ .get(id);
620
+ return row === undefined ? undefined : toMemoryRecord(row);
621
+ }
622
+ listMemories(scope, limit, includeSuperseded = false) {
623
+ const rows = this.database
624
+ .prepare(`
625
+ SELECT id, scope, key, value, source, confidence, tags, speaker,
626
+ episode_id AS episodeId, sequence,
627
+ expires_at AS expiresAt, supersedes_id AS supersedesId,
628
+ superseded_by AS supersededBy,
629
+ created_at AS createdAt, updated_at AS updatedAt
630
+ FROM memories
631
+ WHERE scope = ?
632
+ AND (expires_at IS NULL OR expires_at > ?)
633
+ AND (? = 1 OR superseded_by IS NULL)
634
+ ORDER BY updated_at DESC
635
+ LIMIT ?
636
+ `)
637
+ .all(scope, new Date().toISOString(), includeSuperseded ? 1 : 0, limit);
638
+ return rows.map(toMemoryRecord);
639
+ }
640
+ memoryCandidates(scope, limit, includeSuperseded = false) {
641
+ const rows = this.database
642
+ .prepare(`
643
+ SELECT id, scope, key, value, source, confidence, tags, speaker,
644
+ episode_id AS episodeId, sequence,
645
+ expires_at AS expiresAt, supersedes_id AS supersedesId,
646
+ superseded_by AS supersededBy,
647
+ created_at AS createdAt, updated_at AS updatedAt
648
+ FROM memories
649
+ WHERE scope = ?
650
+ AND (expires_at IS NULL OR expires_at > ?)
651
+ AND (? = 1 OR superseded_by IS NULL)
652
+ ORDER BY updated_at DESC
653
+ LIMIT ?
654
+ `)
655
+ .all(scope, new Date().toISOString(), includeSuperseded ? 1 : 0, limit);
656
+ return rows.map(toMemoryRecord);
657
+ }
658
+ supersedeMemory(id, scope, supersededBy) {
659
+ const result = this.database
660
+ .prepare(`
661
+ UPDATE memories
662
+ SET superseded_by = ?, updated_at = ?
663
+ WHERE id = ? AND scope = ? AND superseded_by IS NULL
664
+ `)
665
+ .run(supersededBy, new Date().toISOString(), id, scope);
666
+ return Number(result.changes) > 0;
667
+ }
668
+ deleteMemory(id, scope) {
669
+ const result = this.database
670
+ .prepare("DELETE FROM memories WHERE id = ? AND scope = ?")
671
+ .run(id, scope);
672
+ return Number(result.changes) > 0;
673
+ }
674
+ clearMemories(scope) {
675
+ const result = this.database
676
+ .prepare("DELETE FROM memories WHERE scope = ?")
677
+ .run(scope);
678
+ return Number(result.changes);
679
+ }
680
+ /**
681
+ * Reclaim memory rows that no read path can return.
682
+ *
683
+ * `listMemories` and `memoryCandidates` both filter out expired rows, and
684
+ * filter out superseded ones unless asked for them, but nothing ever deleted
685
+ * either — so a long-lived install grew forever while the searchable set
686
+ * stayed the same size. This deletes what is already unreachable.
687
+ *
688
+ * `maxPerScope` is different in kind and defaults to off: it evicts *live*
689
+ * memories, which is data the caller stored and can still read. An operator
690
+ * who wants a hard ceiling opts into one.
691
+ *
692
+ * A superseded row is only dropped once nothing live points at it, so
693
+ * `supersedes_id` never dangles and a chain is removed from its tail
694
+ * inwards across successive compactions.
695
+ */
696
+ compactMemories(scope, retention, now = new Date()) {
697
+ const nowIso = now.toISOString();
698
+ const cutoff = new Date(now.getTime() - retention.maxAgeDays * 86_400_000).toISOString();
699
+ return this.transaction(() => {
700
+ const expired = Number(this.database
701
+ .prepare("DELETE FROM memories WHERE scope = ? AND expires_at IS NOT NULL AND expires_at <= ?")
702
+ .run(scope, nowIso).changes);
703
+ const superseded = Number(this.database
704
+ .prepare(`
705
+ DELETE FROM memories
706
+ WHERE scope = ?
707
+ AND superseded_by IS NOT NULL
708
+ AND updated_at <= ?
709
+ AND id NOT IN (
710
+ SELECT supersedes_id FROM memories
711
+ WHERE scope = ? AND supersedes_id IS NOT NULL AND superseded_by IS NULL
712
+ )
713
+ `)
714
+ .run(scope, cutoff, scope).changes);
715
+ const evicted = retention.maxPerScope <= 0
716
+ ? 0
717
+ : Number(this.database
718
+ .prepare(`
719
+ DELETE FROM memories
720
+ WHERE scope = ? AND superseded_by IS NULL AND id NOT IN (
721
+ SELECT id FROM memories
722
+ WHERE scope = ? AND superseded_by IS NULL
723
+ ORDER BY updated_at DESC, id DESC
724
+ LIMIT ?
725
+ )
726
+ `)
727
+ .run(scope, scope, retention.maxPerScope).changes);
728
+ return { expired, superseded, evicted };
729
+ });
730
+ }
731
+ close() {
732
+ this.database.close();
733
+ }
734
+ }
735
+ //# sourceMappingURL=index.js.map