@aeriondyseti/vector-memory-mcp 2.4.4 → 2.5.0-dev.1

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.
@@ -0,0 +1,652 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash, randomUUID } from "crypto";
3
+ import { copyFileSync, existsSync, readdirSync, readFileSync, renameSync } from "fs";
4
+ import { readFile } from "fs/promises";
5
+ import { homedir } from "os";
6
+ import { dirname, join } from "path";
7
+ import type { EmbeddingsService } from "./embeddings.service";
8
+ import { normalizeProject } from "./project";
9
+ import { safeParseJsonObject, serializeVector } from "./sqlite-utils";
10
+
11
+ const UUID_ZERO = "00000000-0000-0000-0000-000000000000";
12
+
13
+ export interface ConsolidationOptions {
14
+ /** Directory to scan for a .vector-memory/memories.db (default: cwd). */
15
+ root: string;
16
+ /** Walk the tree under root and consolidate every repo-local db found. */
17
+ recursive: boolean;
18
+ /** Plan everything (including re-keys) but write nothing. */
19
+ dryRun: boolean;
20
+ /** Rename .vector-memory/ to .vector-memory.migrated/ after success. */
21
+ archive: boolean;
22
+ /** Proceed even if live servers appear to be using the databases. */
23
+ force: boolean;
24
+ }
25
+
26
+ export interface SourceReport {
27
+ sourceDb: string;
28
+ project: string;
29
+ memoriesImported: number;
30
+ memoriesSkipped: number;
31
+ memoriesRekeyed: number;
32
+ conversationsImported: number;
33
+ conversationsSkipped: number;
34
+ indexStateImported: number;
35
+ rekeyMap: Record<string, string>;
36
+ unresolvedReferences: string[];
37
+ errors: string[];
38
+ }
39
+
40
+ export interface ConsolidationSummary {
41
+ targetDb: string;
42
+ backupPath: string | null;
43
+ importBatch: string;
44
+ dryRun: boolean;
45
+ sources: SourceReport[];
46
+ }
47
+
48
+ interface SourceMemoryRow {
49
+ id: string;
50
+ content: string;
51
+ metadata: string;
52
+ created_at: number;
53
+ updated_at: number;
54
+ superseded_by: string | null;
55
+ usefulness: number;
56
+ access_count: number;
57
+ last_accessed: number | null;
58
+ vector: Buffer | null;
59
+ }
60
+
61
+ /** Mirrors MemoryService.waypointId — must stay byte-identical. */
62
+ function waypointIdFor(project: string): string {
63
+ const normalized = project.trim().toLowerCase();
64
+ const hex = createHash("sha256")
65
+ .update(`waypoint:${normalized}`)
66
+ .digest("hex");
67
+ return `wp:${hex.slice(0, 32)}`;
68
+ }
69
+
70
+ function isWaypointRow(row: SourceMemoryRow): boolean {
71
+ if (row.id === UUID_ZERO || row.id.startsWith("wp:")) return true;
72
+ const metadata = safeParseJsonObject(row.metadata);
73
+ return metadata.type === "waypoint";
74
+ }
75
+
76
+ /** Find repo-local databases: <repo>/.vector-memory/memories.db */
77
+ export function discoverSourceDbs(root: string, recursive: boolean): string[] {
78
+ const found: string[] = [];
79
+ const direct = join(root, ".vector-memory", "memories.db");
80
+ if (existsSync(direct)) found.push(direct);
81
+ if (!recursive) return found;
82
+
83
+ const SKIP_DIRS = new Set([
84
+ "node_modules",
85
+ ".git",
86
+ ".vector-memory",
87
+ ".vector-memory.migrated",
88
+ ".cache",
89
+ ]);
90
+
91
+ const walk = (dir: string, depth: number): void => {
92
+ if (depth > 8) return;
93
+ let entries;
94
+ try {
95
+ entries = readdirSync(dir, { withFileTypes: true });
96
+ } catch {
97
+ return;
98
+ }
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
101
+ if (entry.name.startsWith(".") && entry.name !== ".vector-memory") continue;
102
+ const sub = join(dir, entry.name);
103
+ const candidate = join(sub, ".vector-memory", "memories.db");
104
+ if (existsSync(candidate)) found.push(candidate);
105
+ walk(sub, depth + 1);
106
+ }
107
+ };
108
+ walk(root, 0);
109
+ return found;
110
+ }
111
+
112
+ /** Lock files (global + per-repo) whose recorded pid is still alive. */
113
+ export function findLiveServerLocks(sourceDirs: string[]): string[] {
114
+ const lockPaths: string[] = [];
115
+ const globalLocksDir = join(homedir(), ".vector-memory", "locks");
116
+ try {
117
+ for (const name of readdirSync(globalLocksDir)) {
118
+ if (name.endsWith(".lock")) lockPaths.push(join(globalLocksDir, name));
119
+ }
120
+ } catch {
121
+ // no locks dir — fine
122
+ }
123
+ for (const dir of sourceDirs) {
124
+ lockPaths.push(join(dir, "server.lock"));
125
+ }
126
+
127
+ const live: string[] = [];
128
+ for (const path of lockPaths) {
129
+ try {
130
+ const raw = JSON.parse(readFileSync(path, "utf8")) as { pid?: number };
131
+ if (typeof raw.pid !== "number") continue;
132
+ process.kill(raw.pid, 0);
133
+ live.push(path);
134
+ } catch {
135
+ // missing, unreadable, or dead pid — not live
136
+ }
137
+ }
138
+ return live;
139
+ }
140
+
141
+ export class ConsolidationService {
142
+ constructor(
143
+ private target: Database,
144
+ private targetDbPath: string,
145
+ private embeddings: EmbeddingsService,
146
+ ) {}
147
+
148
+ async consolidate(options: ConsolidationOptions): Promise<ConsolidationSummary> {
149
+ const sources = discoverSourceDbs(options.root, options.recursive);
150
+ const importBatch = randomUUID();
151
+
152
+ const summary: ConsolidationSummary = {
153
+ targetDb: this.targetDbPath,
154
+ backupPath: null,
155
+ importBatch,
156
+ dryRun: options.dryRun,
157
+ sources: [],
158
+ };
159
+
160
+ if (sources.length === 0) return summary;
161
+
162
+ // Refuse to run against live servers unless forced — a live server's
163
+ // waypoint write racing the import could be clobbered.
164
+ if (!options.force) {
165
+ const live = findLiveServerLocks(sources.map((s) => dirname(s)));
166
+ if (live.length > 0) {
167
+ throw new Error(
168
+ `Live vector-memory servers detected (${live.join(", ")}). ` +
169
+ `Close those sessions first, or re-run with --force.`,
170
+ );
171
+ }
172
+ }
173
+
174
+ // Backup the global db before the first write
175
+ if (!options.dryRun && existsSync(this.targetDbPath)) {
176
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
177
+ summary.backupPath = `${this.targetDbPath}.pre-consolidate-${stamp}`;
178
+ copyFileSync(this.targetDbPath, summary.backupPath);
179
+ }
180
+
181
+ for (const sourceDb of sources) {
182
+ const report = await this.consolidateOne(sourceDb, importBatch, options);
183
+ summary.sources.push(report);
184
+
185
+ if (!options.dryRun && options.archive && report.errors.length === 0) {
186
+ const dir = dirname(sourceDb);
187
+ try {
188
+ renameSync(dir, `${dir}.migrated`);
189
+ } catch (e) {
190
+ report.errors.push(
191
+ `archive failed: ${e instanceof Error ? e.message : String(e)}`,
192
+ );
193
+ }
194
+ }
195
+ }
196
+
197
+ return summary;
198
+ }
199
+
200
+ private async consolidateOne(
201
+ sourceDbPath: string,
202
+ importBatch: string,
203
+ options: ConsolidationOptions,
204
+ ): Promise<SourceReport> {
205
+ // <repo>/.vector-memory/memories.db → project = <repo>
206
+ const project = normalizeProject(dirname(dirname(sourceDbPath)));
207
+
208
+ const report: SourceReport = {
209
+ sourceDb: sourceDbPath,
210
+ project,
211
+ memoriesImported: 0,
212
+ memoriesSkipped: 0,
213
+ memoriesRekeyed: 0,
214
+ conversationsImported: 0,
215
+ conversationsSkipped: 0,
216
+ indexStateImported: 0,
217
+ rekeyMap: {},
218
+ unresolvedReferences: [],
219
+ errors: [],
220
+ };
221
+
222
+ let source: Database;
223
+ try {
224
+ source = new Database(sourceDbPath, { readonly: true });
225
+ } catch (e) {
226
+ report.errors.push(
227
+ `cannot open source: ${e instanceof Error ? e.message : String(e)}`,
228
+ );
229
+ return report;
230
+ }
231
+
232
+ try {
233
+ await this.importMemories(source, project, importBatch, options, report);
234
+ this.importConversations(source, project, importBatch, options, report);
235
+ await this.importIndexState(source, sourceDbPath, project, options, report);
236
+ } catch (e) {
237
+ report.errors.push(e instanceof Error ? e.message : String(e));
238
+ } finally {
239
+ source.close();
240
+ }
241
+
242
+ return report;
243
+ }
244
+
245
+ // ── Memories ────────────────────────────────────────────────────────
246
+
247
+ private async importMemories(
248
+ source: Database,
249
+ project: string,
250
+ importBatch: string,
251
+ options: ConsolidationOptions,
252
+ report: SourceReport,
253
+ ): Promise<void> {
254
+ if (!tableExists(source, "memories")) return;
255
+
256
+ const rows = source
257
+ .prepare(
258
+ `SELECT m.*, v.vector FROM memories m
259
+ LEFT JOIN memories_vec v ON m.id = v.id`,
260
+ )
261
+ .all() as SourceMemoryRow[];
262
+ if (rows.length === 0) return;
263
+
264
+ const targetGet = this.target.prepare(
265
+ "SELECT content FROM memories WHERE id = ?",
266
+ );
267
+
268
+ // Plan re-keys first so references can be remapped before any insert.
269
+ // Waypoints collapse to the canonical per-project ID (newest wins);
270
+ // other ID collisions with different content get fresh UUIDs.
271
+ const canonicalWaypointId = waypointIdFor(project);
272
+ const waypoints = rows
273
+ .filter(isWaypointRow)
274
+ .sort((a, b) => b.updated_at - a.updated_at);
275
+ const sourceIds = new Set(rows.map((r) => r.id));
276
+ const toImport: SourceMemoryRow[] = [];
277
+
278
+ for (const row of rows) {
279
+ if (isWaypointRow(row)) {
280
+ if (row !== waypoints[0]) {
281
+ report.memoriesSkipped++; // older duplicate waypoint copies
282
+ continue;
283
+ }
284
+ if (row.id !== canonicalWaypointId) {
285
+ report.rekeyMap[row.id] = canonicalWaypointId;
286
+ report.memoriesRekeyed++;
287
+ }
288
+ toImport.push(row);
289
+ continue;
290
+ }
291
+
292
+ const existing = targetGet.get(row.id) as { content: string } | null;
293
+ if (existing) {
294
+ if (existing.content === row.content) {
295
+ report.memoriesSkipped++;
296
+ continue;
297
+ }
298
+ const fresh = randomUUID();
299
+ report.rekeyMap[row.id] = fresh;
300
+ report.memoriesRekeyed++;
301
+ }
302
+ toImport.push(row);
303
+ }
304
+
305
+ // The target waypoint may also already exist — keep whichever is newer.
306
+ const existingWaypoint = this.target
307
+ .prepare("SELECT updated_at FROM memories WHERE id = ?")
308
+ .get(canonicalWaypointId) as { updated_at: number } | null;
309
+ if (existingWaypoint && waypoints[0]) {
310
+ const idx = toImport.indexOf(waypoints[0]);
311
+ if (idx !== -1 && existingWaypoint.updated_at >= waypoints[0].updated_at) {
312
+ // Target's waypoint is newer — drop the source copy. The rekeyMap
313
+ // entry stays: references remap to the surviving target waypoint.
314
+ toImport.splice(idx, 1);
315
+ report.memoriesSkipped++;
316
+ }
317
+ }
318
+
319
+ // Pre-compute embeddings for rows whose vectors are missing or have the
320
+ // wrong dimension (model change) — outside any transaction.
321
+ const expectedBytes = this.embeddings.dimension * 4;
322
+ const reEmbedded = new Map<string, number[]>();
323
+ for (const row of toImport) {
324
+ if (isWaypointRow(row)) continue; // waypoints keep zero vectors
325
+ if (row.vector && row.vector.byteLength === expectedBytes) continue;
326
+ reEmbedded.set(row.id, await this.embeddings.embed(row.content));
327
+ }
328
+ const zeroVector = serializeVector(
329
+ new Array(this.embeddings.dimension).fill(0),
330
+ );
331
+
332
+ if (options.dryRun) {
333
+ report.memoriesImported = toImport.length;
334
+ this.collectUnresolved(rows, sourceIds, report);
335
+ return;
336
+ }
337
+
338
+ const insertMain = this.target.prepare(
339
+ `INSERT INTO memories (id, content, metadata, created_at, updated_at, superseded_by, usefulness, access_count, last_accessed, project)
340
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
341
+ );
342
+ const replaceMain = this.target.prepare(
343
+ `INSERT OR REPLACE INTO memories (id, content, metadata, created_at, updated_at, superseded_by, usefulness, access_count, last_accessed, project)
344
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
345
+ );
346
+ const deleteVec = this.target.prepare(
347
+ "DELETE FROM memories_vec WHERE id = ?",
348
+ );
349
+ const insertVec = this.target.prepare(
350
+ "INSERT OR REPLACE INTO memories_vec (id, vector) VALUES (?, ?)",
351
+ );
352
+ const deleteFts = this.target.prepare(
353
+ "DELETE FROM memories_fts WHERE id = ?",
354
+ );
355
+ const insertFts = this.target.prepare(
356
+ "INSERT INTO memories_fts (id, content) VALUES (?, ?)",
357
+ );
358
+ const existsStmt = this.target.prepare(
359
+ "SELECT 1 FROM memories WHERE id = ?",
360
+ );
361
+
362
+ const rekey = (id: string | null): string | null =>
363
+ id === null ? null : (report.rekeyMap[id] ?? id);
364
+
365
+ const tx = this.target.transaction(() => {
366
+ for (const row of toImport) {
367
+ const newId = rekey(row.id)!;
368
+ const isWaypoint = isWaypointRow(row);
369
+
370
+ // Remap references in metadata and rendered content
371
+ const metadata = safeParseJsonObject(row.metadata);
372
+ let content = row.content;
373
+ for (const field of ["memory_ids", "related_memory_ids"]) {
374
+ const value = metadata[field];
375
+ if (Array.isArray(value)) {
376
+ metadata[field] = value.map((v) =>
377
+ typeof v === "string" ? rekey(v) : v,
378
+ );
379
+ }
380
+ }
381
+ for (const [oldId, mapped] of Object.entries(report.rekeyMap)) {
382
+ if (oldId !== newId) content = content.replaceAll(oldId, mapped);
383
+ }
384
+
385
+ const originalProject = metadata.project;
386
+ if (
387
+ typeof originalProject === "string" &&
388
+ originalProject.length > 0 &&
389
+ normalizeProject(originalProject) !== project
390
+ ) {
391
+ metadata.original_project = originalProject;
392
+ }
393
+ metadata.project = project;
394
+ metadata.import_batch = importBatch;
395
+ if (newId !== row.id) metadata.original_id = row.id;
396
+
397
+ // Re-check existence inside the transaction (no TOCTOU against a
398
+ // live writer). Waypoints may replace the canonical row (newest-wins
399
+ // was decided above); everything else never overwrites.
400
+ const exists = existsStmt.get(newId) != null;
401
+ if (exists && !isWaypoint) {
402
+ report.memoriesSkipped++;
403
+ continue;
404
+ }
405
+
406
+ const vector = isWaypoint
407
+ ? zeroVector
408
+ : reEmbedded.has(row.id)
409
+ ? serializeVector(reEmbedded.get(row.id)!)
410
+ : row.vector!;
411
+
412
+ (exists ? replaceMain : insertMain).run(
413
+ newId,
414
+ content,
415
+ JSON.stringify(metadata),
416
+ row.created_at,
417
+ row.updated_at,
418
+ rekey(row.superseded_by),
419
+ row.usefulness,
420
+ row.access_count,
421
+ row.last_accessed,
422
+ project,
423
+ );
424
+ deleteVec.run(newId);
425
+ insertVec.run(newId, vector);
426
+ deleteFts.run(newId);
427
+ insertFts.run(newId, content);
428
+ report.memoriesImported++;
429
+ }
430
+ });
431
+ tx();
432
+
433
+ this.collectUnresolved(rows, sourceIds, report);
434
+ }
435
+
436
+ /** Report references that point at IDs in neither the source nor target. */
437
+ private collectUnresolved(
438
+ rows: SourceMemoryRow[],
439
+ sourceIds: Set<string>,
440
+ report: SourceReport,
441
+ ): void {
442
+ const targetHas = this.target.prepare("SELECT 1 FROM memories WHERE id = ?");
443
+ for (const row of rows) {
444
+ const metadata = safeParseJsonObject(row.metadata);
445
+ const refs = [
446
+ ...(Array.isArray(metadata.memory_ids) ? metadata.memory_ids : []),
447
+ ...(Array.isArray(metadata.related_memory_ids)
448
+ ? metadata.related_memory_ids
449
+ : []),
450
+ ...(row.superseded_by && row.superseded_by !== "DELETED"
451
+ ? [row.superseded_by]
452
+ : []),
453
+ ].filter((r): r is string => typeof r === "string");
454
+
455
+ for (const ref of refs) {
456
+ const mapped = report.rekeyMap[ref] ?? ref;
457
+ if (sourceIds.has(ref)) continue;
458
+ if (targetHas.get(mapped) != null) continue;
459
+ report.unresolvedReferences.push(`${row.id} -> ${ref}`);
460
+ }
461
+ }
462
+ }
463
+
464
+ // ── Conversation history ────────────────────────────────────────────
465
+
466
+ private importConversations(
467
+ source: Database,
468
+ project: string,
469
+ importBatch: string,
470
+ options: ConsolidationOptions,
471
+ report: SourceReport,
472
+ ): void {
473
+ if (!tableExists(source, "conversation_history")) return;
474
+
475
+ const rows = source
476
+ .prepare(
477
+ `SELECT c.*, v.vector FROM conversation_history c
478
+ LEFT JOIN conversation_history_vec v ON c.id = v.id`,
479
+ )
480
+ .all() as Array<{
481
+ id: string;
482
+ content: string;
483
+ metadata: string;
484
+ created_at: number;
485
+ session_id: string;
486
+ role: string;
487
+ message_index_start: number;
488
+ message_index_end: number;
489
+ project: string;
490
+ vector: Buffer | null;
491
+ }>;
492
+ if (rows.length === 0) return;
493
+
494
+ const existsStmt = this.target.prepare(
495
+ "SELECT 1 FROM conversation_history WHERE id = ?",
496
+ );
497
+
498
+ if (options.dryRun) {
499
+ for (const row of rows) {
500
+ if (existsStmt.get(row.id) != null) report.conversationsSkipped++;
501
+ else report.conversationsImported++;
502
+ }
503
+ return;
504
+ }
505
+
506
+ const insertMain = this.target.prepare(
507
+ `INSERT INTO conversation_history
508
+ (id, content, metadata, created_at, session_id, role, message_index_start, message_index_end, project)
509
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
510
+ );
511
+ const insertVec = this.target.prepare(
512
+ "INSERT OR REPLACE INTO conversation_history_vec (id, vector) VALUES (?, ?)",
513
+ );
514
+ const insertFts = this.target.prepare(
515
+ "INSERT INTO conversation_history_fts (id, content) VALUES (?, ?)",
516
+ );
517
+
518
+ const tx = this.target.transaction(() => {
519
+ for (const row of rows) {
520
+ if (existsStmt.get(row.id) != null) {
521
+ report.conversationsSkipped++;
522
+ continue;
523
+ }
524
+ // Sessions in a repo-local db were indexed from that repo — stamp
525
+ // the canonical project unless the row already carries one.
526
+ const rowProject = row.project.startsWith("/") ? row.project : project;
527
+ const metadata = safeParseJsonObject(row.metadata);
528
+ metadata.project = rowProject;
529
+ metadata.import_batch = importBatch;
530
+
531
+ insertMain.run(
532
+ row.id,
533
+ row.content,
534
+ JSON.stringify(metadata),
535
+ row.created_at,
536
+ row.session_id,
537
+ row.role,
538
+ row.message_index_start,
539
+ row.message_index_end,
540
+ rowProject,
541
+ );
542
+ if (row.vector) insertVec.run(row.id, row.vector);
543
+ insertFts.run(row.id, row.content);
544
+ report.conversationsImported++;
545
+ }
546
+ });
547
+ tx();
548
+ }
549
+
550
+ // ── Conversation index state ────────────────────────────────────────
551
+
552
+ private async importIndexState(
553
+ source: Database,
554
+ sourceDbPath: string,
555
+ project: string,
556
+ options: ConsolidationOptions,
557
+ report: SourceReport,
558
+ ): Promise<void> {
559
+ type StateRow = {
560
+ session_id: string;
561
+ file_path: string;
562
+ project: string;
563
+ last_modified: number;
564
+ chunk_count: number;
565
+ message_count: number;
566
+ indexed_at: number;
567
+ first_message_at: number;
568
+ last_message_at: number;
569
+ };
570
+
571
+ const entries: StateRow[] = [];
572
+ if (tableExists(source, "conversation_index_state")) {
573
+ entries.push(
574
+ ...(source
575
+ .prepare("SELECT * FROM conversation_index_state")
576
+ .all() as StateRow[]),
577
+ );
578
+ }
579
+
580
+ // Legacy JSON state next to the source db
581
+ try {
582
+ const raw = await readFile(
583
+ join(dirname(sourceDbPath), "conversation_index_state.json"),
584
+ "utf-8",
585
+ );
586
+ const legacy = JSON.parse(raw) as Array<{
587
+ sessionId: string;
588
+ filePath: string;
589
+ project: string;
590
+ lastModified: number;
591
+ chunkCount: number;
592
+ messageCount: number;
593
+ indexedAt: string;
594
+ firstMessageAt: string;
595
+ lastMessageAt: string;
596
+ }>;
597
+ const seen = new Set(entries.map((e) => e.session_id));
598
+ for (const e of legacy) {
599
+ if (seen.has(e.sessionId)) continue;
600
+ entries.push({
601
+ session_id: e.sessionId,
602
+ file_path: e.filePath,
603
+ project: e.project,
604
+ last_modified: e.lastModified,
605
+ chunk_count: e.chunkCount,
606
+ message_count: e.messageCount,
607
+ indexed_at: new Date(e.indexedAt).getTime(),
608
+ first_message_at: new Date(e.firstMessageAt).getTime(),
609
+ last_message_at: new Date(e.lastMessageAt).getTime(),
610
+ });
611
+ }
612
+ } catch {
613
+ // no legacy file — fine
614
+ }
615
+
616
+ if (entries.length === 0 || options.dryRun) {
617
+ if (options.dryRun) report.indexStateImported = entries.length;
618
+ return;
619
+ }
620
+
621
+ const insert = this.target.prepare(
622
+ `INSERT OR IGNORE INTO conversation_index_state
623
+ (session_id, file_path, project, last_modified, chunk_count, message_count, indexed_at, first_message_at, last_message_at)
624
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
625
+ );
626
+ const tx = this.target.transaction(() => {
627
+ for (const e of entries) {
628
+ const result = insert.run(
629
+ e.session_id,
630
+ e.file_path,
631
+ e.project.startsWith("/") ? e.project : project,
632
+ e.last_modified,
633
+ e.chunk_count,
634
+ e.message_count,
635
+ e.indexed_at,
636
+ e.first_message_at,
637
+ e.last_message_at,
638
+ );
639
+ if (result.changes > 0) report.indexStateImported++;
640
+ }
641
+ });
642
+ tx();
643
+ }
644
+ }
645
+
646
+ function tableExists(db: Database, name: string): boolean {
647
+ return (
648
+ db
649
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
650
+ .get(name) != null
651
+ );
652
+ }