@velvetmonkey/vault-core 1.27.28 → 1.27.30

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 (42) hide show
  1. package/dist/CLAUDE.md +11 -0
  2. package/dist/entities.d.ts +0 -0
  3. package/dist/entities.d.ts.map +0 -0
  4. package/dist/entities.js +0 -0
  5. package/dist/entities.js.map +0 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/logging/index.d.ts +0 -0
  11. package/dist/logging/index.d.ts.map +0 -0
  12. package/dist/logging/index.js +0 -0
  13. package/dist/logging/index.js.map +0 -0
  14. package/dist/logging/operationLogger.d.ts +0 -0
  15. package/dist/logging/operationLogger.d.ts.map +0 -0
  16. package/dist/logging/operationLogger.js +0 -0
  17. package/dist/logging/operationLogger.js.map +0 -0
  18. package/dist/logging/sessionManager.d.ts +0 -0
  19. package/dist/logging/sessionManager.d.ts.map +0 -0
  20. package/dist/logging/sessionManager.js +0 -0
  21. package/dist/logging/sessionManager.js.map +0 -0
  22. package/dist/logging/types.d.ts +0 -0
  23. package/dist/logging/types.d.ts.map +0 -0
  24. package/dist/logging/types.js +0 -0
  25. package/dist/logging/types.js.map +0 -0
  26. package/dist/protectedZones.d.ts +0 -0
  27. package/dist/protectedZones.d.ts.map +0 -0
  28. package/dist/protectedZones.js +0 -0
  29. package/dist/protectedZones.js.map +0 -0
  30. package/dist/sqlite.d.ts +243 -0
  31. package/dist/sqlite.d.ts.map +1 -0
  32. package/dist/sqlite.js +751 -0
  33. package/dist/sqlite.js.map +1 -0
  34. package/dist/types.d.ts +0 -0
  35. package/dist/types.d.ts.map +0 -0
  36. package/dist/types.js +0 -0
  37. package/dist/types.js.map +0 -0
  38. package/dist/wikilinks.d.ts +0 -0
  39. package/dist/wikilinks.d.ts.map +1 -1
  40. package/dist/wikilinks.js +30 -3
  41. package/dist/wikilinks.js.map +1 -1
  42. package/package.json +5 -1
