experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): add SQL abstraction layer for database portability

Phase 1 of server portability plan. Abstracts better-sqlite3 behind
SqlAdapter interface to enable future support for Cloudflare Durable
Objects SQLite.

- Create sql/types.js with SqlAdapter/SqlAdapterFactory interfaces
- Create sql/better-sqlite3-adapter.js implementing the interface
- Create sql/index.js factory for adapter selection
- Refactor db.js to use adapter instead of direct better-sqlite3 calls
- Refactor users.js to use adapter for system.db
- Update backup.js with TODO for DO SQLite backup strategy
- Update tests to use adapter methods (conn.all/get vs conn.prepare)

All 110 tests pass.

+467 -298
+11 -4
backend/server/backup.js
··· 46 46 function getTableCounts(conn) { 47 47 const counts = {}; 48 48 49 - const itemTypes = conn.prepare(` 49 + const itemTypes = conn.all(` 50 50 SELECT type, COUNT(*) as count 51 51 FROM items 52 52 WHERE CAST(deletedAt AS INTEGER) = 0 53 53 GROUP BY type 54 - `).all(); 54 + `); 55 55 56 56 for (const row of itemTypes) { 57 57 counts[row.type + "s"] = row.count; 58 58 } 59 59 60 - const tagCount = conn.prepare("SELECT COUNT(*) as count FROM tags").get(); 60 + const tagCount = conn.get("SELECT COUNT(*) as count FROM tags"); 61 61 counts.tags = tagCount.count; 62 62 63 63 return counts; ··· 65 65 66 66 /** 67 67 * Create a backup for a single user 68 + * 69 + * TODO: DO SQLite backup - VACUUM INTO is not available in Cloudflare Durable Objects SQLite. 70 + * When deploying to DO, implement an alternative backup strategy: 71 + * - Export data as JSON to R2 72 + * - Or use DO's built-in point-in-time recovery features 68 73 */ 69 74 async function createBackup(userId) { 70 75 console.log(`Creating backup for user: ${userId}`); ··· 84 89 fs.mkdirSync(userBackupDir, { recursive: true }); 85 90 } 86 91 87 - // Get database connection 92 + // Get database connection (SqlAdapter) 88 93 const conn = db.getConnection(userId); 89 94 90 95 const backupFilename = generateBackupFilename(userId); ··· 93 98 94 99 try { 95 100 // Use VACUUM INTO for consistent snapshot (non-blocking) 101 + // Note: This only works with better-sqlite3 adapter. 102 + // TODO: For DO SQLite, implement JSON export to R2 instead. 96 103 conn.exec(`VACUUM INTO '${tempDbPath}'`); 97 104 98 105 // Get table counts for manifest
+211 -226
backend/server/db.js
··· 1 - const Database = require("better-sqlite3"); 1 + const { sqlFactory } = require("./sql"); 2 2 const path = require("path"); 3 3 const crypto = require("crypto"); 4 4 const fs = require("fs"); ··· 14 14 const DATA_DIR = process.env.DATA_DIR || "./data"; 15 15 16 16 // Connection pool - one connection per user:profile 17 + // Now stores SqlAdapter instances instead of raw Database instances 17 18 const connections = new Map(); 18 19 19 20 function getConnection(userId, profileId = "default") { ··· 34 35 } 35 36 36 37 const dbPath = path.join(profileDir, "datastore.sqlite"); 37 - const db = new Database(dbPath); 38 - db.pragma("journal_mode = WAL"); 38 + const adapter = sqlFactory.open(dbPath); 39 + sqlFactory.init(adapter); 39 40 40 - initializeSchema(db); 41 - connections.set(connectionKey, db); 41 + initializeSchema(adapter); 42 + connections.set(connectionKey, adapter); 42 43 43 - return db; 44 + return adapter; 44 45 } 45 46 46 47 /** 47 48 * Rename snake_case columns to camelCase if the old names exist. 48 49 * SQLite 3.25+ supports ALTER TABLE RENAME COLUMN. 49 50 */ 50 - function migrateColumns(db, table, renames) { 51 - const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name); 51 + function migrateColumns(adapter, table, renames) { 52 + const cols = adapter.all(`PRAGMA table_info(${table})`).map(c => c.name); 52 53 for (const [oldName, newName] of Object.entries(renames)) { 53 54 if (cols.includes(oldName) && !cols.includes(newName)) { 54 55 try { 55 - db.exec(`ALTER TABLE ${table} RENAME COLUMN ${oldName} TO ${newName}`); 56 + adapter.exec(`ALTER TABLE ${table} RENAME COLUMN ${oldName} TO ${newName}`); 56 57 } catch (error) { 57 58 console.error(`[schema] Failed to rename ${table}.${oldName} → ${newName}: ${error.message}`); 58 59 } ··· 67 68 * 68 69 * @param {boolean} forceRebuild - Force rebuild even if no renames pending (e.g., for type changes) 69 70 */ 70 - function rebuildTableIfNeeded(db, table, createSQL, renames, forceRebuild = false) { 71 - const currentCols = db.prepare(`PRAGMA table_info(${table})`).all(); 71 + function rebuildTableIfNeeded(adapter, table, createSQL, renames, forceRebuild = false) { 72 + const currentCols = adapter.all(`PRAGMA table_info(${table})`); 72 73 const currentColNames = new Set(currentCols.map(c => c.name)); 73 74 74 75 // Check if any renames are still pending (old name exists, new doesn't) ··· 92 93 .replace(`CREATE TABLE IF NOT EXISTS ${table}`, `CREATE TABLE "${tempTable}"`) 93 94 .replace(`CREATE TABLE ${table}`, `CREATE TABLE "${tempTable}"`); 94 95 95 - db.exec(`DROP TABLE IF EXISTS "${tempTable}"`); 96 - db.exec(tempCreateSQL); 96 + adapter.exec(`DROP TABLE IF EXISTS "${tempTable}"`); 97 + adapter.exec(tempCreateSQL); 97 98 98 99 // Map target columns to source expressions 99 - const targetCols = db.prepare(`PRAGMA table_info("${tempTable}")`).all(); 100 + const targetCols = adapter.all(`PRAGMA table_info("${tempTable}")`); 100 101 const insertCols = []; 101 102 const selectExprs = []; 102 103 ··· 115 116 } 116 117 117 118 // Atomic rebuild: if any step fails, the original table is preserved 118 - db.transaction(() => { 119 - db.exec(`INSERT INTO "${tempTable}" (${insertCols.join(", ")}) SELECT ${selectExprs.join(", ")} FROM "${table}"`); 120 - db.exec(`DROP TABLE "${table}"`); 121 - db.exec(`ALTER TABLE "${tempTable}" RENAME TO "${table}"`); 122 - })(); 119 + adapter.transaction(() => { 120 + adapter.exec(`INSERT INTO "${tempTable}" (${insertCols.join(", ")}) SELECT ${selectExprs.join(", ")} FROM "${table}"`); 121 + adapter.exec(`DROP TABLE "${table}"`); 122 + adapter.exec(`ALTER TABLE "${tempTable}" RENAME TO "${table}"`); 123 + }); 123 124 } 124 125 125 126 /** ··· 133 134 * Handles both camelCase and snake_case column names — safe to call before 134 135 * or after migrateColumns renames them. 135 136 */ 136 - function migrateTimestamps(db, table, columns) { 137 + function migrateTimestamps(adapter, table, columns) { 137 138 const actualCols = new Set( 138 - db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name) 139 + adapter.all(`PRAGMA table_info(${table})`).map(c => c.name) 139 140 ); 140 141 for (const col of columns) { 141 142 // If the camelCase column doesn't exist yet, try the snake_case equivalent ··· 150 151 } 151 152 } 152 153 // Convert ISO 8601 strings (contain 'T') to Unix ms 153 - db.exec(` 154 + adapter.exec(` 154 155 UPDATE ${table} 155 156 SET ${actualCol} = CAST(strftime('%s', ${actualCol}) AS INTEGER) * 1000 156 157 WHERE typeof(${actualCol}) = 'text' AND ${actualCol} LIKE '%T%' 157 158 `); 158 159 // Convert stringified numbers ("1769559596439.0") to integers 159 - db.exec(` 160 + adapter.exec(` 160 161 UPDATE ${table} 161 162 SET ${actualCol} = CAST(CAST(${actualCol} AS REAL) AS INTEGER) 162 163 WHERE typeof(${actualCol}) = 'text' AND ${actualCol} NOT LIKE '%T%' ··· 169 170 * Fails fast with a clear error instead of letting the server boot with 170 171 * a broken schema that crashes on the first query. 171 172 */ 172 - function validateSchema(db) { 173 + function validateSchema(adapter) { 173 174 // Use canonical schema from schema/v1.json 174 175 const required = REQUIRED_SYNC_COLUMNS; 175 176 const missing = []; 176 177 for (const [table, cols] of Object.entries(required)) { 177 - const actual = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name)); 178 + const actual = new Set(adapter.all(`PRAGMA table_info(${table})`).map(c => c.name)); 178 179 for (const col of cols) { 179 180 if (!actual.has(col)) { 180 181 missing.push(`${table}.${col}`); ··· 184 185 if (missing.length > 0) { 185 186 // Log actual schema state for debugging before throwing 186 187 for (const table of Object.keys(required)) { 187 - const actual = db.prepare(`PRAGMA table_info(${table})`).all(); 188 + const actual = adapter.all(`PRAGMA table_info(${table})`); 188 189 console.error(`[schema] ${table} actual columns: ${actual.map(c => c.name).join(", ")}`); 189 190 } 190 191 throw new Error( ··· 194 195 } 195 196 } 196 197 197 - function initializeSchema(db) { 198 + function initializeSchema(adapter) { 198 199 // Canonical camelCase schema — matches sync engine 199 - db.exec(` 200 + adapter.exec(` 200 201 CREATE TABLE IF NOT EXISTS items ( 201 202 id TEXT PRIMARY KEY, 202 203 type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image')), ··· 225 226 "deleted_at": "deletedAt", 226 227 }; 227 228 // Diagnostic: log actual items schema before migration 228 - const itemColsPre = db.prepare("PRAGMA table_info(items)").all(); 229 + const itemColsPre = adapter.all("PRAGMA table_info(items)"); 229 230 console.log(`[schema] items columns before migration: ${itemColsPre.map(c => c.name).join(", ")}`); 230 231 231 - migrateColumns(db, "items", itemRenames); 232 - rebuildTableIfNeeded(db, "items", `CREATE TABLE items ( 232 + migrateColumns(adapter, "items", itemRenames); 233 + rebuildTableIfNeeded(adapter, "items", `CREATE TABLE items ( 233 234 id TEXT PRIMARY KEY, 234 235 type TEXT NOT NULL, 235 236 content TEXT, ··· 244 245 245 246 // Add columns that may not exist in any form. 246 247 // Check both camelCase AND snake_case to avoid creating duplicates if rename failed. 247 - const itemColSet = new Set(db.prepare("PRAGMA table_info(items)").all().map(c => c.name)); 248 + const itemColSet = new Set(adapter.all("PRAGMA table_info(items)").map(c => c.name)); 248 249 if (!itemColSet.has("syncId") && !itemColSet.has("sync_id")) { 249 - db.exec("ALTER TABLE items ADD COLUMN syncId TEXT DEFAULT ''"); 250 + adapter.exec("ALTER TABLE items ADD COLUMN syncId TEXT DEFAULT ''"); 250 251 } 251 252 if (!itemColSet.has("syncSource") && !itemColSet.has("sync_source")) { 252 - db.exec("ALTER TABLE items ADD COLUMN syncSource TEXT DEFAULT ''"); 253 + adapter.exec("ALTER TABLE items ADD COLUMN syncSource TEXT DEFAULT ''"); 253 254 } 254 255 if (!itemColSet.has("syncedAt") && !itemColSet.has("synced_at")) { 255 - db.exec("ALTER TABLE items ADD COLUMN syncedAt INTEGER DEFAULT 0"); 256 + adapter.exec("ALTER TABLE items ADD COLUMN syncedAt INTEGER DEFAULT 0"); 256 257 } 257 258 if (!itemColSet.has("deletedAt") && !itemColSet.has("deleted_at")) { 258 - db.exec("ALTER TABLE items ADD COLUMN deletedAt INTEGER DEFAULT 0"); 259 + adapter.exec("ALTER TABLE items ADD COLUMN deletedAt INTEGER DEFAULT 0"); 259 260 } 260 261 261 262 // Convert any TEXT timestamps to INTEGER (Unix ms) 262 - migrateTimestamps(db, "items", ["createdAt", "updatedAt", "syncedAt", "deletedAt"]); 263 + migrateTimestamps(adapter, "items", ["createdAt", "updatedAt", "syncedAt", "deletedAt"]); 263 264 264 265 // Convert NULL deletedAt to 0 (old schema used NULL for not-deleted, new uses 0) 265 - db.exec("UPDATE items SET deletedAt = 0 WHERE deletedAt IS NULL"); 266 + adapter.exec("UPDATE items SET deletedAt = 0 WHERE deletedAt IS NULL"); 266 267 267 268 // Create indexes only if referenced columns exist (rename may have failed) 268 - const itemColsPost = new Set(db.prepare("PRAGMA table_info(items)").all().map(c => c.name)); 269 + const itemColsPost = new Set(adapter.all("PRAGMA table_info(items)").map(c => c.name)); 269 270 if (itemColsPost.has("syncId")) { 270 - db.exec("CREATE INDEX IF NOT EXISTS idx_items_syncId ON items(syncId)"); 271 + adapter.exec("CREATE INDEX IF NOT EXISTS idx_items_syncId ON items(syncId)"); 271 272 } 272 273 if (itemColsPost.has("deletedAt")) { 273 - db.exec("CREATE INDEX IF NOT EXISTS idx_items_deletedAt ON items(deletedAt)"); 274 + adapter.exec("CREATE INDEX IF NOT EXISTS idx_items_deletedAt ON items(deletedAt)"); 274 275 } 275 276 276 - db.exec(` 277 + adapter.exec(` 277 278 CREATE TABLE IF NOT EXISTS tags ( 278 279 id TEXT PRIMARY KEY, 279 280 name TEXT NOT NULL UNIQUE, ··· 294 295 }; 295 296 296 297 // Diagnostic: log actual tags schema before migration 297 - const tagColsPre = db.prepare("PRAGMA table_info(tags)").all(); 298 + const tagColsPre = adapter.all("PRAGMA table_info(tags)"); 298 299 console.log(`[schema] tags columns before migration: ${tagColsPre.map(c => c.name).join(", ")}`); 299 300 300 - migrateColumns(db, "tags", tagRenames); 301 + migrateColumns(adapter, "tags", tagRenames); 301 302 302 303 // Check if tags.id needs type migration (INTEGER AUTOINCREMENT → TEXT) 303 304 // This happens when the production DB was created with the original schema. 304 305 // rebuildTableIfNeeded only checks for column renames, not type changes. 305 - const tagsIdCol = db.prepare("PRAGMA table_info(tags)").all().find(c => c.name === "id"); 306 + const tagsIdCol = adapter.all("PRAGMA table_info(tags)").find(c => c.name === "id"); 306 307 const needsIdTypeRebuild = tagsIdCol && tagsIdCol.type.toUpperCase() === "INTEGER"; 307 308 if (needsIdTypeRebuild) { 308 309 console.log("[schema] tags.id is INTEGER, forcing rebuild for TEXT PRIMARY KEY migration"); 309 310 } 310 311 311 - rebuildTableIfNeeded(db, "tags", `CREATE TABLE tags ( 312 + rebuildTableIfNeeded(adapter, "tags", `CREATE TABLE tags ( 312 313 id TEXT PRIMARY KEY, 313 314 name TEXT NOT NULL UNIQUE, 314 315 frequency INTEGER DEFAULT 1, ··· 319 320 )`, tagRenames, needsIdTypeRebuild); 320 321 321 322 // Safety net: add any missing columns (handles unknown legacy schemas) 322 - const tagColSet = new Set(db.prepare("PRAGMA table_info(tags)").all().map(c => c.name)); 323 + const tagColSet = new Set(adapter.all("PRAGMA table_info(tags)").map(c => c.name)); 323 324 if (!tagColSet.has("lastUsed") && !tagColSet.has("last_used_at")) { 324 325 console.log("[schema] Adding missing column tags.lastUsed"); 325 - db.exec("ALTER TABLE tags ADD COLUMN lastUsed INTEGER NOT NULL DEFAULT 0"); 326 + adapter.exec("ALTER TABLE tags ADD COLUMN lastUsed INTEGER NOT NULL DEFAULT 0"); 326 327 } 327 328 if (!tagColSet.has("frecencyScore") && !tagColSet.has("frecency_score")) { 328 329 console.log("[schema] Adding missing column tags.frecencyScore"); 329 - db.exec("ALTER TABLE tags ADD COLUMN frecencyScore REAL DEFAULT 0.0"); 330 + adapter.exec("ALTER TABLE tags ADD COLUMN frecencyScore REAL DEFAULT 0.0"); 330 331 } 331 332 if (!tagColSet.has("createdAt") && !tagColSet.has("created_at")) { 332 333 console.log("[schema] Adding missing column tags.createdAt"); 333 - db.exec("ALTER TABLE tags ADD COLUMN createdAt INTEGER NOT NULL DEFAULT 0"); 334 + adapter.exec("ALTER TABLE tags ADD COLUMN createdAt INTEGER NOT NULL DEFAULT 0"); 334 335 } 335 336 if (!tagColSet.has("updatedAt") && !tagColSet.has("updated_at")) { 336 337 console.log("[schema] Adding missing column tags.updatedAt"); 337 - db.exec("ALTER TABLE tags ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0"); 338 + adapter.exec("ALTER TABLE tags ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0"); 338 339 } 339 340 if (!tagColSet.has("frequency")) { 340 341 console.log("[schema] Adding missing column tags.frequency"); 341 - db.exec("ALTER TABLE tags ADD COLUMN frequency INTEGER DEFAULT 1"); 342 + adapter.exec("ALTER TABLE tags ADD COLUMN frequency INTEGER DEFAULT 1"); 342 343 } 343 344 344 345 // Re-run rename/rebuild after ADD COLUMN (columns may now exist with snake_case from ADD) 345 - const tagColsAfterAdd = new Set(db.prepare("PRAGMA table_info(tags)").all().map(c => c.name)); 346 + const tagColsAfterAdd = new Set(adapter.all("PRAGMA table_info(tags)").map(c => c.name)); 346 347 if ([...Object.values(tagRenames)].some(n => !tagColsAfterAdd.has(n))) { 347 - migrateColumns(db, "tags", tagRenames); 348 - rebuildTableIfNeeded(db, "tags", `CREATE TABLE tags ( 348 + migrateColumns(adapter, "tags", tagRenames); 349 + rebuildTableIfNeeded(adapter, "tags", `CREATE TABLE tags ( 349 350 id TEXT PRIMARY KEY, 350 351 name TEXT NOT NULL UNIQUE, 351 352 frequency INTEGER DEFAULT 1, ··· 356 357 )`, tagRenames); 357 358 } 358 359 359 - migrateTimestamps(db, "tags", ["lastUsed", "createdAt", "updatedAt"]); 360 + migrateTimestamps(adapter, "tags", ["lastUsed", "createdAt", "updatedAt"]); 360 361 361 - db.exec("CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name)"); 362 - const tagColsPost = new Set(db.prepare("PRAGMA table_info(tags)").all().map(c => c.name)); 362 + adapter.exec("CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name)"); 363 + const tagColsPost = new Set(adapter.all("PRAGMA table_info(tags)").map(c => c.name)); 363 364 if (tagColsPost.has("frecencyScore")) { 364 - db.exec("CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC)"); 365 + adapter.exec("CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC)"); 365 366 } 366 367 367 - db.exec(` 368 + adapter.exec(` 368 369 CREATE TABLE IF NOT EXISTS item_tags ( 369 370 itemId TEXT NOT NULL, 370 371 tagId TEXT NOT NULL, ··· 380 381 }; 381 382 382 383 // Diagnostic: log actual item_tags schema before migration 383 - const itColsPre = db.prepare("PRAGMA table_info(item_tags)").all(); 384 + const itColsPre = adapter.all("PRAGMA table_info(item_tags)"); 384 385 console.log(`[schema] item_tags columns before migration: ${itColsPre.map(c => c.name).join(", ")}`); 385 386 386 - migrateColumns(db, "item_tags", itemTagRenames); 387 + migrateColumns(adapter, "item_tags", itemTagRenames); 387 388 388 389 // Check if item_tags.tag_id/tagId needs type migration (INTEGER → TEXT) 389 390 // Production item_tags had tag_id INTEGER as foreign key to tags.id INTEGER 390 - const itTagIdCol = db.prepare("PRAGMA table_info(item_tags)").all().find(c => c.name === "tag_id" || c.name === "tagId"); 391 + const itTagIdCol = adapter.all("PRAGMA table_info(item_tags)").find(c => c.name === "tag_id" || c.name === "tagId"); 391 392 const needsTagIdTypeRebuild = itTagIdCol && itTagIdCol.type.toUpperCase() === "INTEGER"; 392 393 if (needsTagIdTypeRebuild) { 393 394 console.log("[schema] item_tags.tagId is INTEGER, forcing rebuild for TEXT migration"); 394 395 } 395 396 396 - rebuildTableIfNeeded(db, "item_tags", `CREATE TABLE item_tags ( 397 + rebuildTableIfNeeded(adapter, "item_tags", `CREATE TABLE item_tags ( 397 398 itemId TEXT NOT NULL, 398 399 tagId TEXT NOT NULL, 399 400 createdAt INTEGER NOT NULL, ··· 401 402 )`, itemTagRenames, needsTagIdTypeRebuild); 402 403 403 404 // Safety net: add any missing columns for item_tags 404 - const itColSet = new Set(db.prepare("PRAGMA table_info(item_tags)").all().map(c => c.name)); 405 + const itColSet = new Set(adapter.all("PRAGMA table_info(item_tags)").map(c => c.name)); 405 406 if (!itColSet.has("itemId") && !itColSet.has("item_id")) { 406 407 console.log("[schema] Adding missing column item_tags.itemId"); 407 - db.exec("ALTER TABLE item_tags ADD COLUMN itemId TEXT NOT NULL DEFAULT ''"); 408 + adapter.exec("ALTER TABLE item_tags ADD COLUMN itemId TEXT NOT NULL DEFAULT ''"); 408 409 } 409 410 if (!itColSet.has("tagId") && !itColSet.has("tag_id")) { 410 411 console.log("[schema] Adding missing column item_tags.tagId"); 411 - db.exec("ALTER TABLE item_tags ADD COLUMN tagId TEXT NOT NULL DEFAULT ''"); 412 + adapter.exec("ALTER TABLE item_tags ADD COLUMN tagId TEXT NOT NULL DEFAULT ''"); 412 413 } 413 414 if (!itColSet.has("createdAt") && !itColSet.has("created_at")) { 414 415 console.log("[schema] Adding missing column item_tags.createdAt"); 415 - db.exec("ALTER TABLE item_tags ADD COLUMN createdAt INTEGER NOT NULL DEFAULT 0"); 416 + adapter.exec("ALTER TABLE item_tags ADD COLUMN createdAt INTEGER NOT NULL DEFAULT 0"); 416 417 } 417 418 418 - migrateTimestamps(db, "item_tags", ["createdAt"]); 419 + migrateTimestamps(adapter, "item_tags", ["createdAt"]); 419 420 420 - const itColsPost = new Set(db.prepare("PRAGMA table_info(item_tags)").all().map(c => c.name)); 421 + const itColsPost = new Set(adapter.all("PRAGMA table_info(item_tags)").map(c => c.name)); 421 422 if (itColsPost.has("itemId")) { 422 - db.exec("CREATE INDEX IF NOT EXISTS idx_item_tags_itemId ON item_tags(itemId)"); 423 + adapter.exec("CREATE INDEX IF NOT EXISTS idx_item_tags_itemId ON item_tags(itemId)"); 423 424 } 424 425 if (itColsPost.has("tagId")) { 425 - db.exec("CREATE INDEX IF NOT EXISTS idx_item_tags_tagId ON item_tags(tagId)"); 426 + adapter.exec("CREATE INDEX IF NOT EXISTS idx_item_tags_tagId ON item_tags(tagId)"); 426 427 } 427 428 428 - db.exec(` 429 + adapter.exec(` 429 430 CREATE TABLE IF NOT EXISTS settings ( 430 431 key TEXT PRIMARY KEY, 431 432 value TEXT ··· 433 434 `); 434 435 435 436 // Fail fast if migration left the schema incomplete 436 - validateSchema(db); 437 + validateSchema(adapter); 437 438 438 439 // Write datastore version after schema init 439 - db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run( 440 - "datastore_version", 441 - String(DATASTORE_VERSION) 440 + adapter.run( 441 + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", 442 + ["datastore_version", String(DATASTORE_VERSION)] 442 443 ); 443 444 } 444 445 ··· 471 472 472 473 // Internal helper - needs conn passed directly 473 474 function getOrCreateTagWithConn(conn, name, timestamp) { 474 - const existing = conn.prepare("SELECT id, frequency FROM tags WHERE name = ?").get(name); 475 + const existing = conn.get("SELECT id, frequency FROM tags WHERE name = ?", [name]); 475 476 476 477 if (existing) { 477 478 const newFrequency = existing.frequency + 1; 478 479 const frecencyScore = calculateFrecency(newFrequency, timestamp); 479 - conn.prepare(` 480 - UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? 481 - WHERE id = ? 482 - `).run(newFrequency, timestamp, frecencyScore, timestamp, existing.id); 480 + conn.run( 481 + "UPDATE tags SET frequency = ?, lastUsed = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?", 482 + [newFrequency, timestamp, frecencyScore, timestamp, existing.id] 483 + ); 483 484 return existing.id; 484 485 } else { 485 486 const tagId = generateUUID(); 486 487 const frecencyScore = calculateFrecency(1, timestamp); 487 - conn.prepare(` 488 - INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) 489 - VALUES (?, ?, 1, ?, ?, ?, ?) 490 - `).run(tagId, name, timestamp, frecencyScore, timestamp, timestamp); 488 + conn.run( 489 + "INSERT INTO tags (id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt) VALUES (?, ?, 1, ?, ?, ?, ?)", 490 + [tagId, name, timestamp, frecencyScore, timestamp, timestamp] 491 + ); 491 492 return tagId; 492 493 } 493 494 } ··· 504 505 // Sync path: match by syncId only. No content-based fallback — syncId is canonical. 505 506 506 507 // Check if syncId matches a server item by its own ID (client sends server ID on re-push) 507 - const existingById = conn.prepare( 508 - "SELECT id, deletedAt FROM items WHERE id = ?" 509 - ).get(syncId); 508 + const existingById = conn.get( 509 + "SELECT id, deletedAt FROM items WHERE id = ?", 510 + [syncId] 511 + ); 510 512 511 513 if (existingById) { 512 514 itemId = existingById.id; ··· 514 516 515 517 // Check syncId column (client's local ID from first push) 516 518 if (!itemId) { 517 - const existingBySyncId = conn.prepare( 518 - "SELECT id, deletedAt FROM items WHERE syncId = ?" 519 - ).get(syncId); 519 + const existingBySyncId = conn.get( 520 + "SELECT id, deletedAt FROM items WHERE syncId = ?", 521 + [syncId] 522 + ); 520 523 521 524 if (existingBySyncId) { 522 525 itemId = existingBySyncId.id; ··· 527 530 if (itemId) { 528 531 if (deletedAt) { 529 532 // Push a tombstone 530 - conn.prepare( 531 - "UPDATE items SET deletedAt = ?, updatedAt = ? WHERE id = ?" 532 - ).run(deletedAt, timestamp, itemId); 533 + conn.run( 534 + "UPDATE items SET deletedAt = ?, updatedAt = ? WHERE id = ?", 535 + [deletedAt, timestamp, itemId] 536 + ); 533 537 } else { 534 538 // Push live content and ensure item is not deleted (undelete case) 535 - conn.prepare( 536 - "UPDATE items SET type = ?, content = ?, metadata = COALESCE(?, metadata), deletedAt = 0, updatedAt = ? WHERE id = ?" 537 - ).run(type, content, metadataJson, timestamp, itemId); 538 - conn.prepare("DELETE FROM item_tags WHERE itemId = ?").run(itemId); 539 + conn.run( 540 + "UPDATE items SET type = ?, content = ?, metadata = COALESCE(?, metadata), deletedAt = 0, updatedAt = ? WHERE id = ?", 541 + [type, content, metadataJson, timestamp, itemId] 542 + ); 543 + conn.run("DELETE FROM item_tags WHERE itemId = ?", [itemId]); 539 544 } 540 545 } 541 546 } ··· 543 548 // Non-sync path: content-based dedup (when no syncId provided) 544 549 if (!syncId && !itemId) { 545 550 if (content) { 546 - const existing = conn.prepare( 547 - "SELECT id FROM items WHERE type = ? AND content = ? AND CAST(deletedAt AS INTEGER) = 0" 548 - ).get(type, content); 551 + const existing = conn.get( 552 + "SELECT id FROM items WHERE type = ? AND content = ? AND CAST(deletedAt AS INTEGER) = 0", 553 + [type, content] 554 + ); 549 555 if (existing) { 550 556 itemId = existing.id; 551 - conn.prepare("UPDATE items SET metadata = COALESCE(?, metadata), updatedAt = ? WHERE id = ?") 552 - .run(metadataJson, timestamp, itemId); 553 - conn.prepare("DELETE FROM item_tags WHERE itemId = ?").run(itemId); 557 + conn.run( 558 + "UPDATE items SET metadata = COALESCE(?, metadata), updatedAt = ? WHERE id = ?", 559 + [metadataJson, timestamp, itemId] 560 + ); 561 + conn.run("DELETE FROM item_tags WHERE itemId = ?", [itemId]); 554 562 } 555 563 } else if (type === 'tagset' && tags.length > 0) { 556 564 const sortedNewTags = [...tags].sort().join('\0'); 557 - const existingTagsets = conn.prepare( 565 + const existingTagsets = conn.all( 558 566 "SELECT id FROM items WHERE type = 'tagset' AND CAST(deletedAt AS INTEGER) = 0" 559 - ).all(); 567 + ); 560 568 for (const ts of existingTagsets) { 561 - const existingTags = conn.prepare( 562 - "SELECT t.name FROM tags t JOIN item_tags it ON t.id = it.tagId WHERE it.itemId = ?" 563 - ).all(ts.id).map(t => t.name).sort().join('\0'); 569 + const existingTags = conn.all( 570 + "SELECT t.name FROM tags t JOIN item_tags it ON t.id = it.tagId WHERE it.itemId = ?", 571 + [ts.id] 572 + ).map(t => t.name).sort().join('\0'); 564 573 if (existingTags === sortedNewTags) { 565 574 itemId = ts.id; 566 - conn.prepare("UPDATE items SET metadata = COALESCE(?, metadata), updatedAt = ? WHERE id = ?") 567 - .run(metadataJson, timestamp, itemId); 568 - conn.prepare("DELETE FROM item_tags WHERE itemId = ?").run(itemId); 575 + conn.run( 576 + "UPDATE items SET metadata = COALESCE(?, metadata), updatedAt = ? WHERE id = ?", 577 + [metadataJson, timestamp, itemId] 578 + ); 579 + conn.run("DELETE FROM item_tags WHERE itemId = ?", [itemId]); 569 580 break; 570 581 } 571 582 } ··· 575 586 // Create new item if no match found 576 587 if (!itemId) { 577 588 itemId = generateUUID(); 578 - conn.prepare(` 579 - INSERT INTO items (id, type, content, metadata, syncId, syncSource, syncedAt, createdAt, updatedAt, deletedAt) 580 - VALUES (?, ?, ?, ?, ?, '', 0, ?, ?, ?) 581 - `).run(itemId, type, content, metadataJson, syncId || '', timestamp, timestamp, deletedAt || 0); 589 + conn.run( 590 + "INSERT INTO items (id, type, content, metadata, syncId, syncSource, syncedAt, createdAt, updatedAt, deletedAt) VALUES (?, ?, ?, ?, ?, '', 0, ?, ?, ?)", 591 + [itemId, type, content, metadataJson, syncId || '', timestamp, timestamp, deletedAt || 0] 592 + ); 582 593 } 583 594 584 595 for (const tagName of tags) { 585 596 const tagId = getOrCreateTagWithConn(conn, tagName, timestamp); 586 - conn.prepare(` 587 - INSERT OR IGNORE INTO item_tags (itemId, tagId, createdAt) 588 - VALUES (?, ?, ?) 589 - `).run(itemId, tagId, timestamp); 597 + conn.run( 598 + "INSERT OR IGNORE INTO item_tags (itemId, tagId, createdAt) VALUES (?, ?, ?)", 599 + [itemId, tagId, timestamp] 600 + ); 590 601 } 591 602 592 603 return itemId; ··· 625 636 626 637 query += " ORDER BY createdAt DESC"; 627 638 628 - const items = conn.prepare(query).all(...params); 629 - 630 - const getTagsStmt = conn.prepare(` 631 - SELECT t.name 632 - FROM tags t 633 - JOIN item_tags it ON t.id = it.tagId 634 - WHERE it.itemId = ? 635 - `); 639 + const items = conn.all(query, params); 636 640 637 641 return items.map((row) => { 638 642 const result = { ··· 642 646 createdAt: toTimestamp(row.createdAt), 643 647 updatedAt: toTimestamp(row.updatedAt), 644 648 deletedAt: toTimestamp(row.deletedAt), 645 - tags: getTagsStmt.all(row.id).map((t) => t.name), 649 + tags: conn.all( 650 + "SELECT t.name FROM tags t JOIN item_tags it ON t.id = it.tagId WHERE it.itemId = ?", 651 + [row.id] 652 + ).map((t) => t.name), 646 653 }; 647 654 if (row.metadata) { 648 655 result.metadata = JSON.parse(row.metadata); ··· 694 701 function getTagsByFrecency(userId, profileId = "default") { 695 702 const conn = getConnection(userId, profileId); 696 703 697 - return conn.prepare(` 698 - SELECT name, frequency, lastUsed, frecencyScore 699 - FROM tags 700 - ORDER BY frecencyScore DESC 701 - `).all(); 704 + return conn.all( 705 + "SELECT name, frequency, lastUsed, frecencyScore FROM tags ORDER BY frecencyScore DESC" 706 + ); 702 707 } 703 708 704 709 function deleteItem(userId, id, profileId = "default") { 705 710 const conn = getConnection(userId, profileId); 706 711 const timestamp = now(); 707 - conn.prepare( 708 - "UPDATE items SET deletedAt = ?, updatedAt = ? WHERE id = ? AND CAST(deletedAt AS INTEGER) = 0" 709 - ).run(timestamp, timestamp, id); 712 + conn.run( 713 + "UPDATE items SET deletedAt = ?, updatedAt = ? WHERE id = ? AND CAST(deletedAt AS INTEGER) = 0", 714 + [timestamp, timestamp, id] 715 + ); 710 716 } 711 717 712 718 function deleteUrl(userId, id, profileId = "default") { ··· 717 723 const conn = getConnection(userId, profileId); 718 724 const timestamp = now(); 719 725 720 - conn.prepare("DELETE FROM item_tags WHERE itemId = ?").run(id); 726 + conn.run("DELETE FROM item_tags WHERE itemId = ?", [id]); 721 727 722 728 for (const tagName of tags) { 723 729 const tagId = getOrCreateTagWithConn(conn, tagName, timestamp); 724 - conn.prepare(` 725 - INSERT OR IGNORE INTO item_tags (itemId, tagId, createdAt) 726 - VALUES (?, ?, ?) 727 - `).run(id, tagId, timestamp); 730 + conn.run( 731 + "INSERT OR IGNORE INTO item_tags (itemId, tagId, createdAt) VALUES (?, ?, ?)", 732 + [id, tagId, timestamp] 733 + ); 728 734 } 729 735 730 - conn.prepare("UPDATE items SET updatedAt = ? WHERE id = ?").run(timestamp, id); 736 + conn.run("UPDATE items SET updatedAt = ? WHERE id = ?", [timestamp, id]); 731 737 } 732 738 733 739 function updateUrlTags(userId, id, tags, profileId = "default") { ··· 736 742 737 743 function getSetting(userId, key, profileId = "default") { 738 744 const conn = getConnection(userId, profileId); 739 - const row = conn.prepare("SELECT value FROM settings WHERE key = ?").get(key); 745 + const row = conn.get("SELECT value FROM settings WHERE key = ?", [key]); 740 746 return row ? row.value : null; 741 747 } 742 748 743 749 function setSetting(userId, key, value, profileId = "default") { 744 750 const conn = getConnection(userId, profileId); 745 - conn.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value); 751 + conn.run("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", [key, value]); 746 752 } 747 753 748 754 function closeAllConnections() { 749 - for (const [userId, conn] of connections) { 755 + for (const [connectionKey, conn] of connections) { 750 756 conn.close(); 751 757 } 752 758 connections.clear(); ··· 828 834 ext: ext, 829 835 }); 830 836 831 - conn.prepare(` 832 - INSERT INTO items (id, type, content, metadata, syncId, syncSource, syncedAt, createdAt, updatedAt, deletedAt) 833 - VALUES (?, 'image', ?, ?, '', '', 0, ?, ?, 0) 834 - `).run(itemId, filename, metadata, timestamp, timestamp); 837 + conn.run( 838 + "INSERT INTO items (id, type, content, metadata, syncId, syncSource, syncedAt, createdAt, updatedAt, deletedAt) VALUES (?, 'image', ?, ?, '', '', 0, ?, ?, 0)", 839 + [itemId, filename, metadata, timestamp, timestamp] 840 + ); 835 841 836 842 // Add tags 837 843 for (const tagName of tags) { 838 844 const tagId = getOrCreateTagWithConn(conn, tagName, timestamp); 839 - conn.prepare(` 840 - INSERT OR IGNORE INTO item_tags (itemId, tagId, createdAt) 841 - VALUES (?, ?, ?) 842 - `).run(itemId, tagId, timestamp); 845 + conn.run( 846 + "INSERT OR IGNORE INTO item_tags (itemId, tagId, createdAt) VALUES (?, ?, ?)", 847 + [itemId, tagId, timestamp] 848 + ); 843 849 } 844 850 845 851 return itemId; ··· 848 854 function getImages(userId, profileId = "default") { 849 855 const conn = getConnection(userId, profileId); 850 856 851 - const items = conn.prepare(` 852 - SELECT id, content, metadata, createdAt, updatedAt 853 - FROM items 854 - WHERE type = 'image' AND CAST(deletedAt AS INTEGER) = 0 855 - ORDER BY createdAt DESC 856 - `).all(); 857 - 858 - const getTagsStmt = conn.prepare(` 859 - SELECT t.name 860 - FROM tags t 861 - JOIN item_tags it ON t.id = it.tagId 862 - WHERE it.itemId = ? 863 - `); 857 + const items = conn.all( 858 + "SELECT id, content, metadata, createdAt, updatedAt FROM items WHERE type = 'image' AND CAST(deletedAt AS INTEGER) = 0 ORDER BY createdAt DESC" 859 + ); 864 860 865 861 return items.map((row) => { 866 862 const metadata = row.metadata ? JSON.parse(row.metadata) : {}; ··· 871 867 size: metadata.size, 872 868 createdAt: toTimestamp(row.createdAt), 873 869 updatedAt: toTimestamp(row.updatedAt), 874 - tags: getTagsStmt.all(row.id).map((t) => t.name), 870 + tags: conn.all( 871 + "SELECT t.name FROM tags t JOIN item_tags it ON t.id = it.tagId WHERE it.itemId = ?", 872 + [row.id] 873 + ).map((t) => t.name), 875 874 }; 876 875 }); 877 876 } ··· 879 878 function getImageById(userId, itemId, profileId = "default") { 880 879 const conn = getConnection(userId, profileId); 881 880 882 - const row = conn.prepare(` 883 - SELECT id, content, metadata, createdAt, updatedAt 884 - FROM items 885 - WHERE id = ? AND type = 'image' AND CAST(deletedAt AS INTEGER) = 0 886 - `).get(itemId); 881 + const row = conn.get( 882 + "SELECT id, content, metadata, createdAt, updatedAt FROM items WHERE id = ? AND type = 'image' AND CAST(deletedAt AS INTEGER) = 0", 883 + [itemId] 884 + ); 887 885 888 886 if (!row) return null; 889 887 ··· 908 906 909 907 // Soft-delete the item record (same as deleteItem) 910 908 const timestamp = now(); 911 - conn.prepare( 912 - "UPDATE items SET deletedAt = ?, updatedAt = ? WHERE id = ? AND CAST(deletedAt AS INTEGER) = 0" 913 - ).run(timestamp, timestamp, itemId); 909 + conn.run( 910 + "UPDATE items SET deletedAt = ?, updatedAt = ? WHERE id = ? AND CAST(deletedAt AS INTEGER) = 0", 911 + [timestamp, timestamp, itemId] 912 + ); 914 913 } 915 914 916 915 /** ··· 936 935 937 936 query += " ORDER BY updatedAt ASC"; 938 937 939 - const items = conn.prepare(query).all(...params); 940 - 941 - const getTagsStmt = conn.prepare(` 942 - SELECT t.name 943 - FROM tags t 944 - JOIN item_tags it ON t.id = it.tagId 945 - WHERE it.itemId = ? 946 - `); 938 + const items = conn.all(query, params); 947 939 948 940 return items.map((row) => { 949 941 const result = { ··· 953 945 createdAt: toTimestamp(row.createdAt), 954 946 updatedAt: toTimestamp(row.updatedAt), 955 947 deletedAt: toTimestamp(row.deletedAt), 956 - tags: getTagsStmt.all(row.id).map((t) => t.name), 948 + tags: conn.all( 949 + "SELECT t.name FROM tags t JOIN item_tags it ON t.id = it.tagId WHERE it.itemId = ?", 950 + [row.id] 951 + ).map((t) => t.name), 957 952 }; 958 953 if (row.metadata) { 959 954 result.metadata = JSON.parse(row.metadata); ··· 968 963 function getItemById(userId, itemId, profileId = "default") { 969 964 const conn = getConnection(userId, profileId); 970 965 971 - const row = conn.prepare(` 972 - SELECT id, type, content, metadata, createdAt, updatedAt 973 - FROM items 974 - WHERE id = ? AND CAST(deletedAt AS INTEGER) = 0 975 - `).get(itemId); 966 + const row = conn.get( 967 + "SELECT id, type, content, metadata, createdAt, updatedAt FROM items WHERE id = ? AND CAST(deletedAt AS INTEGER) = 0", 968 + [itemId] 969 + ); 976 970 977 971 if (!row) return null; 978 - 979 - const getTagsStmt = conn.prepare(` 980 - SELECT t.name 981 - FROM tags t 982 - JOIN item_tags it ON t.id = it.tagId 983 - WHERE it.itemId = ? 984 - `); 985 972 986 973 const result = { 987 974 id: row.id, ··· 989 976 content: row.content, 990 977 createdAt: toTimestamp(row.createdAt), 991 978 updatedAt: toTimestamp(row.updatedAt), 992 - tags: getTagsStmt.all(row.id).map((t) => t.name), 979 + tags: conn.all( 980 + "SELECT t.name FROM tags t JOIN item_tags it ON t.id = it.tagId WHERE it.itemId = ?", 981 + [row.id] 982 + ).map((t) => t.name), 993 983 }; 994 984 if (row.metadata) { 995 985 result.metadata = JSON.parse(row.metadata); ··· 1008 998 let totalRemoved = 0; 1009 999 1010 1000 // --- Deduplicate url/text items by (type, content) --- 1011 - const dupGroups = conn.prepare(` 1001 + const dupGroups = conn.all(` 1012 1002 SELECT type, content, COUNT(*) as cnt 1013 1003 FROM items 1014 1004 WHERE CAST(deletedAt AS INTEGER) = 0 AND type IN ('url', 'text') AND content IS NOT NULL AND content != '' 1015 1005 GROUP BY type, content 1016 1006 HAVING cnt > 1 1017 - `).all(); 1007 + `); 1018 1008 1019 1009 for (const group of dupGroups) { 1020 - const items = conn.prepare(` 1021 - SELECT id, syncId, updatedAt 1022 - FROM items 1023 - WHERE type = ? AND content = ? AND CAST(deletedAt AS INTEGER) = 0 1024 - ORDER BY 1025 - CASE WHEN syncId IS NOT NULL AND syncId != '' THEN 0 ELSE 1 END, 1026 - updatedAt DESC 1027 - `).all(group.type, group.content); 1010 + const items = conn.all( 1011 + `SELECT id, syncId, updatedAt 1012 + FROM items 1013 + WHERE type = ? AND content = ? AND CAST(deletedAt AS INTEGER) = 0 1014 + ORDER BY 1015 + CASE WHEN syncId IS NOT NULL AND syncId != '' THEN 0 ELSE 1 END, 1016 + updatedAt DESC`, 1017 + [group.type, group.content] 1018 + ); 1028 1019 1029 1020 // Keep first (best), delete the rest 1030 1021 for (let i = 1; i < items.length; i++) { 1031 - conn.prepare("DELETE FROM item_tags WHERE itemId = ?").run(items[i].id); 1032 - conn.prepare("DELETE FROM items WHERE id = ?").run(items[i].id); 1022 + conn.run("DELETE FROM item_tags WHERE itemId = ?", [items[i].id]); 1023 + conn.run("DELETE FROM items WHERE id = ?", [items[i].id]); 1033 1024 totalRemoved++; 1034 1025 } 1035 1026 } 1036 1027 1037 1028 // --- Deduplicate tagsets by sorted tag names --- 1038 - const tagsets = conn.prepare(` 1039 - SELECT id, syncId, updatedAt 1040 - FROM items 1041 - WHERE type = 'tagset' AND CAST(deletedAt AS INTEGER) = 0 1042 - `).all(); 1043 - 1044 - const getTagNamesStmt = conn.prepare(` 1045 - SELECT t.name FROM tags t 1046 - JOIN item_tags it ON t.id = it.tagId 1047 - WHERE it.itemId = ? 1048 - ORDER BY t.name 1049 - `); 1029 + const tagsets = conn.all( 1030 + "SELECT id, syncId, updatedAt FROM items WHERE type = 'tagset' AND CAST(deletedAt AS INTEGER) = 0" 1031 + ); 1050 1032 1051 1033 // Group tagsets by their sorted tag string 1052 1034 const tagsetGroups = new Map(); 1053 1035 for (const ts of tagsets) { 1054 - const tagNames = getTagNamesStmt.all(ts.id).map(t => t.name).join('\0'); 1036 + const tagNames = conn.all( 1037 + "SELECT t.name FROM tags t JOIN item_tags it ON t.id = it.tagId WHERE it.itemId = ? ORDER BY t.name", 1038 + [ts.id] 1039 + ).map(t => t.name).join('\0'); 1055 1040 if (!tagsetGroups.has(tagNames)) { 1056 1041 tagsetGroups.set(tagNames, []); 1057 1042 } ··· 1071 1056 1072 1057 // Keep first, delete rest 1073 1058 for (let i = 1; i < items.length; i++) { 1074 - conn.prepare("DELETE FROM item_tags WHERE itemId = ?").run(items[i].id); 1075 - conn.prepare("DELETE FROM items WHERE id = ?").run(items[i].id); 1059 + conn.run("DELETE FROM item_tags WHERE itemId = ?", [items[i].id]); 1060 + conn.run("DELETE FROM items WHERE id = ?", [items[i].id]); 1076 1061 totalRemoved++; 1077 1062 } 1078 1063 }
+125
backend/server/sql/better-sqlite3-adapter.js
··· 1 + /** 2 + * better-sqlite3 Adapter Implementation 3 + * 4 + * Wraps the better-sqlite3 library to conform to the SqlAdapter interface. 5 + * Provides statement caching for improved performance. 6 + */ 7 + 8 + const Database = require("better-sqlite3"); 9 + 10 + /** 11 + * @typedef {import('./types').SqlAdapter} SqlAdapter 12 + * @typedef {import('./types').SqlAdapterFactory} SqlAdapterFactory 13 + * @typedef {import('./types').RunResult} RunResult 14 + */ 15 + 16 + /** 17 + * @implements {SqlAdapter} 18 + */ 19 + class BetterSqlite3Adapter { 20 + /** 21 + * @param {import('better-sqlite3').Database} db 22 + */ 23 + constructor(db) { 24 + /** @private */ 25 + this.db = db; 26 + /** @private @type {Map<string, import('better-sqlite3').Statement>} */ 27 + this.stmtCache = new Map(); 28 + } 29 + 30 + /** 31 + * @param {string} sql 32 + */ 33 + exec(sql) { 34 + this.db.exec(sql); 35 + } 36 + 37 + /** 38 + * @param {string} sql 39 + * @param {unknown[]} [params=[]] 40 + * @returns {RunResult} 41 + */ 42 + run(sql, params = []) { 43 + const stmt = this.getOrPrepare(sql); 44 + const result = stmt.run(...params); 45 + return { 46 + changes: result.changes, 47 + lastInsertRowid: Number(result.lastInsertRowid), 48 + }; 49 + } 50 + 51 + /** 52 + * @template T 53 + * @param {string} sql 54 + * @param {unknown[]} [params=[]] 55 + * @returns {T|null} 56 + */ 57 + get(sql, params = []) { 58 + const stmt = this.getOrPrepare(sql); 59 + return stmt.get(...params) ?? null; 60 + } 61 + 62 + /** 63 + * @template T 64 + * @param {string} sql 65 + * @param {unknown[]} [params=[]] 66 + * @returns {T[]} 67 + */ 68 + all(sql, params = []) { 69 + const stmt = this.getOrPrepare(sql); 70 + return stmt.all(...params); 71 + } 72 + 73 + /** 74 + * @template T 75 + * @param {function(): T} fn 76 + * @returns {T} 77 + */ 78 + transaction(fn) { 79 + return this.db.transaction(fn)(); 80 + } 81 + 82 + close() { 83 + this.stmtCache.clear(); 84 + this.db.close(); 85 + } 86 + 87 + /** 88 + * @private 89 + * @param {string} sql 90 + * @returns {import('better-sqlite3').Statement} 91 + */ 92 + getOrPrepare(sql) { 93 + let stmt = this.stmtCache.get(sql); 94 + if (!stmt) { 95 + stmt = this.db.prepare(sql); 96 + this.stmtCache.set(sql, stmt); 97 + } 98 + return stmt; 99 + } 100 + } 101 + 102 + /** 103 + * @type {SqlAdapterFactory} 104 + */ 105 + const factory = { 106 + /** 107 + * @param {string} path 108 + * @param {{ readonly?: boolean }} [options] 109 + * @returns {SqlAdapter} 110 + */ 111 + open(path, options) { 112 + const dbOptions = options?.readonly ? { readonly: true } : {}; 113 + const db = new Database(path, dbOptions); 114 + return new BetterSqlite3Adapter(db); 115 + }, 116 + 117 + /** 118 + * @param {SqlAdapter} adapter 119 + */ 120 + init(adapter) { 121 + adapter.exec("PRAGMA journal_mode = WAL"); 122 + }, 123 + }; 124 + 125 + module.exports = { factory, BetterSqlite3Adapter };
+26
backend/server/sql/index.js
··· 1 + /** 2 + * SQL Adapter Factory 3 + * 4 + * Selects the appropriate SQL adapter based on environment configuration. 5 + * Currently only supports better-sqlite3; future adapters can be added here. 6 + */ 7 + 8 + const { factory: betterSqlite3Factory } = require("./better-sqlite3-adapter"); 9 + 10 + // Environment variable to select SQL adapter (future use) 11 + const SQL_ADAPTER = process.env.SQL_ADAPTER || "better-sqlite3"; 12 + 13 + function getFactory() { 14 + switch (SQL_ADAPTER) { 15 + case "better-sqlite3": 16 + return betterSqlite3Factory; 17 + // Future: case "do-sqlite": return doSqliteFactory; 18 + default: 19 + console.warn(`Unknown SQL_ADAPTER "${SQL_ADAPTER}", falling back to better-sqlite3`); 20 + return betterSqlite3Factory; 21 + } 22 + } 23 + 24 + const sqlFactory = getFactory(); 25 + 26 + module.exports = { sqlFactory };
+26
backend/server/sql/types.js
··· 1 + /** 2 + * SQL Abstraction Layer Types 3 + * 4 + * Abstracts database operations to support multiple backends: 5 + * - better-sqlite3 (Node.js, current) 6 + * - Cloudflare Durable Objects SQLite (future) 7 + * 8 + * @typedef {Object} RunResult 9 + * @property {number} changes - Number of rows changed by the statement 10 + * @property {number} [lastInsertRowid] - Row ID of the last inserted row (if applicable) 11 + * 12 + * @typedef {Object} SqlAdapter 13 + * @property {function(string): void} exec - Execute raw SQL (DDL, multi-statement) 14 + * @property {function(string, unknown[]?): RunResult} run - Execute parameterized write 15 + * @property {function(string, unknown[]?): (Object|null)} get - Query single row 16 + * @property {function(string, unknown[]?): Object[]} all - Query all rows 17 + * @property {function(function(): T): T} transaction - Execute within transaction 18 + * @property {function(): void} close - Close connection 19 + * 20 + * @typedef {Object} SqlAdapterFactory 21 + * @property {function(string, {readonly?: boolean}?): SqlAdapter} open - Open database connection 22 + * @property {function(SqlAdapter): void} init - Platform-specific initialization 23 + */ 24 + 25 + // Export empty object - types are defined via JSDoc above 26 + module.exports = {};
+16 -16
backend/server/test.js
··· 613 613 describe("Sync Columns Schema", () => { 614 614 it("should have sync columns in schema", () => { 615 615 const conn = db.getConnection(TEST_USER_ID); 616 - const tableInfo = conn.prepare("PRAGMA table_info(items)").all(); 616 + const tableInfo = conn.all("PRAGMA table_info(items)"); 617 617 const columnNames = tableInfo.map((col) => col.name); 618 618 619 619 assert.ok(columnNames.includes("syncId"), "should have syncId column"); ··· 623 623 624 624 it("should have sync_id index", () => { 625 625 const conn = db.getConnection(TEST_USER_ID); 626 - const indexes = conn.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='items'").all(); 626 + const indexes = conn.all("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='items'"); 627 627 const indexNames = indexes.map((idx) => idx.name); 628 628 629 629 assert.ok(indexNames.includes("idx_items_syncId"), "should have idx_items_syncId index"); ··· 684 684 const conn = freshDb.getConnection("legacy-user"); 685 685 686 686 // Verify columns were renamed 687 - const itemCols = conn.prepare("PRAGMA table_info(items)").all().map(c => c.name); 687 + const itemCols = conn.all("PRAGMA table_info(items)").map(c => c.name); 688 688 assert.ok(itemCols.includes("syncId"), "items.sync_id should be renamed to syncId"); 689 689 assert.ok(itemCols.includes("createdAt"), "items.created_at should be renamed to createdAt"); 690 690 assert.ok(itemCols.includes("deletedAt"), "items.deleted_at should be renamed to deletedAt"); 691 691 assert.ok(!itemCols.includes("sync_id"), "items should not have snake_case sync_id"); 692 692 693 - const tagCols = conn.prepare("PRAGMA table_info(tags)").all().map(c => c.name); 693 + const tagCols = conn.all("PRAGMA table_info(tags)").map(c => c.name); 694 694 assert.ok(tagCols.includes("lastUsed"), "tags.last_used_at should be renamed to lastUsed"); 695 695 assert.ok(tagCols.includes("frecencyScore"), "tags.frecency_score should be renamed to frecencyScore"); 696 696 697 - const itCols = conn.prepare("PRAGMA table_info(item_tags)").all().map(c => c.name); 697 + const itCols = conn.all("PRAGMA table_info(item_tags)").map(c => c.name); 698 698 assert.ok(itCols.includes("itemId"), "item_tags.item_id should be renamed to itemId"); 699 699 assert.ok(itCols.includes("tagId"), "item_tags.tag_id should be renamed to tagId"); 700 700 ··· 776 776 const conn = freshDb.getConnection("rebuild-user"); 777 777 778 778 // Verify all columns are camelCase after rebuild 779 - const itemCols = new Set(conn.prepare("PRAGMA table_info(items)").all().map(c => c.name)); 779 + const itemCols = new Set(conn.all("PRAGMA table_info(items)").map(c => c.name)); 780 780 assert.ok(itemCols.has("createdAt"), "items should have createdAt"); 781 781 assert.ok(itemCols.has("deletedAt"), "items should have deletedAt"); 782 782 assert.ok(itemCols.has("syncId"), "items should have syncId"); 783 783 784 - const tagCols = new Set(conn.prepare("PRAGMA table_info(tags)").all().map(c => c.name)); 784 + const tagCols = new Set(conn.all("PRAGMA table_info(tags)").map(c => c.name)); 785 785 assert.ok(tagCols.has("lastUsed"), "tags should have lastUsed (was last_used)"); 786 786 assert.ok(tagCols.has("frecencyScore"), "tags should have frecencyScore"); 787 787 assert.ok(tagCols.has("createdAt"), "tags should have createdAt"); 788 788 // Verify tags.id is now TEXT (was INTEGER AUTOINCREMENT) 789 - const tagIdCol = conn.prepare("PRAGMA table_info(tags)").all().find(c => c.name === "id"); 789 + const tagIdCol = conn.all("PRAGMA table_info(tags)").find(c => c.name === "id"); 790 790 assert.ok(tagIdCol, "tags should have id column"); 791 791 assert.strictEqual(tagIdCol.type, "TEXT", "tags.id should be TEXT after rebuild"); 792 792 793 - const itCols = new Set(conn.prepare("PRAGMA table_info(item_tags)").all().map(c => c.name)); 793 + const itCols = new Set(conn.all("PRAGMA table_info(item_tags)").map(c => c.name)); 794 794 assert.ok(itCols.has("itemId"), "item_tags should have itemId"); 795 795 assert.ok(itCols.has("tagId"), "item_tags should have tagId"); 796 796 // Verify item_tags.tagId is now TEXT (was INTEGER) 797 - const itTagIdCol = conn.prepare("PRAGMA table_info(item_tags)").all().find(c => c.name === "tagId"); 797 + const itTagIdCol = conn.all("PRAGMA table_info(item_tags)").find(c => c.name === "tagId"); 798 798 assert.ok(itTagIdCol, "item_tags should have tagId column"); 799 799 assert.strictEqual(itTagIdCol.type, "TEXT", "item_tags.tagId should be TEXT after rebuild"); 800 800 ··· 865 865 const conn = freshDb.getConnection("missing-cols-user"); 866 866 867 867 // Verify all required columns exist 868 - const tagCols = new Set(conn.prepare("PRAGMA table_info(tags)").all().map(c => c.name)); 868 + const tagCols = new Set(conn.all("PRAGMA table_info(tags)").map(c => c.name)); 869 869 assert.ok(tagCols.has("lastUsed"), "tags should have lastUsed after safety net"); 870 870 assert.ok(tagCols.has("frecencyScore"), "tags should have frecencyScore after safety net"); 871 871 assert.ok(tagCols.has("createdAt"), "tags should have createdAt after safety net"); ··· 952 952 // DB values are numeric (ISO converted to Unix ms, float strings to integers) 953 953 // but may remain TEXT type due to column TEXT affinity from legacy schema. 954 954 // The toTimestamp() safety net in response code ensures API returns integers. 955 - const row1 = conn.prepare("SELECT CAST(createdAt AS INTEGER) as v FROM items WHERE id = 'iso-1'").get(); 955 + const row1 = conn.get("SELECT CAST(createdAt AS INTEGER) as v FROM items WHERE id = 'iso-1'"); 956 956 assert.ok(row1.v > 1700000000000, `ISO timestamp should be Unix ms, got ${row1.v}`); 957 957 958 - const row2 = conn.prepare("SELECT CAST(createdAt AS INTEGER) as v, CAST(deletedAt AS INTEGER) as dv FROM items WHERE id = 'num-1'").get(); 958 + const row2 = conn.get("SELECT CAST(createdAt AS INTEGER) as v, CAST(deletedAt AS INTEGER) as dv FROM items WHERE id = 'num-1'"); 959 959 assert.strictEqual(row2.v, 1769559596558, `Should preserve value: ${row2.v}`); 960 960 assert.strictEqual(row2.dv, 1769509253170); 961 961 ··· 1046 1046 const conn = freshDb.getConnection("prod-user"); 1047 1047 1048 1048 // Verify columns were renamed to camelCase 1049 - const itemCols = new Set(conn.prepare("PRAGMA table_info(items)").all().map(c => c.name)); 1049 + const itemCols = new Set(conn.all("PRAGMA table_info(items)").map(c => c.name)); 1050 1050 assert.ok(itemCols.has("syncId"), "sync_id should be renamed to syncId"); 1051 1051 assert.ok(itemCols.has("createdAt"), "created_at should be renamed to createdAt"); 1052 1052 assert.ok(itemCols.has("deletedAt"), "deleted_at should be renamed to deletedAt"); 1053 1053 1054 - const tagCols = new Set(conn.prepare("PRAGMA table_info(tags)").all().map(c => c.name)); 1054 + const tagCols = new Set(conn.all("PRAGMA table_info(tags)").map(c => c.name)); 1055 1055 assert.ok(tagCols.has("lastUsed"), "last_used_at should be renamed to lastUsed"); 1056 1056 assert.ok(tagCols.has("frecencyScore"), "frecency_score should be renamed to frecencyScore"); 1057 1057 1058 - const itCols = new Set(conn.prepare("PRAGMA table_info(item_tags)").all().map(c => c.name)); 1058 + const itCols = new Set(conn.all("PRAGMA table_info(item_tags)").map(c => c.name)); 1059 1059 assert.ok(itCols.has("itemId"), "item_id should be renamed to itemId"); 1060 1060 assert.ok(itCols.has("tagId"), "tag_id should be renamed to tagId"); 1061 1061
+52 -52
backend/server/users.js
··· 1 - const Database = require("better-sqlite3"); 1 + const { sqlFactory } = require("./sql"); 2 2 const crypto = require("crypto"); 3 3 const path = require("path"); 4 4 const fs = require("fs"); ··· 15 15 fs.mkdirSync(DATA_DIR, { recursive: true }); 16 16 } 17 17 18 - systemDb = new Database(SYSTEM_DB_PATH); 19 - systemDb.pragma("journal_mode = WAL"); 18 + systemDb = sqlFactory.open(SYSTEM_DB_PATH); 19 + sqlFactory.init(systemDb); 20 20 21 21 // Initialize users table 22 22 systemDb.exec(` ··· 62 62 const db = getSystemDb(); 63 63 64 64 // Check if user already exists 65 - const existing = db.prepare("SELECT id FROM users WHERE id = ?").get(userId); 65 + const existing = db.get("SELECT id FROM users WHERE id = ?", [userId]); 66 66 if (existing) { 67 67 throw new Error(`User '${userId}' already exists`); 68 68 } ··· 71 71 const apiKeyHash = hashApiKey(apiKey); 72 72 const timestamp = new Date().toISOString(); 73 73 74 - db.prepare(` 75 - INSERT INTO users (id, api_key_hash, created_at) 76 - VALUES (?, ?, ?) 77 - `).run(userId, apiKeyHash, timestamp); 74 + db.run( 75 + "INSERT INTO users (id, api_key_hash, created_at) VALUES (?, ?, ?)", 76 + [userId, apiKeyHash, timestamp] 77 + ); 78 78 79 79 // Return the raw key - this is the only time it's available 80 80 return { userId, apiKey }; ··· 87 87 function createUserWithKey(userId, existingKey) { 88 88 const db = getSystemDb(); 89 89 90 - const existingUser = db.prepare("SELECT id FROM users WHERE id = ?").get(userId); 90 + const existingUser = db.get("SELECT id FROM users WHERE id = ?", [userId]); 91 91 if (existingUser) { 92 92 throw new Error(`User '${userId}' already exists`); 93 93 } ··· 95 95 const apiKeyHash = hashApiKey(existingKey); 96 96 const timestamp = new Date().toISOString(); 97 97 98 - db.prepare(` 99 - INSERT INTO users (id, api_key_hash, created_at) 100 - VALUES (?, ?, ?) 101 - `).run(userId, apiKeyHash, timestamp); 98 + db.run( 99 + "INSERT INTO users (id, api_key_hash, created_at) VALUES (?, ?, ?)", 100 + [userId, apiKeyHash, timestamp] 101 + ); 102 102 103 103 return { userId }; 104 104 } ··· 113 113 const db = getSystemDb(); 114 114 const apiKeyHash = hashApiKey(apiKey); 115 115 116 - const row = db.prepare("SELECT id FROM users WHERE api_key_hash = ?").get(apiKeyHash); 116 + const row = db.get("SELECT id FROM users WHERE api_key_hash = ?", [apiKeyHash]); 117 117 return row ? row.id : null; 118 118 } 119 119 ··· 122 122 */ 123 123 function deleteUser(userId) { 124 124 const db = getSystemDb(); 125 - db.prepare("DELETE FROM users WHERE id = ?").run(userId); 125 + db.run("DELETE FROM users WHERE id = ?", [userId]); 126 126 } 127 127 128 128 /** ··· 130 130 */ 131 131 function listUsers() { 132 132 const db = getSystemDb(); 133 - return db.prepare("SELECT id, created_at FROM users ORDER BY created_at").all(); 133 + return db.all("SELECT id, created_at FROM users ORDER BY created_at"); 134 134 } 135 135 136 136 /** ··· 140 140 function regenerateApiKey(userId) { 141 141 const db = getSystemDb(); 142 142 143 - const existing = db.prepare("SELECT id FROM users WHERE id = ?").get(userId); 143 + const existing = db.get("SELECT id FROM users WHERE id = ?", [userId]); 144 144 if (!existing) { 145 145 throw new Error(`User '${userId}' does not exist`); 146 146 } ··· 148 148 const apiKey = generateApiKey(); 149 149 const apiKeyHash = hashApiKey(apiKey); 150 150 151 - db.prepare("UPDATE users SET api_key_hash = ? WHERE id = ?").run(apiKeyHash, userId); 151 + db.run("UPDATE users SET api_key_hash = ? WHERE id = ?", [apiKeyHash, userId]); 152 152 153 153 return { userId, apiKey }; 154 154 } ··· 175 175 const db = getSystemDb(); 176 176 177 177 // Check if user exists 178 - const user = db.prepare("SELECT id FROM users WHERE id = ?").get(userId); 178 + const user = db.get("SELECT id FROM users WHERE id = ?", [userId]); 179 179 if (!user) { 180 180 throw new Error(`User '${userId}' does not exist`); 181 181 } ··· 184 184 const slug = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); 185 185 186 186 // Check if profile already exists 187 - const existing = db.prepare( 188 - "SELECT id FROM profiles WHERE user_id = ? AND slug = ?" 189 - ).get(userId, slug); 187 + const existing = db.get( 188 + "SELECT id FROM profiles WHERE user_id = ? AND slug = ?", 189 + [userId, slug] 190 + ); 190 191 if (existing) { 191 192 throw new Error(`Profile '${slug}' already exists for user '${userId}'`); 192 193 } ··· 194 195 const profileId = crypto.randomUUID(); 195 196 const timestamp = new Date().toISOString(); 196 197 197 - db.prepare(` 198 - INSERT INTO profiles (id, user_id, slug, name, created_at, last_used_at) 199 - VALUES (?, ?, ?, ?, ?, ?) 200 - `).run(profileId, userId, slug, name, timestamp, timestamp); 198 + db.run( 199 + "INSERT INTO profiles (id, user_id, slug, name, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?)", 200 + [profileId, userId, slug, name, timestamp, timestamp] 201 + ); 201 202 202 203 return { id: profileId, userId, slug, name, created_at: timestamp, last_used_at: timestamp }; 203 204 } ··· 209 210 */ 210 211 function listProfiles(userId) { 211 212 const db = getSystemDb(); 212 - return db.prepare(` 213 - SELECT id, user_id, slug, name, created_at, last_used_at 214 - FROM profiles 215 - WHERE user_id = ? 216 - ORDER BY last_used_at DESC 217 - `).all(userId); 213 + return db.all( 214 + "SELECT id, user_id, slug, name, created_at, last_used_at FROM profiles WHERE user_id = ? ORDER BY last_used_at DESC", 215 + [userId] 216 + ); 218 217 } 219 218 220 219 /** ··· 225 224 */ 226 225 function getProfile(userId, slug) { 227 226 const db = getSystemDb(); 228 - return db.prepare(` 229 - SELECT id, user_id, slug, name, created_at, last_used_at 230 - FROM profiles 231 - WHERE user_id = ? AND slug = ? 232 - `).get(userId, slug); 227 + return db.get( 228 + "SELECT id, user_id, slug, name, created_at, last_used_at FROM profiles WHERE user_id = ? AND slug = ?", 229 + [userId, slug] 230 + ); 233 231 } 234 232 235 233 /** ··· 240 238 function updateProfileLastUsed(userId, slug) { 241 239 const db = getSystemDb(); 242 240 const timestamp = new Date().toISOString(); 243 - db.prepare(` 244 - UPDATE profiles SET last_used_at = ? WHERE user_id = ? AND slug = ? 245 - `).run(timestamp, userId, slug); 241 + db.run( 242 + "UPDATE profiles SET last_used_at = ? WHERE user_id = ? AND slug = ?", 243 + [timestamp, userId, slug] 244 + ); 246 245 } 247 246 248 247 /** ··· 253 252 */ 254 253 function getProfileById(userId, profileId) { 255 254 const db = getSystemDb(); 256 - return db.prepare(` 257 - SELECT id, user_id, slug, name, created_at, last_used_at 258 - FROM profiles 259 - WHERE user_id = ? AND id = ? 260 - `).get(userId, profileId); 255 + return db.get( 256 + "SELECT id, user_id, slug, name, created_at, last_used_at FROM profiles WHERE user_id = ? AND id = ?", 257 + [userId, profileId] 258 + ); 261 259 } 262 260 263 261 /** ··· 324 322 const db = getSystemDb(); 325 323 326 324 // Verify profile belongs to user 327 - const profile = db.prepare( 328 - "SELECT id, slug FROM profiles WHERE id = ? AND user_id = ?" 329 - ).get(profileId, userId); 325 + const profile = db.get( 326 + "SELECT id, slug FROM profiles WHERE id = ? AND user_id = ?", 327 + [profileId, userId] 328 + ); 330 329 331 330 if (!profile) { 332 331 throw new Error(`Profile '${profileId}' not found for user '${userId}'`); 333 332 } 334 333 335 334 // Delete profile record 336 - db.prepare("DELETE FROM profiles WHERE id = ?").run(profileId); 335 + db.run("DELETE FROM profiles WHERE id = ?", [profileId]); 337 336 338 337 // Note: Profile data directory is NOT deleted here - data is preserved 339 338 // Client should handle profile data cleanup if desired ··· 362 361 } 363 362 364 363 const db = getSystemDb(); 365 - const profiles = db.prepare(` 366 - SELECT id, slug FROM profiles WHERE user_id = ? 367 - `).all(userId); 364 + const profiles = db.all( 365 + "SELECT id, slug FROM profiles WHERE user_id = ?", 366 + [userId] 367 + ); 368 368 369 369 // Handle orphan "default" folder (exists but no profile record) 370 370 const defaultFolder = path.join(profilesDir, "default");