@chainlesschain/personal-data-hub 0.1.0

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.
Files changed (50) hide show
  1. package/README.md +241 -0
  2. package/__tests__/adapter-spec.test.js +78 -0
  3. package/__tests__/adapters/email-adapter.test.js +605 -0
  4. package/__tests__/adapters/email-imap-session.test.js +334 -0
  5. package/__tests__/adapters/email-parser.test.js +244 -0
  6. package/__tests__/adapters/email-providers.test.js +84 -0
  7. package/__tests__/analysis.test.js +302 -0
  8. package/__tests__/batch.test.js +133 -0
  9. package/__tests__/bridges-cc-kg.test.js +231 -0
  10. package/__tests__/bridges-cc-llm.test.js +191 -0
  11. package/__tests__/bridges-cc-rag.test.js +162 -0
  12. package/__tests__/ids.test.js +45 -0
  13. package/__tests__/key-providers.test.js +126 -0
  14. package/__tests__/kg-derive.test.js +219 -0
  15. package/__tests__/llm-client.test.js +122 -0
  16. package/__tests__/mock-adapter.test.js +93 -0
  17. package/__tests__/prompt-builder.test.js +204 -0
  18. package/__tests__/query-parser.test.js +150 -0
  19. package/__tests__/rag-derive.test.js +169 -0
  20. package/__tests__/registry.test.js +304 -0
  21. package/__tests__/schemas.test.js +331 -0
  22. package/__tests__/vault.test.js +506 -0
  23. package/lib/adapter-spec.js +155 -0
  24. package/lib/adapters/email-imap/email-adapter.js +398 -0
  25. package/lib/adapters/email-imap/email-parser.js +177 -0
  26. package/lib/adapters/email-imap/imap-session.js +294 -0
  27. package/lib/adapters/email-imap/index.js +26 -0
  28. package/lib/adapters/email-imap/providers.js +111 -0
  29. package/lib/analysis.js +226 -0
  30. package/lib/batch.js +123 -0
  31. package/lib/bridges/cc-kg-sink.js +264 -0
  32. package/lib/bridges/cc-llm-adapter.js +169 -0
  33. package/lib/bridges/cc-rag-sink.js +118 -0
  34. package/lib/bridges/index.js +44 -0
  35. package/lib/constants.js +92 -0
  36. package/lib/ids.js +103 -0
  37. package/lib/index.js +141 -0
  38. package/lib/key-providers.js +146 -0
  39. package/lib/kg-derive.js +214 -0
  40. package/lib/llm-client.js +171 -0
  41. package/lib/migrations.js +246 -0
  42. package/lib/mock-adapter.js +199 -0
  43. package/lib/prompt-builder.js +205 -0
  44. package/lib/query-parser.js +250 -0
  45. package/lib/rag-derive.js +186 -0
  46. package/lib/registry.js +398 -0
  47. package/lib/schemas.js +379 -0
  48. package/lib/vault.js +883 -0
  49. package/package.json +63 -0
  50. package/vitest.config.js +10 -0