package/dist/sqlite.js ADDED
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Shared SQLite State Management
3
+ *
4
+ * Consolidates scattered JSON files and in-memory state into a single
5
+ * SQLite database with FTS5 for entity search.
6
+ *
7
+ * Target performance:
8
+ * - Startup <100ms for 10k note vault
9
+ * - Entity search <10ms
10
+ * - Single .flywheel/state.db for backup
11
+ */
12
+ import Database from 'better-sqlite3';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ // =============================================================================
16
+ // Constants
17
+ // =============================================================================
18
+ /** Current schema version - bump when schema changes */
19
+ export const SCHEMA_VERSION = 1;
20
+ /** State database filename */
21
+ export const STATE_DB_FILENAME = 'state.db';
22
+ /** Directory for flywheel state */
23
+ export const FLYWHEEL_DIR = '.flywheel';
24
+ // =============================================================================
25
+ // Schema
26
+ // =============================================================================
27
+ const SCHEMA_SQL = `
28
+ -- Schema version tracking
29
+ CREATE TABLE IF NOT EXISTS schema_version (
30
+ version INTEGER PRIMARY KEY,
31
+ applied_at TEXT DEFAULT (datetime('now'))
32
+ );
33
+
34
+ -- Metadata key-value store
35
+ CREATE TABLE IF NOT EXISTS metadata (
36
+ key TEXT PRIMARY KEY,
37
+ value TEXT NOT NULL,
38
+ updated_at TEXT DEFAULT (datetime('now'))
39
+ );
40
+
41
+ -- Notes metadata
42
+ CREATE TABLE IF NOT EXISTS notes (
43
+ id INTEGER PRIMARY KEY,
44
+ path TEXT UNIQUE NOT NULL,
45
+ title TEXT NOT NULL,
46
+ content_hash TEXT,
47
+ modified_at INTEGER NOT NULL,
48
+ aliases_json TEXT,
49
+ tags_json TEXT
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_notes_path ON notes(path);
52
+
53
+ -- Entity index (replaces wikilink-entities.json)
54
+ CREATE TABLE IF NOT EXISTS entities (
55
+ id INTEGER PRIMARY KEY,
56
+ name TEXT NOT NULL,
57
+ name_lower TEXT NOT NULL,
58
+ path TEXT NOT NULL,
59
+ category TEXT NOT NULL,
60
+ aliases_json TEXT,
61
+ hub_score INTEGER DEFAULT 0
62
+ );
63
+ CREATE INDEX IF NOT EXISTS idx_entities_name_lower ON entities(name_lower);
64
+ CREATE INDEX IF NOT EXISTS idx_entities_category ON entities(category);
65
+
66
+ -- FTS5 for entity search with porter stemmer
67
+ CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
68
+ name, aliases, category,
69
+ content='entities', content_rowid='id',
70
+ tokenize='porter unicode61'
71
+ );
72
+
73
+ -- Auto-sync triggers for entities_fts
74
+ CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
75
+ INSERT INTO entities_fts(rowid, name, aliases, category)
76
+ VALUES (
77
+ new.id,
78
+ new.name,
79
+ COALESCE((SELECT group_concat(value, ' ') FROM json_each(new.aliases_json)), ''),
80
+ new.category
81
+ );
82
+ END;
83
+
84
+ CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
85
+ INSERT INTO entities_fts(entities_fts, rowid, name, aliases, category)
86
+ VALUES (
87
+ 'delete',
88
+ old.id,
89
+ old.name,
90
+ COALESCE((SELECT group_concat(value, ' ') FROM json_each(old.aliases_json)), ''),
91
+ old.category
92
+ );
93
+ END;
94
+
95
+ CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
96
+ INSERT INTO entities_fts(entities_fts, rowid, name, aliases, category)
97
+ VALUES (
98
+ 'delete',
99
+ old.id,
100
+ old.name,
101
+ COALESCE((SELECT group_concat(value, ' ') FROM json_each(old.aliases_json)), ''),
102
+ old.category
103
+ );
104
+ INSERT INTO entities_fts(rowid, name, aliases, category)
105
+ VALUES (
106
+ new.id,
107
+ new.name,
108
+ COALESCE((SELECT group_concat(value, ' ') FROM json_each(new.aliases_json)), ''),
109
+ new.category
110
+ );
111
+ END;
112
+
113
+ -- Links table (replaces in-memory backlinks)
114
+ CREATE TABLE IF NOT EXISTS links (
115
+ id INTEGER PRIMARY KEY,
116
+ source_path TEXT NOT NULL,
117
+ target TEXT NOT NULL,
118
+ target_path TEXT,
119
+ line_number INTEGER
120
+ );
121
+ CREATE INDEX IF NOT EXISTS idx_links_source_path ON links(source_path);
122
+ CREATE INDEX IF NOT EXISTS idx_links_target_path ON links(target_path);
123
+ CREATE INDEX IF NOT EXISTS idx_links_target ON links(target);
124
+
125
+ -- Recency tracking (replaces entity-recency.json)
126
+ CREATE TABLE IF NOT EXISTS recency (
127
+ entity_name_lower TEXT PRIMARY KEY,
128
+ last_mentioned_at INTEGER NOT NULL,
129
+ mention_count INTEGER DEFAULT 1
130
+ );
131
+
132
+ -- Crank state (replaces last-crank-commit.json and other crank state)
133
+ CREATE TABLE IF NOT EXISTS crank_state (
134
+ key TEXT PRIMARY KEY,
135
+ value TEXT NOT NULL,
136
+ updated_at TEXT DEFAULT (datetime('now'))
137
+ );
138
+
139
+ -- Content search FTS5 (migrated from vault-search.db)
140
+ CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
141
+ path, title, content,
142
+ tokenize='porter'
143
+ );
144
+ `;
145
+ // =============================================================================
146
+ // Database Initialization
147
+ // =============================================================================
148
+ /**
149
+ * Get the database path for a vault
150
+ */
151
+ export function getStateDbPath(vaultPath) {
152
+ const flywheelDir = path.join(vaultPath, FLYWHEEL_DIR);
153
+ if (!fs.existsSync(flywheelDir)) {
154
+ fs.mkdirSync(flywheelDir, { recursive: true });
155
+ }
156
+ return path.join(flywheelDir, STATE_DB_FILENAME);
157
+ }
158
+ /**
159
+ * Initialize schema and run migrations
160
+ */
161
+ function initSchema(db) {
162
+ // Enable WAL mode for better concurrent read performance
163
+ db.pragma('journal_mode = WAL');
164
+ // Enable foreign keys
165
+ db.pragma('foreign_keys = ON');
166
+ // Run schema creation
167
+ db.exec(SCHEMA_SQL);
168
+ // Check and record schema version
169
+ const versionRow = db.prepare('SELECT MAX(version) as version FROM schema_version').get();
170
+ const currentVersion = versionRow?.version ?? 0;
171
+ if (currentVersion < SCHEMA_VERSION) {
172
+ // Run migrations here when we add new schema versions
173
+ // For now, just record the current version
174
+ db.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
175
+ }
176
+ }
177
+ /**
178
+ * Open or create the state database for a vault
179
+ *
180
+ * @param vaultPath - Absolute path to the vault root
181
+ * @returns StateDb instance with prepared statements
182
+ */
183
+ export function openStateDb(vaultPath) {
184
+ const dbPath = getStateDbPath(vaultPath);
185
+ const db = new Database(dbPath);
186
+ // Initialize schema
187
+ initSchema(db);
188
+ // Prepare all statements
189
+ const stateDb = {
190
+ db,
191
+ vaultPath,
192
+ dbPath,
193
+ // Entity operations
194
+ insertEntity: db.prepare(`
195
+ INSERT INTO entities (name, name_lower, path, category, aliases_json, hub_score)
196
+ VALUES (?, ?, ?, ?, ?, ?)
197
+ `),
198
+ updateEntity: db.prepare(`
199
+ UPDATE entities
200
+ SET name = ?, name_lower = ?, path = ?, category = ?, aliases_json = ?, hub_score = ?
201
+ WHERE id = ?
202
+ `),
203
+ deleteEntity: db.prepare('DELETE FROM entities WHERE id = ?'),
204
+ getEntityByName: db.prepare(`
205
+ SELECT id, name, name_lower, path, category, aliases_json, hub_score
206
+ FROM entities WHERE name_lower = ?
207
+ `),
208
+ getEntityById: db.prepare(`
209
+ SELECT id, name, name_lower, path, category, aliases_json, hub_score
210
+ FROM entities WHERE id = ?
211
+ `),
212
+ getAllEntities: db.prepare(`
213
+ SELECT id, name, name_lower, path, category, aliases_json, hub_score
214
+ FROM entities ORDER BY name
215
+ `),
216
+ getEntitiesByCategory: db.prepare(`
217
+ SELECT id, name, name_lower, path, category, aliases_json, hub_score
218
+ FROM entities WHERE category = ? ORDER BY name
219
+ `),
220
+ searchEntitiesFts: db.prepare(`
221
+ SELECT e.id, e.name, e.name_lower, e.path, e.category, e.aliases_json, e.hub_score,
222
+ bm25(entities_fts) as rank
223
+ FROM entities_fts
224
+ JOIN entities e ON e.id = entities_fts.rowid
225
+ WHERE entities_fts MATCH ?
226
+ ORDER BY rank
227
+ LIMIT ?
228
+ `),
229
+ clearEntities: db.prepare('DELETE FROM entities'),
230
+ // Link operations
231
+ insertLink: db.prepare(`
232
+ INSERT INTO links (source_path, target, target_path, line_number)
233
+ VALUES (?, ?, ?, ?)
234
+ `),
235
+ deleteLinksFromSource: db.prepare('DELETE FROM links WHERE source_path = ?'),
236
+ getBacklinks: db.prepare(`
237
+ SELECT id, source_path, target, target_path, line_number
238
+ FROM links WHERE target_path = ?
239
+ `),
240
+ getOutlinks: db.prepare(`
241
+ SELECT id, source_path, target, target_path, line_number
242
+ FROM links WHERE source_path = ?
243
+ `),
244
+ clearLinks: db.prepare('DELETE FROM links'),
245
+ // Recency operations
246
+ upsertRecency: db.prepare(`
247
+ INSERT INTO recency (entity_name_lower, last_mentioned_at, mention_count)
248
+ VALUES (?, ?, 1)
249
+ ON CONFLICT(entity_name_lower) DO UPDATE SET
250
+ last_mentioned_at = excluded.last_mentioned_at,
251
+ mention_count = mention_count + 1
252
+ `),
253
+ getRecency: db.prepare(`
254
+ SELECT entity_name_lower, last_mentioned_at, mention_count
255
+ FROM recency WHERE entity_name_lower = ?
256
+ `),
257
+ getAllRecency: db.prepare(`
258
+ SELECT entity_name_lower, last_mentioned_at, mention_count
259
+ FROM recency ORDER BY last_mentioned_at DESC
260
+ `),
261
+ clearRecency: db.prepare('DELETE FROM recency'),
262
+ // Crank state operations
263
+ setCrankState: db.prepare(`
264
+ INSERT INTO crank_state (key, value, updated_at)
265
+ VALUES (?, ?, datetime('now'))
266
+ ON CONFLICT(key) DO UPDATE SET
267
+ value = excluded.value,
268
+ updated_at = datetime('now')
269
+ `),
270
+ getCrankState: db.prepare('SELECT value FROM crank_state WHERE key = ?'),
271
+ deleteCrankState: db.prepare('DELETE FROM crank_state WHERE key = ?'),
272
+ // Notes operations
273
+ insertNote: db.prepare(`
274
+ INSERT INTO notes (path, title, content_hash, modified_at, aliases_json, tags_json)
275
+ VALUES (?, ?, ?, ?, ?, ?)
276
+ `),
277
+ updateNote: db.prepare(`
278
+ UPDATE notes
279
+ SET title = ?, content_hash = ?, modified_at = ?, aliases_json = ?, tags_json = ?
280
+ WHERE path = ?
281
+ `),
282
+ deleteNote: db.prepare('DELETE FROM notes WHERE path = ?'),
283
+ getNoteByPath: db.prepare(`
284
+ SELECT id, path, title, content_hash, modified_at, aliases_json, tags_json
285
+ FROM notes WHERE path = ?
286
+ `),
287
+ getAllNotes: db.prepare(`
288
+ SELECT id, path, title, content_hash, modified_at, aliases_json, tags_json
289
+ FROM notes ORDER BY path
290
+ `),
291
+ clearNotes: db.prepare('DELETE FROM notes'),
292
+ // Metadata operations
293
+ getMetadataValue: db.prepare('SELECT value FROM metadata WHERE key = ?'),
294
+ setMetadataValue: db.prepare(`
295
+ INSERT INTO metadata (key, value, updated_at)
296
+ VALUES (?, ?, datetime('now'))
297
+ ON CONFLICT(key) DO UPDATE SET
298
+ value = excluded.value,
299
+ updated_at = datetime('now')
300
+ `),
301
+ // Transactions
302
+ bulkInsertEntities: db.transaction((entities, category) => {
303
+ let count = 0;
304
+ for (const entity of entities) {
305
+ stateDb.insertEntity.run(entity.name, entity.name.toLowerCase(), entity.path, category, JSON.stringify(entity.aliases), entity.hubScore ?? 0);
306
+ count++;
307
+ }
308
+ return count;
309
+ }),
310
+ bulkInsertLinks: db.transaction((links) => {
311
+ let count = 0;
312
+ for (const link of links) {
313
+ stateDb.insertLink.run(link.sourcePath, link.target, link.targetPath, link.lineNumber);
314
+ count++;
315
+ }
316
+ return count;
317
+ }),
318
+ replaceAllEntities: db.transaction((index) => {
319
+ // Clear existing entities
320
+ stateDb.clearEntities.run();
321
+ // Insert all entities by category
322
+ const categories = [
323
+ 'technologies', 'acronyms', 'people', 'projects',
324
+ 'organizations', 'locations', 'concepts', 'other'
325
+ ];
326
+ let total = 0;
327
+ for (const category of categories) {
328
+ const entities = index[category];
329
+ if (!entities?.length)
330
+ continue;
331
+ for (const entity of entities) {
332
+ // Handle both string and EntityWithAliases formats
333
+ const entityObj = typeof entity === 'string'
334
+ ? { name: entity, path: '', aliases: [], hubScore: 0 }
335
+ : entity;
336
+ stateDb.insertEntity.run(entityObj.name, entityObj.name.toLowerCase(), entityObj.path, category, JSON.stringify(entityObj.aliases), entityObj.hubScore ?? 0);
337
+ total++;
338
+ }
339
+ }
340
+ // Update metadata
341
+ stateDb.setMetadataValue.run('entities_built_at', new Date().toISOString());
342
+ stateDb.setMetadataValue.run('entity_count', String(total));
343
+ return total;
344
+ }),
345
+ close: () => {
346
+ db.close();
347
+ },
348
+ };
349
+ return stateDb;
350
+ }
351
+ // =============================================================================
352
+ // Entity Operations
353
+ // =============================================================================
354
+ /**
355
+ * Search entities using FTS5 with porter stemming
356
+ *
357
+ * @param stateDb - State database instance
358
+ * @param query - Search query (supports FTS5 syntax)
359
+ * @param limit - Maximum results to return
360
+ * @returns Array of matching entities with relevance scores
361
+ */
362
+ export function searchEntities(stateDb, query, limit = 20) {
363
+ const escapedQuery = escapeFts5Query(query);
364
+ // Handle empty query - return empty results
365
+ if (!escapedQuery) {
366
+ return [];
367
+ }
368
+ const rows = stateDb.searchEntitiesFts.all(escapedQuery, limit);
369
+ return rows.map(row => ({
370
+ id: row.id,
371
+ name: row.name,
372
+ nameLower: row.name_lower,
373
+ path: row.path,
374
+ category: row.category,
375
+ aliases: row.aliases_json ? JSON.parse(row.aliases_json) : [],
376
+ hubScore: row.hub_score,
377
+ rank: row.rank,
378
+ }));
379
+ }
380
+ /**
381
+ * Search entities by prefix for autocomplete
382
+ *
383
+ * @param stateDb - State database instance
384
+ * @param prefix - Prefix to search for
385
+ * @param limit - Maximum results to return
386
+ */
387
+ export function searchEntitiesPrefix(stateDb, prefix, limit = 20) {
388
+ return searchEntities(stateDb, `${escapeFts5Query(prefix)}*`, limit);
389
+ }
390
+ /**
391
+ * Get entity by exact name (case-insensitive)
392
+ */
393
+ export function getEntityByName(stateDb, name) {
394
+ const row = stateDb.getEntityByName.get(name.toLowerCase());
395
+ if (!row)
396
+ return null;
397
+ return {
398
+ id: row.id,
399
+ name: row.name,
400
+ nameLower: row.name_lower,
401
+ path: row.path,
402
+ category: row.category,
403
+ aliases: row.aliases_json ? JSON.parse(row.aliases_json) : [],
404
+ hubScore: row.hub_score,
405
+ rank: 0,
406
+ };
407
+ }
408
+ /**
409
+ * Get all entities from the database
410
+ */
411
+ export function getAllEntitiesFromDb(stateDb) {
412
+ const rows = stateDb.getAllEntities.all();
413
+ return rows.map(row => ({
414
+ id: row.id,
415
+ name: row.name,
416
+ nameLower: row.name_lower,
417
+ path: row.path,
418
+ category: row.category,
419
+ aliases: row.aliases_json ? JSON.parse(row.aliases_json) : [],
420
+ hubScore: row.hub_score,
421
+ rank: 0,
422
+ }));
423
+ }
424
+ /**
425
+ * Convert database entities back to EntityIndex format
426
+ */
427
+ export function getEntityIndexFromDb(stateDb) {
428
+ const entities = getAllEntitiesFromDb(stateDb);
429
+ const index = {
430
+ technologies: [],
431
+ acronyms: [],
432
+ people: [],
433
+ projects: [],
434
+ organizations: [],
435
+ locations: [],
436
+ concepts: [],
437
+ other: [],
438
+ _metadata: {
439
+ total_entities: entities.length,
440
+ generated_at: new Date().toISOString(),
441
+ vault_path: stateDb.vaultPath,
442
+ source: 'vault-core sqlite',
443
+ },
444
+ };
445
+ for (const entity of entities) {
446
+ const entityObj = {
447
+ name: entity.name,
448
+ path: entity.path,
449
+ aliases: entity.aliases,
450
+ hubScore: entity.hubScore,
451
+ };
452
+ index[entity.category].push(entityObj);
453
+ }
454
+ return index;
455
+ }
456
+ // =============================================================================
457
+ // Link Operations
458
+ // =============================================================================
459
+ /**
460
+ * Get all notes that link to a given path (backlinks)
461
+ */
462
+ export function getBacklinks(stateDb, targetPath) {
463
+ const rows = stateDb.getBacklinks.all(targetPath);
464
+ return rows.map(row => ({
465
+ id: row.id,
466
+ sourcePath: row.source_path,
467
+ target: row.target,
468
+ targetPath: row.target_path,
469
+ lineNumber: row.line_number,
470
+ }));
471
+ }
472
+ /**
473
+ * Get all links from a given note (outlinks)
474
+ */
475
+ export function getOutlinks(stateDb, sourcePath) {
476
+ const rows = stateDb.getOutlinks.all(sourcePath);
477
+ return rows.map(row => ({
478
+ id: row.id,
479
+ sourcePath: row.source_path,
480
+ target: row.target,
481
+ targetPath: row.target_path,
482
+ lineNumber: row.line_number,
483
+ }));
484
+ }
485
+ /**
486
+ * Replace all links from a source note
487
+ */
488
+ export function replaceLinksFromSource(stateDb, sourcePath, links) {
489
+ const transaction = stateDb.db.transaction(() => {
490
+ stateDb.deleteLinksFromSource.run(sourcePath);
491
+ for (const link of links) {
492
+ stateDb.insertLink.run(sourcePath, link.target, link.targetPath, link.lineNumber);
493
+ }
494
+ });
495
+ transaction();
496
+ }
497
+ // =============================================================================
498
+ // Recency Operations
499
+ // =============================================================================
500
+ /**
501
+ * Record a mention of an entity
502
+ */
503
+ export function recordEntityMention(stateDb, entityName, mentionedAt = new Date()) {
504
+ stateDb.upsertRecency.run(entityName.toLowerCase(), mentionedAt.getTime());
505
+ }
506
+ /**
507
+ * Get recency info for an entity
508
+ */
509
+ export function getEntityRecency(stateDb, entityName) {
510
+ const row = stateDb.getRecency.get(entityName.toLowerCase());
511
+ if (!row)
512
+ return null;
513
+ return {
514
+ entityNameLower: row.entity_name_lower,
515
+ lastMentionedAt: row.last_mentioned_at,
516
+ mentionCount: row.mention_count,
517
+ };
518
+ }
519
+ /**
520
+ * Get all recency data ordered by most recent
521
+ */
522
+ export function getAllRecency(stateDb) {
523
+ const rows = stateDb.getAllRecency.all();
524
+ return rows.map(row => ({
525
+ entityNameLower: row.entity_name_lower,
526
+ lastMentionedAt: row.last_mentioned_at,
527
+ mentionCount: row.mention_count,
528
+ }));
529
+ }
530
+ // =============================================================================
531
+ // Crank State Operations
532
+ // =============================================================================
533
+ /**
534
+ * Set a crank state value
535
+ */
536
+ export function setCrankState(stateDb, key, value) {
537
+ stateDb.setCrankState.run(key, JSON.stringify(value));
538
+ }
539
+ /**
540
+ * Get a crank state value
541
+ */
542
+ export function getCrankState(stateDb, key) {
543
+ const row = stateDb.getCrankState.get(key);
544
+ if (!row)
545
+ return null;
546
+ return JSON.parse(row.value);
547
+ }
548
+ /**
549
+ * Delete a crank state key
550
+ */
551
+ export function deleteCrankState(stateDb, key) {
552
+ stateDb.deleteCrankState.run(key);
553
+ }
554
+ // =============================================================================
555
+ // Metadata Operations
556
+ // =============================================================================
557
+ /**
558
+ * Get database metadata
559
+ */
560
+ export function getStateDbMetadata(stateDb) {
561
+ const schemaRow = stateDb.db.prepare('SELECT MAX(version) as version FROM schema_version').get();
562
+ const entitiesBuiltRow = stateDb.getMetadataValue.get('entities_built_at');
563
+ const entityCountRow = stateDb.getMetadataValue.get('entity_count');
564
+ const notesBuiltRow = stateDb.getMetadataValue.get('notes_built_at');
565
+ const noteCountRow = stateDb.getMetadataValue.get('note_count');
566
+ return {
567
+ schemaVersion: schemaRow?.version ?? 0,
568
+ entitiesBuiltAt: entitiesBuiltRow?.value ?? null,
569
+ entityCount: entityCountRow ? parseInt(entityCountRow.value, 10) : 0,
570
+ notesBuiltAt: notesBuiltRow?.value ?? null,
571
+ noteCount: noteCountRow ? parseInt(noteCountRow.value, 10) : 0,
572
+ };
573
+ }
574
+ /**
575
+ * Check if entity data is stale (older than threshold)
576
+ */
577
+ export function isEntityDataStale(stateDb, thresholdMs = 60 * 60 * 1000 // 1 hour default
578
+ ) {
579
+ const metadata = getStateDbMetadata(stateDb);
580
+ if (!metadata.entitiesBuiltAt) {
581
+ return true;
582
+ }
583
+ const builtAt = new Date(metadata.entitiesBuiltAt).getTime();
584
+ const age = Date.now() - builtAt;
585
+ return age > thresholdMs;
586
+ }
587
+ // =============================================================================
588
+ // Utility Functions
589
+ // =============================================================================
590
+ /**
591
+ * Escape special FTS5 characters in a query
592
+ */
593
+ export function escapeFts5Query(query) {
594
+ // Handle empty query
595
+ if (!query || !query.trim()) {
596
+ return '';
597
+ }
598
+ // Remove or escape FTS5 special characters
599
+ // Keep * for prefix matching, escape others
600
+ return query
601
+ .replace(/"/g, '""') // Escape quotes
602
+ .replace(/[(){}[\]^~:-]/g, ' ') // Remove special operators including hyphen
603
+ .replace(/\s+/g, ' ') // Normalize whitespace
604
+ .trim();
605
+ }
606
+ /**
607
+ * Check if the state database exists for a vault
608
+ */
609
+ export function stateDbExists(vaultPath) {
610
+ const dbPath = getStateDbPath(vaultPath);
611
+ return fs.existsSync(dbPath);
612
+ }
613
+ /**
614
+ * Delete the state database (for testing or reset)
615
+ */
616
+ export function deleteStateDb(vaultPath) {
617
+ const dbPath = getStateDbPath(vaultPath);
618
+ if (fs.existsSync(dbPath)) {
619
+ fs.unlinkSync(dbPath);
620
+ }
621
+ // Also remove WAL and SHM files if they exist
622
+ const walPath = dbPath + '-wal';
623
+ const shmPath = dbPath + '-shm';
624
+ if (fs.existsSync(walPath))
625
+ fs.unlinkSync(walPath);
626
+ if (fs.existsSync(shmPath))
627
+ fs.unlinkSync(shmPath);
628
+ }
629
+ /**
630
+ * Get default legacy file paths for a vault
631
+ */
632
+ export function getLegacyPaths(vaultPath) {
633
+ const claudeDir = path.join(vaultPath, '.claude');
634
+ return {
635
+ entities: path.join(claudeDir, 'wikilink-entities.json'),
636
+ recency: path.join(claudeDir, 'entity-recency.json'),
637
+ lastCommit: path.join(claudeDir, 'last-crank-commit.json'),
638
+ hints: path.join(claudeDir, 'crank-mutation-hints.json'),
639
+ };
640
+ }
641
+ /**
642
+ * Migrate legacy JSON files to SQLite state database
643
+ *
644
+ * This function reads existing JSON state files and imports them
645
+ * into the consolidated SQLite database. It does NOT delete the
646
+ * original JSON files - that should be done manually after verifying
647
+ * the migration was successful.
648
+ *
649
+ * @param stateDb - Open state database
650
+ * @param legacyPaths - Paths to legacy JSON files
651
+ * @returns Migration result with counts and any errors
652
+ */
653
+ export async function migrateFromJsonToSqlite(stateDb, legacyPaths) {
654
+ const result = {
655
+ success: true,
656
+ entitiesMigrated: 0,
657
+ recencyMigrated: 0,
658
+ crankStateMigrated: 0,
659
+ errors: [],
660
+ };
661
+ // Migrate entities
662
+ if (legacyPaths.entities && fs.existsSync(legacyPaths.entities)) {
663
+ try {
664
+ const content = fs.readFileSync(legacyPaths.entities, 'utf-8');
665
+ const index = JSON.parse(content);
666
+ result.entitiesMigrated = stateDb.replaceAllEntities(index);
667
+ }
668
+ catch (error) {
669
+ result.errors.push(`Failed to migrate entities: ${error}`);
670
+ result.success = false;
671
+ }
672
+ }
673
+ // Migrate recency data
674
+ if (legacyPaths.recency && fs.existsSync(legacyPaths.recency)) {
675
+ try {
676
+ const content = fs.readFileSync(legacyPaths.recency, 'utf-8');
677
+ const data = JSON.parse(content);
678
+ for (const [entityName, timestamp] of Object.entries(data.lastMentioned)) {
679
+ recordEntityMention(stateDb, entityName, new Date(timestamp));
680
+ result.recencyMigrated++;
681
+ }
682
+ }
683
+ catch (error) {
684
+ result.errors.push(`Failed to migrate recency: ${error}`);
685
+ result.success = false;
686
+ }
687
+ }
688
+ // Migrate last commit tracking
689
+ if (legacyPaths.lastCommit && fs.existsSync(legacyPaths.lastCommit)) {
690
+ try {
691
+ const content = fs.readFileSync(legacyPaths.lastCommit, 'utf-8');
692
+ const data = JSON.parse(content);
693
+ setCrankState(stateDb, 'last_commit', data);
694
+ result.crankStateMigrated++;
695
+ }
696
+ catch (error) {
697
+ result.errors.push(`Failed to migrate last commit: ${error}`);
698
+ result.success = false;
699
+ }
700
+ }
701
+ // Migrate mutation hints
702
+ if (legacyPaths.hints && fs.existsSync(legacyPaths.hints)) {
703
+ try {
704
+ const content = fs.readFileSync(legacyPaths.hints, 'utf-8');
705
+ const data = JSON.parse(content);
706
+ setCrankState(stateDb, 'mutation_hints', data);
707
+ result.crankStateMigrated++;
708
+ }
709
+ catch (error) {
710
+ result.errors.push(`Failed to migrate hints: ${error}`);
711
+ result.success = false;
712
+ }
713
+ }
714
+ return result;
715
+ }
716
+ /**
717
+ * Backup legacy JSON files before migration
718
+ *
719
+ * Creates .bak files alongside the originals
720
+ */
721
+ export function backupLegacyFiles(legacyPaths) {
722
+ const backedUp = [];
723
+ for (const [, filePath] of Object.entries(legacyPaths)) {
724
+ if (filePath && fs.existsSync(filePath)) {
725
+ const backupPath = filePath + '.bak';
726
+ fs.copyFileSync(filePath, backupPath);
727
+ backedUp.push(filePath);
728
+ }
729
+ }
730
+ return backedUp;
731
+ }
732
+ /**
733
+ * Delete legacy JSON files after successful migration
734
+ *
735
+ * Only deletes files that have corresponding .bak backups
736
+ */
737
+ export function deleteLegacyFiles(legacyPaths) {
738
+ const deleted = [];
739
+ for (const [, filePath] of Object.entries(legacyPaths)) {
740
+ if (filePath && fs.existsSync(filePath)) {
741
+ const backupPath = filePath + '.bak';
742
+ // Only delete if backup exists (safety check)
743
+ if (fs.existsSync(backupPath)) {
744
+ fs.unlinkSync(filePath);
745
+ deleted.push(filePath);
746
+ }
747
+ }
748
+ }
749
+ return deleted;
750
+ }
751
+ //# sourceMappingURL=sqlite.js.map