@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.
- package/README.md +241 -0
- package/__tests__/adapter-spec.test.js +78 -0
- package/__tests__/adapters/email-adapter.test.js +605 -0
- package/__tests__/adapters/email-imap-session.test.js +334 -0
- package/__tests__/adapters/email-parser.test.js +244 -0
- package/__tests__/adapters/email-providers.test.js +84 -0
- package/__tests__/analysis.test.js +302 -0
- package/__tests__/batch.test.js +133 -0
- package/__tests__/bridges-cc-kg.test.js +231 -0
- package/__tests__/bridges-cc-llm.test.js +191 -0
- package/__tests__/bridges-cc-rag.test.js +162 -0
- package/__tests__/ids.test.js +45 -0
- package/__tests__/key-providers.test.js +126 -0
- package/__tests__/kg-derive.test.js +219 -0
- package/__tests__/llm-client.test.js +122 -0
- package/__tests__/mock-adapter.test.js +93 -0
- package/__tests__/prompt-builder.test.js +204 -0
- package/__tests__/query-parser.test.js +150 -0
- package/__tests__/rag-derive.test.js +169 -0
- package/__tests__/registry.test.js +304 -0
- package/__tests__/schemas.test.js +331 -0
- package/__tests__/vault.test.js +506 -0
- package/lib/adapter-spec.js +155 -0
- package/lib/adapters/email-imap/email-adapter.js +398 -0
- package/lib/adapters/email-imap/email-parser.js +177 -0
- package/lib/adapters/email-imap/imap-session.js +294 -0
- package/lib/adapters/email-imap/index.js +26 -0
- package/lib/adapters/email-imap/providers.js +111 -0
- package/lib/analysis.js +226 -0
- package/lib/batch.js +123 -0
- package/lib/bridges/cc-kg-sink.js +264 -0
- package/lib/bridges/cc-llm-adapter.js +169 -0
- package/lib/bridges/cc-rag-sink.js +118 -0
- package/lib/bridges/index.js +44 -0
- package/lib/constants.js +92 -0
- package/lib/ids.js +103 -0
- package/lib/index.js +141 -0
- package/lib/key-providers.js +146 -0
- package/lib/kg-derive.js +214 -0
- package/lib/llm-client.js +171 -0
- package/lib/migrations.js +246 -0
- package/lib/mock-adapter.js +199 -0
- package/lib/prompt-builder.js +205 -0
- package/lib/query-parser.js +250 -0
- package/lib/rag-derive.js +186 -0
- package/lib/registry.js +398 -0
- package/lib/schemas.js +379 -0
- package/lib/vault.js +883 -0
- package/package.json +63 -0
- 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 };
|