package/lib/vault.js ADDED
@@ -0,0 +1,883 @@
1
+ /**
2
+ * LocalVault — SQLCipher-encrypted Personal Data Hub storage.
3
+ *
4
+ * Mirrors §7 (Security model) + §4.2 (Hub core tables) of
5
+ * docs/design/Personal_Data_Hub_Architecture.md.
6
+ *
7
+ * Design choices:
8
+ * - SQLCipher (via better-sqlite3-multiple-ciphers) with AES-256.
9
+ * - Master key sourced from a pluggable KeyProvider (see key-providers.js).
10
+ * The vault never sees plaintext storage of the key beyond the in-memory
11
+ * PRAGMA-key application during open().
12
+ * - Per-entity tables (not one big JSON blob) so KG ingest + RAG queries
13
+ * stay fast and SQL-shaped.
14
+ * - JSON columns for schemaless tails (extra, identifiers, content, etc.).
15
+ * - All entity writes go through schema validators first — invalid rows
16
+ * are rejected (don't pollute the vault).
17
+ * - putBatch is transactional: all-or-nothing per batch. Adapter-level
18
+ * partial-batch tolerance happens in batch.partitionBatch() before this.
19
+ * - Key rotation walks the file with PRAGMA rekey; the caller is
20
+ * responsible for arranging atomic rotation across processes (close
21
+ * other handles first).
22
+ */
23
+
24
+ "use strict";
25
+
26
+ const fs = require("node:fs");
27
+ const path = require("node:path");
28
+
29
+ const { validate } = require("./schemas");
30
+ const { applyMigrations, getSchemaVersion } = require("./migrations");
31
+ const { isValidKeyHex } = require("./key-providers");
32
+
33
+ // Default SQLCipher cipher (matches WCDB / mainstream SQLCipher v4).
34
+ const DEFAULT_CIPHER = "sqlcipher";
35
+ const DEFAULT_KDF_ITER = 256000;
36
+ const DEFAULT_CIPHER_PAGE_SIZE = 4096;
37
+
38
+ // ─── Helpers ─────────────────────────────────────────────────────────────
39
+
40
+ function loadDriver() {
41
+ // Lazy require so consumers that only need schemas don't pay for the
42
+ // native binding load. Errors surface here with a precise message.
43
+ try {
44
+ return require("better-sqlite3-multiple-ciphers");
45
+ } catch (err) {
46
+ const msg =
47
+ "Failed to load better-sqlite3-multiple-ciphers. " +
48
+ "Install it as a workspace dep or pin the version in your package. " +
49
+ "Original error: " + (err && err.message ? err.message : String(err));
50
+ const wrapped = new Error(msg);
51
+ wrapped.cause = err;
52
+ throw wrapped;
53
+ }
54
+ }
55
+
56
+ function escSingleQuote(s) {
57
+ // Used only for PRAGMA values — better-sqlite3 doesn't bind PRAGMA values.
58
+ return String(s).replace(/'/g, "''");
59
+ }
60
+
61
+ function ensureValidId(id, label) {
62
+ if (typeof id !== "string" || id.length === 0) {
63
+ throw new Error(`${label} must be a non-empty string id`);
64
+ }
65
+ }
66
+
67
+ // ─── LocalVault ──────────────────────────────────────────────────────────
68
+
69
+ class LocalVault {
70
+ /**
71
+ * @param {object} opts
72
+ * @param {string} opts.path absolute path to vault file
73
+ * @param {string} opts.key hex-encoded 32-byte master key
74
+ * @param {string} [opts.cipher] SQLCipher cipher name (default "sqlcipher")
75
+ * @param {number} [opts.kdfIter] PBKDF2 iterations (default 256000)
76
+ * @param {number} [opts.pageSize] cipher page size bytes (default 4096)
77
+ * @param {boolean} [opts.readonly] open read-only (no migrations)
78
+ * @param {boolean} [opts.skipAudit] skip writing the vault.opened audit row (tests)
79
+ */
80
+ constructor(opts) {
81
+ if (!opts || typeof opts !== "object") {
82
+ throw new Error("LocalVault: opts required");
83
+ }
84
+ if (typeof opts.path !== "string" || opts.path.length === 0) {
85
+ throw new Error("LocalVault: opts.path required");
86
+ }
87
+ if (!isValidKeyHex(opts.key)) {
88
+ throw new Error("LocalVault: opts.key must be 64 hex chars (32 bytes)");
89
+ }
90
+ this.path = opts.path;
91
+ this._key = opts.key;
92
+ this.cipher = opts.cipher || DEFAULT_CIPHER;
93
+ this.kdfIter = opts.kdfIter || DEFAULT_KDF_ITER;
94
+ this.pageSize = opts.pageSize || DEFAULT_CIPHER_PAGE_SIZE;
95
+ this.readonly = !!opts.readonly;
96
+ this._skipAudit = !!opts.skipAudit;
97
+ this.db = null;
98
+ }
99
+
100
+ /**
101
+ * Open the vault. Applies PRAGMA key, runs pending migrations, enables WAL.
102
+ * Idempotent — calling on an already-open vault returns the existing handle.
103
+ */
104
+ open() {
105
+ if (this.db) return this.db;
106
+
107
+ const Database = loadDriver();
108
+ fs.mkdirSync(path.dirname(this.path), { recursive: true });
109
+
110
+ const db = new Database(this.path, this.readonly ? { readonly: true } : {});
111
+
112
+ // Cipher config goes BEFORE the key. better-sqlite3-multiple-ciphers
113
+ // supports the sqlcipher dialect family natively.
114
+ db.pragma(`cipher='${escSingleQuote(this.cipher)}'`);
115
+ db.pragma(`kdf_iter=${this.kdfIter | 0}`);
116
+ db.pragma(`cipher_page_size=${this.pageSize | 0}`);
117
+ db.pragma(`key='${escSingleQuote(this._key)}'`);
118
+
119
+ // Smoke check — verifies decryption succeeded. A wrong key surfaces here
120
+ // as a SqliteError "file is not a database".
121
+ try {
122
+ db.prepare("SELECT count(*) FROM sqlite_master").get();
123
+ } catch (err) {
124
+ db.close();
125
+ const wrapped = new Error(
126
+ "LocalVault.open: decryption failed (likely wrong key or corrupted file)"
127
+ );
128
+ wrapped.cause = err;
129
+ throw wrapped;
130
+ }
131
+
132
+ if (!this.readonly) {
133
+ db.pragma("journal_mode = WAL");
134
+ db.pragma("foreign_keys = ON");
135
+ applyMigrations(db);
136
+ }
137
+
138
+ this.db = db;
139
+
140
+ if (!this.readonly && !this._skipAudit) {
141
+ this._auditDirect("vault.opened", this.path, { schemaVersion: getSchemaVersion(db) });
142
+ }
143
+
144
+ return db;
145
+ }
146
+
147
+ close() {
148
+ if (this.db) {
149
+ try {
150
+ this.db.close();
151
+ } finally {
152
+ this.db = null;
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Close + delete vault file (and its sidecar -wal / -shm).
159
+ * For "擦除所有数据" UX. Doesn't touch the master key in the KeyProvider —
160
+ * that's the caller's job, since key lifecycle policy varies.
161
+ */
162
+ destroy() {
163
+ this.close();
164
+ const candidates = [this.path, this.path + "-wal", this.path + "-shm"];
165
+ for (const p of candidates) {
166
+ try {
167
+ if (fs.existsSync(p)) fs.unlinkSync(p);
168
+ } catch (_err) {
169
+ // Best-effort. Leftover sidecar files are harmless without the key.
170
+ }
171
+ }
172
+ }
173
+
174
+ _requireOpen() {
175
+ if (!this.db) throw new Error("LocalVault: open() the vault first");
176
+ return this.db;
177
+ }
178
+
179
+ // ─── Schema-versioned ID dedup ─────────────────────────────────────────
180
+
181
+ schemaVersion() {
182
+ return getSchemaVersion(this._requireOpen());
183
+ }
184
+
185
+ // ─── Entity put ────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Insert or replace an Event. Validates first; throws on invalid input
189
+ * (caller should partition before calling — see batch.partitionBatch).
190
+ */
191
+ putEvent(event) {
192
+ const r = validate(event);
193
+ if (!r.valid) {
194
+ throw new Error(
195
+ `LocalVault.putEvent: invalid event ${event && event.id} — ${r.errors.join("; ")}`
196
+ );
197
+ }
198
+ const db = this._requireOpen();
199
+ return db
200
+ .prepare(
201
+ `INSERT INTO events
202
+ (id, subtype, occurred_at, duration_ms, actor, participants, place, items, topics,
203
+ content, source_adapter, source_original_id, source, extra, ingested_at, confidence)
204
+ VALUES (@id, @subtype, @occurredAt, @durationMs, @actor, @participants, @place, @items, @topics,
205
+ @content, @sourceAdapter, @sourceOriginalId, @source, @extra, @ingestedAt, @confidence)
206
+ ON CONFLICT(id) DO UPDATE SET
207
+ subtype = excluded.subtype,
208
+ occurred_at = excluded.occurred_at,
209
+ duration_ms = excluded.duration_ms,
210
+ actor = excluded.actor,
211
+ participants = excluded.participants,
212
+ place = excluded.place,
213
+ items = excluded.items,
214
+ topics = excluded.topics,
215
+ content = excluded.content,
216
+ source_adapter = excluded.source_adapter,
217
+ source_original_id = excluded.source_original_id,
218
+ source = excluded.source,
219
+ extra = excluded.extra,
220
+ ingested_at = excluded.ingested_at,
221
+ confidence = excluded.confidence`
222
+ )
223
+ .run({
224
+ id: event.id,
225
+ subtype: event.subtype,
226
+ occurredAt: event.occurredAt,
227
+ durationMs: event.durationMs ?? null,
228
+ actor: event.actor ?? null,
229
+ participants: event.participants ? JSON.stringify(event.participants) : null,
230
+ place: event.place ?? null,
231
+ items: event.items ? JSON.stringify(event.items) : null,
232
+ topics: event.topics ? JSON.stringify(event.topics) : null,
233
+ content: JSON.stringify(event.content),
234
+ sourceAdapter: event.source.adapter,
235
+ sourceOriginalId: event.source.originalId ?? null,
236
+ source: JSON.stringify(event.source),
237
+ extra: event.extra ? JSON.stringify(event.extra) : null,
238
+ ingestedAt: event.ingestedAt,
239
+ confidence: event.confidence ?? null,
240
+ });
241
+ }
242
+
243
+ putPerson(person) {
244
+ const r = validate(person);
245
+ if (!r.valid) {
246
+ throw new Error(
247
+ `LocalVault.putPerson: invalid person ${person && person.id} — ${r.errors.join("; ")}`
248
+ );
249
+ }
250
+ const db = this._requireOpen();
251
+ return db
252
+ .prepare(
253
+ `INSERT INTO persons
254
+ (id, subtype, names, identifiers, relation, notes,
255
+ source_adapter, source_original_id, source, extra, ingested_at, confidence)
256
+ VALUES (@id, @subtype, @names, @identifiers, @relation, @notes,
257
+ @sourceAdapter, @sourceOriginalId, @source, @extra, @ingestedAt, @confidence)
258
+ ON CONFLICT(id) DO UPDATE SET
259
+ subtype = excluded.subtype,
260
+ names = excluded.names,
261
+ identifiers = excluded.identifiers,
262
+ relation = excluded.relation,
263
+ notes = excluded.notes,
264
+ source_adapter = excluded.source_adapter,
265
+ source_original_id = excluded.source_original_id,
266
+ source = excluded.source,
267
+ extra = excluded.extra,
268
+ ingested_at = excluded.ingested_at,
269
+ confidence = excluded.confidence`
270
+ )
271
+ .run({
272
+ id: person.id,
273
+ subtype: person.subtype,
274
+ names: JSON.stringify(person.names),
275
+ identifiers: person.identifiers ? JSON.stringify(person.identifiers) : null,
276
+ relation: person.relation ?? null,
277
+ notes: person.notes ?? null,
278
+ sourceAdapter: person.source.adapter,
279
+ sourceOriginalId: person.source.originalId ?? null,
280
+ source: JSON.stringify(person.source),
281
+ extra: person.extra ? JSON.stringify(person.extra) : null,
282
+ ingestedAt: person.ingestedAt,
283
+ confidence: person.confidence ?? null,
284
+ });
285
+ }
286
+
287
+ putPlace(place) {
288
+ const r = validate(place);
289
+ if (!r.valid) {
290
+ throw new Error(
291
+ `LocalVault.putPlace: invalid place ${place && place.id} — ${r.errors.join("; ")}`
292
+ );
293
+ }
294
+ const db = this._requireOpen();
295
+ return db
296
+ .prepare(
297
+ `INSERT INTO places
298
+ (id, name, coordinates_lat, coordinates_lng, address, category, aliases,
299
+ source_adapter, source_original_id, source, extra, ingested_at, confidence)
300
+ VALUES (@id, @name, @lat, @lng, @address, @category, @aliases,
301
+ @sourceAdapter, @sourceOriginalId, @source, @extra, @ingestedAt, @confidence)
302
+ ON CONFLICT(id) DO UPDATE SET
303
+ name = excluded.name,
304
+ coordinates_lat = excluded.coordinates_lat,
305
+ coordinates_lng = excluded.coordinates_lng,
306
+ address = excluded.address,
307
+ category = excluded.category,
308
+ aliases = excluded.aliases,
309
+ source_adapter = excluded.source_adapter,
310
+ source_original_id = excluded.source_original_id,
311
+ source = excluded.source,
312
+ extra = excluded.extra,
313
+ ingested_at = excluded.ingested_at,
314
+ confidence = excluded.confidence`
315
+ )
316
+ .run({
317
+ id: place.id,
318
+ name: place.name,
319
+ lat: place.coordinates ? place.coordinates.lat : null,
320
+ lng: place.coordinates ? place.coordinates.lng : null,
321
+ address: place.address ?? null,
322
+ category: place.category ?? null,
323
+ aliases: JSON.stringify(place.aliases),
324
+ sourceAdapter: place.source.adapter,
325
+ sourceOriginalId: place.source.originalId ?? null,
326
+ source: JSON.stringify(place.source),
327
+ extra: place.extra ? JSON.stringify(place.extra) : null,
328
+ ingestedAt: place.ingestedAt,
329
+ confidence: place.confidence ?? null,
330
+ });
331
+ }
332
+
333
+ putItem(item) {
334
+ const r = validate(item);
335
+ if (!r.valid) {
336
+ throw new Error(
337
+ `LocalVault.putItem: invalid item ${item && item.id} — ${r.errors.join("; ")}`
338
+ );
339
+ }
340
+ const db = this._requireOpen();
341
+ return db
342
+ .prepare(
343
+ `INSERT INTO items
344
+ (id, subtype, name, category, price_value, price_currency, merchant,
345
+ external_url, thumbnail_local_path,
346
+ source_adapter, source_original_id, source, extra, ingested_at, confidence)
347
+ VALUES (@id, @subtype, @name, @category, @priceValue, @priceCurrency, @merchant,
348
+ @externalUrl, @thumbnailLocalPath,
349
+ @sourceAdapter, @sourceOriginalId, @source, @extra, @ingestedAt, @confidence)
350
+ ON CONFLICT(id) DO UPDATE SET
351
+ subtype = excluded.subtype,
352
+ name = excluded.name,
353
+ category = excluded.category,
354
+ price_value = excluded.price_value,
355
+ price_currency = excluded.price_currency,
356
+ merchant = excluded.merchant,
357
+ external_url = excluded.external_url,
358
+ thumbnail_local_path = excluded.thumbnail_local_path,
359
+ source_adapter = excluded.source_adapter,
360
+ source_original_id = excluded.source_original_id,
361
+ source = excluded.source,
362
+ extra = excluded.extra,
363
+ ingested_at = excluded.ingested_at,
364
+ confidence = excluded.confidence`
365
+ )
366
+ .run({
367
+ id: item.id,
368
+ subtype: item.subtype,
369
+ name: item.name,
370
+ category: item.category ?? null,
371
+ priceValue: item.price ? item.price.value : null,
372
+ priceCurrency: item.price ? item.price.currency : null,
373
+ merchant: item.merchant ?? null,
374
+ externalUrl: item.externalUrl ?? null,
375
+ thumbnailLocalPath: item.thumbnailLocalPath ?? null,
376
+ sourceAdapter: item.source.adapter,
377
+ sourceOriginalId: item.source.originalId ?? null,
378
+ source: JSON.stringify(item.source),
379
+ extra: item.extra ? JSON.stringify(item.extra) : null,
380
+ ingestedAt: item.ingestedAt,
381
+ confidence: item.confidence ?? null,
382
+ });
383
+ }
384
+
385
+ putTopic(topic) {
386
+ const r = validate(topic);
387
+ if (!r.valid) {
388
+ throw new Error(
389
+ `LocalVault.putTopic: invalid topic ${topic && topic.id} — ${r.errors.join("; ")}`
390
+ );
391
+ }
392
+ const db = this._requireOpen();
393
+ return db
394
+ .prepare(
395
+ `INSERT INTO topics
396
+ (id, name, parent_topic, derived_from_events,
397
+ source_adapter, source_original_id, source, extra, ingested_at, confidence)
398
+ VALUES (@id, @name, @parentTopic, @derivedFromEvents,
399
+ @sourceAdapter, @sourceOriginalId, @source, @extra, @ingestedAt, @confidence)
400
+ ON CONFLICT(id) DO UPDATE SET
401
+ name = excluded.name,
402
+ parent_topic = excluded.parent_topic,
403
+ derived_from_events = excluded.derived_from_events,
404
+ source_adapter = excluded.source_adapter,
405
+ source_original_id = excluded.source_original_id,
406
+ source = excluded.source,
407
+ extra = excluded.extra,
408
+ ingested_at = excluded.ingested_at,
409
+ confidence = excluded.confidence`
410
+ )
411
+ .run({
412
+ id: topic.id,
413
+ name: topic.name,
414
+ parentTopic: topic.parentTopic ?? null,
415
+ derivedFromEvents: topic.derivedFromEvents ? JSON.stringify(topic.derivedFromEvents) : null,
416
+ sourceAdapter: topic.source.adapter,
417
+ sourceOriginalId: topic.source.originalId ?? null,
418
+ source: JSON.stringify(topic.source),
419
+ extra: topic.extra ? JSON.stringify(topic.extra) : null,
420
+ ingestedAt: topic.ingestedAt,
421
+ confidence: topic.confidence ?? null,
422
+ });
423
+ }
424
+
425
+ /**
426
+ * Transactionally insert/update an entire NormalizedBatch.
427
+ * Validates every entity up-front; aborts the whole batch on any failure.
428
+ * Returns counts per entity type.
429
+ */
430
+ putBatch(batch) {
431
+ if (batch == null || typeof batch !== "object") {
432
+ throw new Error("LocalVault.putBatch: batch must be a plain object");
433
+ }
434
+ const db = this._requireOpen();
435
+ const counts = { events: 0, persons: 0, places: 0, items: 0, topics: 0 };
436
+
437
+ const tx = db.transaction(() => {
438
+ for (const e of batch.events || []) {
439
+ this.putEvent(e);
440
+ counts.events += 1;
441
+ }
442
+ for (const p of batch.persons || []) {
443
+ this.putPerson(p);
444
+ counts.persons += 1;
445
+ }
446
+ for (const pl of batch.places || []) {
447
+ this.putPlace(pl);
448
+ counts.places += 1;
449
+ }
450
+ for (const i of batch.items || []) {
451
+ this.putItem(i);
452
+ counts.items += 1;
453
+ }
454
+ for (const t of batch.topics || []) {
455
+ this.putTopic(t);
456
+ counts.topics += 1;
457
+ }
458
+ });
459
+ tx();
460
+
461
+ return counts;
462
+ }
463
+
464
+ /**
465
+ * Append a raw adapter payload to the raw_events archive. Idempotent on
466
+ * (adapter, originalId) — re-ingest replaces the existing row, preserving
467
+ * the original capture timestamp.
468
+ */
469
+ putRawEvent({ adapter, originalId, capturedAt, payload }) {
470
+ if (typeof adapter !== "string" || adapter.length === 0)
471
+ throw new Error("putRawEvent: adapter required");
472
+ if (typeof originalId !== "string" || originalId.length === 0)
473
+ throw new Error("putRawEvent: originalId required");
474
+ if (!Number.isInteger(capturedAt) || capturedAt <= 0)
475
+ throw new Error("putRawEvent: capturedAt must be positive integer ms");
476
+
477
+ const db = this._requireOpen();
478
+ const json = typeof payload === "string" ? payload : JSON.stringify(payload);
479
+ return db
480
+ .prepare(
481
+ `INSERT INTO raw_events (adapter, original_id, captured_at, payload)
482
+ VALUES (?, ?, ?, ?)
483
+ ON CONFLICT(adapter, original_id) DO UPDATE SET
484
+ captured_at = excluded.captured_at,
485
+ payload = excluded.payload`
486
+ )
487
+ .run(adapter, originalId, capturedAt, json);
488
+ }
489
+
490
+ // ─── Entity reads ──────────────────────────────────────────────────────
491
+
492
+ getEvent(id) {
493
+ ensureValidId(id, "getEvent");
494
+ const row = this._requireOpen().prepare("SELECT * FROM events WHERE id = ?").get(id);
495
+ return row ? this._rowToEvent(row) : null;
496
+ }
497
+ getPerson(id) {
498
+ ensureValidId(id, "getPerson");
499
+ const row = this._requireOpen().prepare("SELECT * FROM persons WHERE id = ?").get(id);
500
+ return row ? this._rowToPerson(row) : null;
501
+ }
502
+ getPlace(id) {
503
+ ensureValidId(id, "getPlace");
504
+ const row = this._requireOpen().prepare("SELECT * FROM places WHERE id = ?").get(id);
505
+ return row ? this._rowToPlace(row) : null;
506
+ }
507
+ getItem(id) {
508
+ ensureValidId(id, "getItem");
509
+ const row = this._requireOpen().prepare("SELECT * FROM items WHERE id = ?").get(id);
510
+ return row ? this._rowToItem(row) : null;
511
+ }
512
+ getTopic(id) {
513
+ ensureValidId(id, "getTopic");
514
+ const row = this._requireOpen().prepare("SELECT * FROM topics WHERE id = ?").get(id);
515
+ return row ? this._rowToTopic(row) : null;
516
+ }
517
+
518
+ /**
519
+ * Lookup an entity by (adapter, originalId). Useful for dedup checks
520
+ * before normalizing — adapters do this in their sync loop to skip rows
521
+ * already in the vault.
522
+ */
523
+ findBySource(table, adapter, originalId) {
524
+ if (!["events", "persons", "places", "items", "topics"].includes(table)) {
525
+ throw new Error(`findBySource: unknown table "${table}"`);
526
+ }
527
+ if (typeof adapter !== "string" || typeof originalId !== "string") return null;
528
+ const row = this._requireOpen()
529
+ .prepare(`SELECT * FROM ${table} WHERE source_adapter = ? AND source_original_id = ?`)
530
+ .get(adapter, originalId);
531
+ if (!row) return null;
532
+ switch (table) {
533
+ case "events":
534
+ return this._rowToEvent(row);
535
+ case "persons":
536
+ return this._rowToPerson(row);
537
+ case "places":
538
+ return this._rowToPlace(row);
539
+ case "items":
540
+ return this._rowToItem(row);
541
+ case "topics":
542
+ return this._rowToTopic(row);
543
+ }
544
+ return null;
545
+ }
546
+
547
+ /**
548
+ * queryEvents — common filters with sensible defaults. Returns events
549
+ * ordered by occurred_at DESC (newest first) since that's the vast
550
+ * majority of analysis queries ("recent X by Y").
551
+ *
552
+ * @param {object} q
553
+ * @param {string} [q.subtype]
554
+ * @param {number} [q.since] occurred_at >= since
555
+ * @param {number} [q.until] occurred_at <= until
556
+ * @param {string} [q.actor]
557
+ * @param {string} [q.adapter]
558
+ * @param {number} [q.limit=100]
559
+ * @param {number} [q.offset=0]
560
+ */
561
+ queryEvents(q = {}) {
562
+ const where = [];
563
+ const params = {};
564
+ if (q.subtype) {
565
+ where.push("subtype = @subtype");
566
+ params.subtype = q.subtype;
567
+ }
568
+ if (Number.isFinite(q.since)) {
569
+ where.push("occurred_at >= @since");
570
+ params.since = q.since;
571
+ }
572
+ if (Number.isFinite(q.until)) {
573
+ where.push("occurred_at <= @until");
574
+ params.until = q.until;
575
+ }
576
+ if (q.actor) {
577
+ where.push("actor = @actor");
578
+ params.actor = q.actor;
579
+ }
580
+ if (q.adapter) {
581
+ where.push("source_adapter = @adapter");
582
+ params.adapter = q.adapter;
583
+ }
584
+
585
+ const limit = Number.isInteger(q.limit) && q.limit > 0 ? Math.min(q.limit, 10000) : 100;
586
+ const offset = Number.isInteger(q.offset) && q.offset >= 0 ? q.offset : 0;
587
+ params.limit = limit;
588
+ params.offset = offset;
589
+
590
+ const sql =
591
+ "SELECT * FROM events" +
592
+ (where.length ? " WHERE " + where.join(" AND ") : "") +
593
+ " ORDER BY occurred_at DESC LIMIT @limit OFFSET @offset";
594
+
595
+ return this._requireOpen()
596
+ .prepare(sql)
597
+ .all(params)
598
+ .map((row) => this._rowToEvent(row));
599
+ }
600
+
601
+ countEvents(q = {}) {
602
+ const where = [];
603
+ const params = {};
604
+ if (q.subtype) {
605
+ where.push("subtype = @subtype");
606
+ params.subtype = q.subtype;
607
+ }
608
+ if (Number.isFinite(q.since)) {
609
+ where.push("occurred_at >= @since");
610
+ params.since = q.since;
611
+ }
612
+ if (Number.isFinite(q.until)) {
613
+ where.push("occurred_at <= @until");
614
+ params.until = q.until;
615
+ }
616
+ if (q.actor) {
617
+ where.push("actor = @actor");
618
+ params.actor = q.actor;
619
+ }
620
+ if (q.adapter) {
621
+ where.push("source_adapter = @adapter");
622
+ params.adapter = q.adapter;
623
+ }
624
+ const sql =
625
+ "SELECT COUNT(*) as n FROM events" + (where.length ? " WHERE " + where.join(" AND ") : "");
626
+ return this._requireOpen().prepare(sql).get(params).n;
627
+ }
628
+
629
+ // ─── Sync watermarks ───────────────────────────────────────────────────
630
+
631
+ getWatermark(adapter, scope = "") {
632
+ if (typeof adapter !== "string" || adapter.length === 0) {
633
+ throw new Error("getWatermark: adapter required");
634
+ }
635
+ const row = this._requireOpen()
636
+ .prepare(
637
+ `SELECT adapter, scope, watermark, last_synced_at, last_status, last_error
638
+ FROM sync_watermarks WHERE adapter = ? AND scope = ?`
639
+ )
640
+ .get(adapter, scope);
641
+ return row || null;
642
+ }
643
+
644
+ setWatermark(adapter, scope, record) {
645
+ if (typeof adapter !== "string" || adapter.length === 0) {
646
+ throw new Error("setWatermark: adapter required");
647
+ }
648
+ if (!record || typeof record !== "object") {
649
+ throw new Error("setWatermark: record must be a plain object");
650
+ }
651
+ const scopeStr = typeof scope === "string" ? scope : "";
652
+ return this._requireOpen()
653
+ .prepare(
654
+ `INSERT INTO sync_watermarks (adapter, scope, watermark, last_synced_at, last_status, last_error)
655
+ VALUES (?, ?, ?, ?, ?, ?)
656
+ ON CONFLICT(adapter, scope) DO UPDATE SET
657
+ watermark = excluded.watermark,
658
+ last_synced_at = excluded.last_synced_at,
659
+ last_status = excluded.last_status,
660
+ last_error = excluded.last_error`
661
+ )
662
+ .run(
663
+ adapter,
664
+ scopeStr,
665
+ record.watermark != null ? String(record.watermark) : null,
666
+ Number.isInteger(record.lastSyncedAt) ? record.lastSyncedAt : null,
667
+ record.lastStatus != null ? String(record.lastStatus) : null,
668
+ record.lastError != null ? String(record.lastError) : null
669
+ );
670
+ }
671
+
672
+ // ─── Audit log ─────────────────────────────────────────────────────────
673
+
674
+ audit(action, target, details) {
675
+ return this._auditDirect(action, target, details);
676
+ }
677
+
678
+ _auditDirect(action, target, details) {
679
+ if (typeof action !== "string" || action.length === 0) {
680
+ throw new Error("audit: action required");
681
+ }
682
+ return this._requireOpen()
683
+ .prepare(
684
+ "INSERT INTO audit_log (at, action, target, details) VALUES (?, ?, ?, ?)"
685
+ )
686
+ .run(
687
+ Date.now(),
688
+ action,
689
+ target == null ? null : String(target),
690
+ details == null ? null : typeof details === "string" ? details : JSON.stringify(details)
691
+ );
692
+ }
693
+
694
+ queryAudit({ since, action, limit = 100 } = {}) {
695
+ const where = [];
696
+ const params = {};
697
+ if (Number.isFinite(since)) {
698
+ where.push("at >= @since");
699
+ params.since = since;
700
+ }
701
+ if (action) {
702
+ where.push("action = @action");
703
+ params.action = action;
704
+ }
705
+ params.limit = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 10000) : 100;
706
+
707
+ const sql =
708
+ "SELECT * FROM audit_log" +
709
+ (where.length ? " WHERE " + where.join(" AND ") : "") +
710
+ " ORDER BY at DESC LIMIT @limit";
711
+ return this._requireOpen().prepare(sql).all(params);
712
+ }
713
+
714
+ // ─── Stats ─────────────────────────────────────────────────────────────
715
+
716
+ stats() {
717
+ const db = this._requireOpen();
718
+ const count = (tbl) => db.prepare(`SELECT COUNT(*) as n FROM ${tbl}`).get().n;
719
+ return {
720
+ schemaVersion: getSchemaVersion(db),
721
+ events: count("events"),
722
+ persons: count("persons"),
723
+ places: count("places"),
724
+ items: count("items"),
725
+ topics: count("topics"),
726
+ rawEvents: count("raw_events"),
727
+ auditLog: count("audit_log"),
728
+ watermarks: count("sync_watermarks"),
729
+ };
730
+ }
731
+
732
+ // ─── Key rotation ──────────────────────────────────────────────────────
733
+
734
+ /**
735
+ * Rotate the master key. Uses SQLCipher's `PRAGMA rekey` which rewrites
736
+ * every page with the new key. Atomic from SQLite's POV; the caller is
737
+ * responsible for arranging that no other process has the vault open
738
+ * during this call.
739
+ *
740
+ * `PRAGMA rekey` is rejected by SQLCipher when journal_mode = WAL (since
741
+ * rekeying needs single-process exclusive page access). We temporarily
742
+ * switch the journal mode to DELETE, rekey, then restore WAL.
743
+ *
744
+ * Audits the rotation event AFTER successful rekey.
745
+ */
746
+ rotateKey(newKeyHex) {
747
+ if (!isValidKeyHex(newKeyHex)) {
748
+ throw new Error("rotateKey: newKeyHex must be 64 hex chars (32 bytes)");
749
+ }
750
+ if (newKeyHex === this._key) {
751
+ throw new Error("rotateKey: new key equals current key — refusing no-op rotation");
752
+ }
753
+ const db = this._requireOpen();
754
+
755
+ // Snapshot current journal mode so we can restore. better-sqlite3 returns
756
+ // an array for journal_mode queries (one row per attached DB).
757
+ const beforeRows = db.pragma("journal_mode");
758
+ const before =
759
+ Array.isArray(beforeRows) && beforeRows[0]
760
+ ? beforeRows[0].journal_mode || beforeRows[0]
761
+ : "delete";
762
+
763
+ if (String(before).toLowerCase() === "wal") {
764
+ db.pragma("journal_mode = DELETE");
765
+ }
766
+
767
+ try {
768
+ db.pragma(`rekey='${escSingleQuote(newKeyHex)}'`);
769
+ this._key = newKeyHex;
770
+ } finally {
771
+ if (String(before).toLowerCase() === "wal") {
772
+ db.pragma("journal_mode = WAL");
773
+ }
774
+ }
775
+
776
+ this._auditDirect("vault.key_rotated", this.path, { at: Date.now() });
777
+ }
778
+
779
+ // ─── Row → entity helpers ──────────────────────────────────────────────
780
+
781
+ _parseJson(s, fallback) {
782
+ if (s == null) return fallback;
783
+ try {
784
+ return JSON.parse(s);
785
+ } catch (_err) {
786
+ return fallback;
787
+ }
788
+ }
789
+
790
+ _rowToEvent(row) {
791
+ return {
792
+ id: row.id,
793
+ type: "event",
794
+ subtype: row.subtype,
795
+ occurredAt: row.occurred_at,
796
+ ...(row.duration_ms != null ? { durationMs: row.duration_ms } : {}),
797
+ ...(row.actor != null ? { actor: row.actor } : {}),
798
+ ...(row.participants ? { participants: this._parseJson(row.participants, []) } : {}),
799
+ ...(row.place != null ? { place: row.place } : {}),
800
+ ...(row.items ? { items: this._parseJson(row.items, []) } : {}),
801
+ ...(row.topics ? { topics: this._parseJson(row.topics, []) } : {}),
802
+ content: this._parseJson(row.content, {}),
803
+ source: this._parseJson(row.source, {}),
804
+ ...(row.extra ? { extra: this._parseJson(row.extra, {}) } : {}),
805
+ ingestedAt: row.ingested_at,
806
+ ...(row.confidence != null ? { confidence: row.confidence } : {}),
807
+ };
808
+ }
809
+
810
+ _rowToPerson(row) {
811
+ return {
812
+ id: row.id,
813
+ type: "person",
814
+ subtype: row.subtype,
815
+ names: this._parseJson(row.names, []),
816
+ ...(row.identifiers ? { identifiers: this._parseJson(row.identifiers, {}) } : {}),
817
+ ...(row.relation != null ? { relation: row.relation } : {}),
818
+ ...(row.notes != null ? { notes: row.notes } : {}),
819
+ source: this._parseJson(row.source, {}),
820
+ ...(row.extra ? { extra: this._parseJson(row.extra, {}) } : {}),
821
+ ingestedAt: row.ingested_at,
822
+ ...(row.confidence != null ? { confidence: row.confidence } : {}),
823
+ };
824
+ }
825
+
826
+ _rowToPlace(row) {
827
+ return {
828
+ id: row.id,
829
+ type: "place",
830
+ name: row.name,
831
+ ...(row.coordinates_lat != null && row.coordinates_lng != null
832
+ ? { coordinates: { lat: row.coordinates_lat, lng: row.coordinates_lng } }
833
+ : {}),
834
+ ...(row.address != null ? { address: row.address } : {}),
835
+ ...(row.category != null ? { category: row.category } : {}),
836
+ aliases: this._parseJson(row.aliases, []),
837
+ source: this._parseJson(row.source, {}),
838
+ ...(row.extra ? { extra: this._parseJson(row.extra, {}) } : {}),
839
+ ingestedAt: row.ingested_at,
840
+ ...(row.confidence != null ? { confidence: row.confidence } : {}),
841
+ };
842
+ }
843
+
844
+ _rowToItem(row) {
845
+ return {
846
+ id: row.id,
847
+ type: "item",
848
+ subtype: row.subtype,
849
+ name: row.name,
850
+ ...(row.category != null ? { category: row.category } : {}),
851
+ ...(row.price_value != null && row.price_currency != null
852
+ ? { price: { value: row.price_value, currency: row.price_currency } }
853
+ : {}),
854
+ ...(row.merchant != null ? { merchant: row.merchant } : {}),
855
+ ...(row.external_url != null ? { externalUrl: row.external_url } : {}),
856
+ ...(row.thumbnail_local_path != null
857
+ ? { thumbnailLocalPath: row.thumbnail_local_path }
858
+ : {}),
859
+ source: this._parseJson(row.source, {}),
860
+ ...(row.extra ? { extra: this._parseJson(row.extra, {}) } : {}),
861
+ ingestedAt: row.ingested_at,
862
+ ...(row.confidence != null ? { confidence: row.confidence } : {}),
863
+ };
864
+ }
865
+
866
+ _rowToTopic(row) {
867
+ return {
868
+ id: row.id,
869
+ type: "topic",
870
+ name: row.name,
871
+ ...(row.parent_topic != null ? { parentTopic: row.parent_topic } : {}),
872
+ ...(row.derived_from_events
873
+ ? { derivedFromEvents: this._parseJson(row.derived_from_events, []) }
874
+ : {}),
875
+ source: this._parseJson(row.source, {}),
876
+ ...(row.extra ? { extra: this._parseJson(row.extra, {}) } : {}),
877
+ ingestedAt: row.ingested_at,
878
+ ...(row.confidence != null ? { confidence: row.confidence } : {}),
879
+ };
880
+ }
881
+ }
882
+
883
+ module.exports = { LocalVault };