experiments in a post-browser web
10
fork

Configure Feed

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

feat(schema): add schema codegen system for single source of truth

+1320
+3
package.json
··· 69 69 "server:install": "cd backend/server && npm install", 70 70 "server:healthcheck": "cd backend/server && npm run healthcheck", 71 71 "server:deploy": "./scripts/deploy-server.sh", 72 + "//-- Schema Codegen --//": "", 73 + "schema:codegen": "node schema/codegen.js", 74 + "schema:test": "node --test schema/fidelity.test.js", 72 75 "//-- Sync Tests --//": "", 73 76 "test:sync": "node backend/tests/sync-integration.test.js", 74 77 "test:sync:verbose": "VERBOSE=1 node backend/tests/sync-integration.test.js",
+100
schema/README.md
··· 1 + # Schema Codegen 2 + 3 + Single source of truth for sync schema definitions across all backends. 4 + 5 + ## Quick Start 6 + 7 + ```bash 8 + # Generate code from schema 9 + node schema/codegen.js 10 + # or 11 + yarn schema:codegen 12 + 13 + # Run fidelity tests 14 + node --test schema/fidelity.test.js 15 + # or 16 + yarn schema:test 17 + ``` 18 + 19 + ## Files 20 + 21 + | File | Purpose | 22 + |------|---------| 23 + | `v1.json` | Canonical schema definition | 24 + | `codegen.js` | Code generator script | 25 + | `fidelity.test.js` | Cross-platform schema validation tests | 26 + | `generated/` | Generated output (do not edit) | 27 + 28 + ## Generated Files 29 + 30 + | File | Usage | 31 + |------|-------| 32 + | `sqlite-full.sql` | Full schema for desktop (includes local-only columns) | 33 + | `sqlite-sync.sql` | Sync-only schema for server | 34 + | `types.ts` | TypeScript interfaces | 35 + | `types.rs` | Rust structs with serde attributes | 36 + | `validate.js` | Schema validator function | 37 + 38 + ## Schema Structure 39 + 40 + The schema defines three core sync tables: 41 + 42 + ### items 43 + Unified content storage for URLs, text notes, tagsets, and images. 44 + 45 + **Sync columns**: id, type, content, mimeType, metadata, syncId, syncSource, syncedAt, createdAt, updatedAt, deletedAt, starred, archived 46 + 47 + **Local-only columns**: visitCount, lastVisitAt, frecencyScore, title, domain, favicon 48 + 49 + ### tags 50 + Tag definitions with frecency tracking. 51 + 52 + **Sync columns**: id, name, frequency, lastUsed, frecencyScore, createdAt, updatedAt 53 + 54 + **Local-only columns**: slug, color, parentId, description, metadata 55 + 56 + ### item_tags 57 + Junction table linking items to tags. 58 + 59 + **Sync columns**: itemId, tagId, createdAt 60 + 61 + **Local-only columns**: id (desktop only - server uses composite PK) 62 + 63 + ## Validation 64 + 65 + The generated `validate.js` provides two functions: 66 + 67 + ```javascript 68 + import { validateSyncSchema, assertValidSyncSchema } from './generated/validate.js'; 69 + 70 + // Non-throwing validation 71 + const result = validateSyncSchema((table) => { 72 + // Return array of column names for the table 73 + return db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name); 74 + }); 75 + 76 + if (!result.valid) { 77 + console.error('Missing columns:', result.missing); 78 + } 79 + 80 + // Throwing validation 81 + assertValidSyncSchema((table) => getColumnNames(table)); 82 + ``` 83 + 84 + ## Adding/Modifying Schema 85 + 86 + 1. Edit `v1.json` 87 + 2. Run `node schema/codegen.js` 88 + 3. Run `node --test schema/fidelity.test.js` to verify backends match 89 + 4. Update backend implementations if tests fail 90 + 5. Commit all changes together 91 + 92 + ## Sync vs Local Columns 93 + 94 + - `"sync": true` - Column participates in cross-device sync 95 + - `"sync": false` - Column is local-only (not included in sync payloads) 96 + 97 + Local-only columns are typically: 98 + - Computed values (frecencyScore) 99 + - UI state (visitCount, lastVisitAt) 100 + - Platform-specific data (desktop-only metadata)
+317
schema/codegen.js
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Schema Codegen 4 + * 5 + * Generates SQLite, TypeScript, and Rust code from schema/v1.json 6 + * 7 + * Usage: 8 + * node schema/codegen.js 9 + * yarn schema:codegen 10 + */ 11 + 12 + import { readFileSync, writeFileSync, mkdirSync } from 'fs'; 13 + import { dirname, join } from 'path'; 14 + import { fileURLToPath } from 'url'; 15 + 16 + const __filename = fileURLToPath(import.meta.url); 17 + const __dirname = dirname(__filename); 18 + 19 + // Type mapping 20 + const typeMap = { 21 + sqlite: { 22 + text: 'TEXT', 23 + integer: 'INTEGER', 24 + real: 'REAL', 25 + boolean: 'INTEGER', 26 + }, 27 + typescript: { 28 + text: 'string', 29 + integer: 'number', 30 + real: 'number', 31 + boolean: 'number', // SQLite stores as 0/1 32 + }, 33 + rust: { 34 + text: 'String', 35 + integer: 'i64', 36 + real: 'f64', 37 + boolean: 'i32', 38 + }, 39 + }; 40 + 41 + // Generate SQLite CREATE TABLE statements 42 + function generateSqlite(schema, syncOnly = false) { 43 + const lines = []; 44 + lines.push('-- Generated by schema/codegen.js'); 45 + lines.push(`-- Schema version: ${schema.version}`); 46 + lines.push(`-- Generated: ${new Date().toISOString()}`); 47 + lines.push('-- DO NOT EDIT - regenerate with: yarn schema:codegen'); 48 + lines.push(''); 49 + 50 + for (const [tableName, table] of Object.entries(schema.tables)) { 51 + lines.push(`-- ${table.description || tableName}`); 52 + lines.push(`CREATE TABLE IF NOT EXISTS ${tableName} (`); 53 + 54 + const columnDefs = []; 55 + for (const [colName, col] of Object.entries(table.columns)) { 56 + // Skip non-sync columns if syncOnly 57 + if (syncOnly && col.sync === false) continue; 58 + 59 + let def = ` ${colName} ${typeMap.sqlite[col.type]}`; 60 + 61 + if (col.primary_key) def += ' PRIMARY KEY'; 62 + if (col.not_null) def += ' NOT NULL'; 63 + if (col.unique) def += ' UNIQUE'; 64 + if (col.check) def += ` CHECK(${col.check})`; 65 + if (col.default !== undefined) { 66 + def += ` DEFAULT ${col.default}`; 67 + } 68 + 69 + columnDefs.push(def); 70 + } 71 + 72 + lines.push(columnDefs.join(',\n')); 73 + lines.push(');'); 74 + lines.push(''); 75 + 76 + // Indexes 77 + for (const idx of (table.indexes || [])) { 78 + // Skip non-sync indexes if syncOnly 79 + if (syncOnly && idx.sync === false) continue; 80 + 81 + const unique = idx.unique ? 'UNIQUE ' : ''; 82 + const cols = idx.columns.join(', '); 83 + const order = idx.order ? ` ${idx.order}` : ''; 84 + lines.push(`CREATE ${unique}INDEX IF NOT EXISTS ${idx.name} ON ${tableName}(${cols}${order});`); 85 + } 86 + lines.push(''); 87 + } 88 + 89 + return lines.join('\n'); 90 + } 91 + 92 + // Generate TypeScript interfaces 93 + function generateTypescript(schema, syncOnly = false) { 94 + const lines = []; 95 + lines.push('/**'); 96 + lines.push(' * Generated by schema/codegen.js'); 97 + lines.push(` * Schema version: ${schema.version}`); 98 + lines.push(` * Generated: ${new Date().toISOString()}`); 99 + lines.push(' * DO NOT EDIT - regenerate with: yarn schema:codegen'); 100 + lines.push(' */'); 101 + lines.push(''); 102 + 103 + for (const [tableName, table] of Object.entries(schema.tables)) { 104 + const interfaceName = tableName.charAt(0).toUpperCase() + 105 + tableName.slice(1).replace(/_./g, x => x[1].toUpperCase()); 106 + 107 + lines.push(`/** ${table.description || tableName} */`); 108 + lines.push(`export interface Schema${interfaceName} {`); 109 + 110 + for (const [colName, col] of Object.entries(table.columns)) { 111 + // Skip non-sync columns if syncOnly 112 + if (syncOnly && col.sync === false) continue; 113 + 114 + const tsType = typeMap.typescript[col.type]; 115 + const nullable = col.nullable ? ' | null' : ''; 116 + const comment = col.description ? ` /** ${col.description} */` : ''; 117 + 118 + if (comment) lines.push(comment); 119 + lines.push(` ${colName}: ${tsType}${nullable};`); 120 + } 121 + 122 + lines.push('}'); 123 + lines.push(''); 124 + } 125 + 126 + // Generate table names type 127 + lines.push('/** Valid sync table names */'); 128 + lines.push('export type SchemaSyncTableName = ' + 129 + Object.keys(schema.tables).map(t => `'${t}'`).join(' | ') + ';'); 130 + lines.push(''); 131 + 132 + // Generate validation constants 133 + lines.push('/** Required sync columns by table */'); 134 + lines.push('export const REQUIRED_SYNC_COLUMNS: Record<SchemaSyncTableName, string[]> = {'); 135 + for (const [tableName, cols] of Object.entries(schema.validation.required_sync_columns)) { 136 + lines.push(` ${tableName}: ${JSON.stringify(cols)},`); 137 + } 138 + lines.push('};'); 139 + lines.push(''); 140 + 141 + return lines.join('\n'); 142 + } 143 + 144 + // Generate Rust structs 145 + function generateRust(schema, syncOnly = false) { 146 + const lines = []; 147 + lines.push('// Generated by schema/codegen.js'); 148 + lines.push(`// Schema version: ${schema.version}`); 149 + lines.push(`// Generated: ${new Date().toISOString()}`); 150 + lines.push('// DO NOT EDIT - regenerate with: yarn schema:codegen'); 151 + lines.push(''); 152 + lines.push('use serde::{Deserialize, Serialize};'); 153 + lines.push(''); 154 + 155 + for (const [tableName, table] of Object.entries(schema.tables)) { 156 + const structName = tableName.split('_') 157 + .map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(''); 158 + 159 + lines.push(`/// ${table.description || tableName}`); 160 + lines.push('#[derive(Debug, Clone, Serialize, Deserialize)]'); 161 + lines.push(`pub struct Schema${structName} {`); 162 + 163 + for (const [colName, col] of Object.entries(table.columns)) { 164 + // Skip non-sync columns if syncOnly 165 + if (syncOnly && col.sync === false) continue; 166 + 167 + let rustType = typeMap.rust[col.type]; 168 + if (col.nullable) { 169 + rustType = `Option<${rustType}>`; 170 + } 171 + 172 + // Convert camelCase to snake_case for Rust 173 + let snakeName = colName.replace(/[A-Z]/g, m => `_${m.toLowerCase()}`); 174 + 175 + // Handle Rust reserved keywords 176 + const rustReserved = ['type', 'struct', 'enum', 'fn', 'mod', 'use', 'pub', 'self', 'super', 'crate']; 177 + if (rustReserved.includes(snakeName)) { 178 + snakeName = `r#${snakeName}`; 179 + } 180 + 181 + // Add serde rename if names differ (compare without r# prefix) 182 + const cleanSnakeName = snakeName.replace(/^r#/, ''); 183 + if (cleanSnakeName !== colName) { 184 + lines.push(` #[serde(rename = "${colName}")]`); 185 + } 186 + 187 + lines.push(` /// ${col.description || colName}`); 188 + lines.push(` pub ${snakeName}: ${rustType},`); 189 + } 190 + 191 + lines.push('}'); 192 + lines.push(''); 193 + } 194 + 195 + return lines.join('\n'); 196 + } 197 + 198 + // Generate validateSchema function 199 + function generateValidator(schema) { 200 + const lines = []; 201 + lines.push('/**'); 202 + lines.push(' * Schema Validator'); 203 + lines.push(' * Generated by schema/codegen.js'); 204 + lines.push(` * Schema version: ${schema.version}`); 205 + lines.push(' * DO NOT EDIT - regenerate with: yarn schema:codegen'); 206 + lines.push(' */'); 207 + lines.push(''); 208 + lines.push('/**'); 209 + lines.push(' * Validate that a database has all required sync columns.'); 210 + lines.push(' * Works with any SQLite wrapper that supports PRAGMA table_info.'); 211 + lines.push(' *'); 212 + lines.push(' * @param {Function} getColumns - Function that takes table name and returns column names array'); 213 + lines.push(' * @returns {{ valid: boolean, missing: string[] }} Validation result'); 214 + lines.push(' */'); 215 + lines.push('export function validateSyncSchema(getColumns) {'); 216 + lines.push(' const required = {'); 217 + 218 + for (const [tableName, cols] of Object.entries(schema.validation.required_sync_columns)) { 219 + lines.push(` ${tableName}: ${JSON.stringify(cols)},`); 220 + } 221 + 222 + lines.push(' };'); 223 + lines.push(''); 224 + lines.push(' const missing = [];'); 225 + lines.push(''); 226 + lines.push(' for (const [table, cols] of Object.entries(required)) {'); 227 + lines.push(' const actual = new Set(getColumns(table));'); 228 + lines.push(' for (const col of cols) {'); 229 + lines.push(' if (!actual.has(col)) {'); 230 + lines.push(' missing.push(`${table}.${col}`);'); 231 + lines.push(' }'); 232 + lines.push(' }'); 233 + lines.push(' }'); 234 + lines.push(''); 235 + lines.push(' return {'); 236 + lines.push(' valid: missing.length === 0,'); 237 + lines.push(' missing,'); 238 + lines.push(' };'); 239 + lines.push('}'); 240 + lines.push(''); 241 + lines.push('/**'); 242 + lines.push(' * Validate schema and throw if invalid.'); 243 + lines.push(' * @param {Function} getColumns - Function that takes table name and returns column names array'); 244 + lines.push(' * @throws {Error} If required columns are missing'); 245 + lines.push(' */'); 246 + lines.push('export function assertValidSyncSchema(getColumns) {'); 247 + lines.push(' const result = validateSyncSchema(getColumns);'); 248 + lines.push(' if (!result.valid) {'); 249 + lines.push(' throw new Error('); 250 + lines.push(' `[schema] Required columns missing: ${result.missing.join(", ")}. ` +'); 251 + lines.push(' `Database may need migration. See schema/v1.json for canonical schema.`'); 252 + lines.push(' );'); 253 + lines.push(' }'); 254 + lines.push('}'); 255 + lines.push(''); 256 + lines.push('/** Schema version */'); 257 + lines.push(`export const SCHEMA_VERSION = ${schema.version};`); 258 + lines.push(''); 259 + lines.push('/** Required sync columns by table */'); 260 + lines.push('export const REQUIRED_SYNC_COLUMNS = {'); 261 + for (const [tableName, cols] of Object.entries(schema.validation.required_sync_columns)) { 262 + lines.push(` ${tableName}: ${JSON.stringify(cols)},`); 263 + } 264 + lines.push('};'); 265 + lines.push(''); 266 + 267 + return lines.join('\n'); 268 + } 269 + 270 + // Main 271 + function main() { 272 + console.log('[codegen] Reading schema/v1.json...'); 273 + 274 + const schemaPath = join(__dirname, 'v1.json'); 275 + const content = readFileSync(schemaPath, 'utf-8'); 276 + const schema = JSON.parse(content); 277 + 278 + console.log(`[codegen] Schema version: ${schema.version}`); 279 + console.log(`[codegen] Tables: ${Object.keys(schema.tables).join(', ')}`); 280 + 281 + const outDir = join(__dirname, 'generated'); 282 + mkdirSync(outDir, { recursive: true }); 283 + 284 + // Generate full SQLite (all columns) 285 + console.log('[codegen] Generating SQLite (full)...'); 286 + const sqliteFull = generateSqlite(schema, false); 287 + writeFileSync(join(outDir, 'sqlite-full.sql'), sqliteFull); 288 + 289 + // Generate sync-only SQLite (for server) 290 + console.log('[codegen] Generating SQLite (sync-only)...'); 291 + const sqliteSync = generateSqlite(schema, true); 292 + writeFileSync(join(outDir, 'sqlite-sync.sql'), sqliteSync); 293 + 294 + // Generate TypeScript 295 + console.log('[codegen] Generating TypeScript...'); 296 + const typescript = generateTypescript(schema, false); 297 + writeFileSync(join(outDir, 'types.ts'), typescript); 298 + 299 + // Generate Rust 300 + console.log('[codegen] Generating Rust...'); 301 + const rust = generateRust(schema, false); 302 + writeFileSync(join(outDir, 'types.rs'), rust); 303 + 304 + // Generate validator 305 + console.log('[codegen] Generating validator...'); 306 + const validator = generateValidator(schema); 307 + writeFileSync(join(outDir, 'validate.js'), validator); 308 + 309 + console.log('[codegen] Done! Generated files in schema/generated/'); 310 + console.log(' - sqlite-full.sql (all columns for desktop)'); 311 + console.log(' - sqlite-sync.sql (sync columns only for server)'); 312 + console.log(' - types.ts (TypeScript interfaces)'); 313 + console.log(' - types.rs (Rust structs)'); 314 + console.log(' - validate.js (Schema validator)'); 315 + } 316 + 317 + main();
+237
schema/fidelity.test.js
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Schema Fidelity Tests 4 + * 5 + * Verifies that all backend schema implementations match the canonical schema. 6 + * Run with: node schema/fidelity.test.js 7 + * yarn schema:test 8 + * 9 + * These tests: 10 + * 1. Parse CREATE TABLE statements from each backend 11 + * 2. Compare against required sync columns from schema/v1.json 12 + * 3. Verify timestamp columns use INTEGER (not TEXT) 13 + * 4. Verify id columns use TEXT (not INTEGER) 14 + */ 15 + 16 + import { readFileSync } from 'fs'; 17 + import { dirname, join } from 'path'; 18 + import { fileURLToPath } from 'url'; 19 + import { test, describe, before } from 'node:test'; 20 + import assert from 'node:assert'; 21 + 22 + const __filename = fileURLToPath(import.meta.url); 23 + const __dirname = dirname(__filename); 24 + 25 + // Load canonical schema 26 + const schema = JSON.parse(readFileSync(join(__dirname, 'v1.json'), 'utf-8')); 27 + const REQUIRED_SYNC_COLUMNS = schema.validation.required_sync_columns; 28 + 29 + /** 30 + * Parse CREATE TABLE statements and extract column info 31 + */ 32 + function parseCreateTable(sql, tableName) { 33 + // Find the CREATE TABLE statement for this table 34 + const regex = new RegExp( 35 + `CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+${tableName}\\s*\\(([^;]+)\\)`, 36 + 'i' 37 + ); 38 + const match = sql.match(regex); 39 + if (!match) return null; 40 + 41 + const columnsStr = match[1]; 42 + const columns = {}; 43 + 44 + // Split by comma, but be careful about CHECK constraints that contain commas 45 + const lines = []; 46 + let depth = 0; 47 + let current = ''; 48 + for (const char of columnsStr) { 49 + if (char === '(') depth++; 50 + else if (char === ')') depth--; 51 + else if (char === ',' && depth === 0) { 52 + lines.push(current.trim()); 53 + current = ''; 54 + continue; 55 + } 56 + current += char; 57 + } 58 + if (current.trim()) lines.push(current.trim()); 59 + 60 + for (const line of lines) { 61 + const trimmed = line.trim(); 62 + // Skip constraints (PRIMARY KEY, FOREIGN KEY, etc.) 63 + if (/^(PRIMARY|FOREIGN|UNIQUE|CHECK|CONSTRAINT)/i.test(trimmed)) continue; 64 + 65 + // Parse column: name TYPE [constraints] 66 + const colMatch = trimmed.match(/^(\w+)\s+(\w+)/); 67 + if (colMatch) { 68 + const [, colName, colType] = colMatch; 69 + columns[colName] = { 70 + name: colName, 71 + type: colType.toUpperCase(), 72 + definition: trimmed, 73 + }; 74 + } 75 + } 76 + 77 + return columns; 78 + } 79 + 80 + /** 81 + * Read schema from Electron datastore.ts 82 + */ 83 + function getElectronSchema() { 84 + const path = join(__dirname, '../backend/electron/datastore.ts'); 85 + return readFileSync(path, 'utf-8'); 86 + } 87 + 88 + /** 89 + * Read schema from Server db.js 90 + */ 91 + function getServerSchema() { 92 + const path = join(__dirname, '../backend/server/db.js'); 93 + return readFileSync(path, 'utf-8'); 94 + } 95 + 96 + // ==================== Tests ==================== 97 + 98 + describe('Schema Fidelity Tests', () => { 99 + let electronSql; 100 + let serverSql; 101 + 102 + before(() => { 103 + electronSql = getElectronSchema(); 104 + serverSql = getServerSchema(); 105 + }); 106 + 107 + describe('Electron Backend', () => { 108 + for (const [tableName, requiredCols] of Object.entries(REQUIRED_SYNC_COLUMNS)) { 109 + test(`${tableName} has all required sync columns`, () => { 110 + const columns = parseCreateTable(electronSql, tableName); 111 + assert.ok(columns, `Table ${tableName} not found in Electron schema`); 112 + 113 + const missing = requiredCols.filter(col => !columns[col]); 114 + assert.deepStrictEqual(missing, [], `Missing columns in Electron ${tableName}: ${missing.join(', ')}`); 115 + }); 116 + } 117 + 118 + test('items.createdAt is INTEGER', () => { 119 + const columns = parseCreateTable(electronSql, 'items'); 120 + assert.ok(columns, 'Table items not found'); 121 + assert.ok(columns.createdAt, 'Column createdAt not found'); 122 + assert.strictEqual(columns.createdAt.type, 'INTEGER', 'createdAt should be INTEGER'); 123 + }); 124 + 125 + test('items.id is TEXT', () => { 126 + const columns = parseCreateTable(electronSql, 'items'); 127 + assert.ok(columns, 'Table items not found'); 128 + assert.ok(columns.id, 'Column id not found'); 129 + assert.strictEqual(columns.id.type, 'TEXT', 'id should be TEXT'); 130 + }); 131 + 132 + test('tags.id is TEXT (not INTEGER)', () => { 133 + const columns = parseCreateTable(electronSql, 'tags'); 134 + assert.ok(columns, 'Table tags not found'); 135 + assert.ok(columns.id, 'Column id not found'); 136 + assert.strictEqual(columns.id.type, 'TEXT', 'tags.id should be TEXT (not INTEGER AUTOINCREMENT)'); 137 + }); 138 + 139 + test('item_tags.tagId is TEXT', () => { 140 + const columns = parseCreateTable(electronSql, 'item_tags'); 141 + assert.ok(columns, 'Table item_tags not found'); 142 + assert.ok(columns.tagId, 'Column tagId not found'); 143 + assert.strictEqual(columns.tagId.type, 'TEXT', 'tagId should be TEXT'); 144 + }); 145 + }); 146 + 147 + describe('Server Backend', () => { 148 + for (const [tableName, requiredCols] of Object.entries(REQUIRED_SYNC_COLUMNS)) { 149 + test(`${tableName} has all required sync columns`, () => { 150 + const columns = parseCreateTable(serverSql, tableName); 151 + assert.ok(columns, `Table ${tableName} not found in Server schema`); 152 + 153 + const missing = requiredCols.filter(col => !columns[col]); 154 + assert.deepStrictEqual(missing, [], `Missing columns in Server ${tableName}: ${missing.join(', ')}`); 155 + }); 156 + } 157 + 158 + test('items.createdAt is INTEGER', () => { 159 + const columns = parseCreateTable(serverSql, 'items'); 160 + assert.ok(columns, 'Table items not found'); 161 + assert.ok(columns.createdAt, 'Column createdAt not found'); 162 + assert.strictEqual(columns.createdAt.type, 'INTEGER', 'createdAt should be INTEGER'); 163 + }); 164 + 165 + test('tags.id is TEXT', () => { 166 + const columns = parseCreateTable(serverSql, 'tags'); 167 + assert.ok(columns, 'Table tags not found'); 168 + assert.ok(columns.id, 'Column id not found'); 169 + assert.strictEqual(columns.id.type, 'TEXT', 'tags.id should be TEXT'); 170 + }); 171 + }); 172 + 173 + describe('Generated Schema Consistency', () => { 174 + test('sqlite-sync.sql matches canonical schema', () => { 175 + const generatedSql = readFileSync(join(__dirname, 'generated/sqlite-sync.sql'), 'utf-8'); 176 + 177 + for (const [tableName, requiredCols] of Object.entries(REQUIRED_SYNC_COLUMNS)) { 178 + const columns = parseCreateTable(generatedSql, tableName); 179 + assert.ok(columns, `Table ${tableName} not found in generated SQL`); 180 + 181 + const missing = requiredCols.filter(col => !columns[col]); 182 + assert.deepStrictEqual(missing, [], `Missing columns in generated ${tableName}: ${missing.join(', ')}`); 183 + } 184 + }); 185 + 186 + test('generated types.ts exports REQUIRED_SYNC_COLUMNS', () => { 187 + const typesTs = readFileSync(join(__dirname, 'generated/types.ts'), 'utf-8'); 188 + assert.ok(typesTs.includes('REQUIRED_SYNC_COLUMNS'), 'types.ts should export REQUIRED_SYNC_COLUMNS'); 189 + }); 190 + 191 + test('generated validate.js has validateSyncSchema function', () => { 192 + const validateJs = readFileSync(join(__dirname, 'generated/validate.js'), 'utf-8'); 193 + assert.ok(validateJs.includes('validateSyncSchema'), 'validate.js should export validateSyncSchema'); 194 + assert.ok(validateJs.includes('assertValidSyncSchema'), 'validate.js should export assertValidSyncSchema'); 195 + }); 196 + }); 197 + 198 + describe('Cross-Backend Consistency', () => { 199 + test('Electron and Server have matching required columns', () => { 200 + for (const [tableName, requiredCols] of Object.entries(REQUIRED_SYNC_COLUMNS)) { 201 + const electronCols = parseCreateTable(electronSql, tableName); 202 + const serverCols = parseCreateTable(serverSql, tableName); 203 + 204 + assert.ok(electronCols, `Table ${tableName} not found in Electron`); 205 + assert.ok(serverCols, `Table ${tableName} not found in Server`); 206 + 207 + for (const col of requiredCols) { 208 + assert.ok(electronCols[col], `Electron ${tableName}.${col} missing`); 209 + assert.ok(serverCols[col], `Server ${tableName}.${col} missing`); 210 + } 211 + } 212 + }); 213 + 214 + test('Timestamp columns use same type', () => { 215 + const timestampCols = ['createdAt', 'updatedAt', 'deletedAt', 'syncedAt']; 216 + 217 + for (const col of timestampCols) { 218 + const electronCols = parseCreateTable(electronSql, 'items'); 219 + const serverCols = parseCreateTable(serverSql, 'items'); 220 + 221 + if (electronCols[col] && serverCols[col]) { 222 + assert.strictEqual( 223 + electronCols[col].type, 224 + serverCols[col].type, 225 + `items.${col} type mismatch: Electron=${electronCols[col].type}, Server=${serverCols[col].type}` 226 + ); 227 + } 228 + } 229 + }); 230 + }); 231 + }); 232 + 233 + // Run tests if executed directly 234 + const isMain = process.argv[1] === fileURLToPath(import.meta.url); 235 + if (isMain) { 236 + console.log('Running schema fidelity tests...\n'); 237 + }
+70
schema/generated/sqlite-full.sql
··· 1 + -- Generated by schema/codegen.js 2 + -- Schema version: 1 3 + -- Generated: 2026-01-29T14:37:23.295Z 4 + -- DO NOT EDIT - regenerate with: yarn schema:codegen 5 + 6 + -- Unified content storage - URLs, text notes, tagsets, and images 7 + CREATE TABLE IF NOT EXISTS items ( 8 + id TEXT PRIMARY KEY NOT NULL, 9 + type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image')), 10 + content TEXT, 11 + mimeType TEXT DEFAULT '', 12 + metadata TEXT DEFAULT '{}', 13 + syncId TEXT DEFAULT '', 14 + syncSource TEXT DEFAULT '', 15 + syncedAt INTEGER DEFAULT 0, 16 + createdAt INTEGER NOT NULL, 17 + updatedAt INTEGER NOT NULL, 18 + deletedAt INTEGER DEFAULT 0, 19 + starred INTEGER DEFAULT 0, 20 + archived INTEGER DEFAULT 0, 21 + visitCount INTEGER DEFAULT 0, 22 + lastVisitAt INTEGER DEFAULT 0, 23 + frecencyScore INTEGER DEFAULT 0, 24 + title TEXT DEFAULT '', 25 + domain TEXT DEFAULT '', 26 + favicon TEXT DEFAULT '' 27 + ); 28 + 29 + CREATE INDEX IF NOT EXISTS idx_items_type ON items(type); 30 + CREATE INDEX IF NOT EXISTS idx_items_syncId ON items(syncId); 31 + CREATE INDEX IF NOT EXISTS idx_items_deletedAt ON items(deletedAt); 32 + CREATE INDEX IF NOT EXISTS idx_items_createdAt ON items(createdAt DESC); 33 + CREATE INDEX IF NOT EXISTS idx_items_starred ON items(starred); 34 + CREATE INDEX IF NOT EXISTS idx_items_lastVisitAt ON items(lastVisitAt); 35 + CREATE INDEX IF NOT EXISTS idx_items_visitCount ON items(visitCount); 36 + CREATE INDEX IF NOT EXISTS idx_items_frecencyScore ON items(frecencyScore DESC); 37 + CREATE INDEX IF NOT EXISTS idx_items_domain ON items(domain); 38 + 39 + -- Tag definitions with frecency tracking 40 + CREATE TABLE IF NOT EXISTS tags ( 41 + id TEXT PRIMARY KEY NOT NULL, 42 + name TEXT NOT NULL UNIQUE, 43 + frequency INTEGER DEFAULT 1, 44 + lastUsed INTEGER NOT NULL, 45 + frecencyScore REAL DEFAULT 0.0, 46 + createdAt INTEGER NOT NULL, 47 + updatedAt INTEGER NOT NULL, 48 + slug TEXT, 49 + color TEXT DEFAULT '#999999', 50 + parentId TEXT DEFAULT '', 51 + description TEXT DEFAULT '', 52 + metadata TEXT DEFAULT '{}' 53 + ); 54 + 55 + CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); 56 + CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC); 57 + CREATE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug); 58 + CREATE INDEX IF NOT EXISTS idx_tags_parentId ON tags(parentId); 59 + 60 + -- Junction table linking items to tags 61 + CREATE TABLE IF NOT EXISTS item_tags ( 62 + id TEXT PRIMARY KEY NOT NULL, 63 + itemId TEXT NOT NULL, 64 + tagId TEXT NOT NULL, 65 + createdAt INTEGER NOT NULL 66 + ); 67 + 68 + CREATE INDEX IF NOT EXISTS idx_item_tags_itemId ON item_tags(itemId); 69 + CREATE INDEX IF NOT EXISTS idx_item_tags_tagId ON item_tags(tagId); 70 + CREATE UNIQUE INDEX IF NOT EXISTS idx_item_tags_unique ON item_tags(itemId, tagId);
+52
schema/generated/sqlite-sync.sql
··· 1 + -- Generated by schema/codegen.js 2 + -- Schema version: 1 3 + -- Generated: 2026-01-29T14:37:23.296Z 4 + -- DO NOT EDIT - regenerate with: yarn schema:codegen 5 + 6 + -- Unified content storage - URLs, text notes, tagsets, and images 7 + CREATE TABLE IF NOT EXISTS items ( 8 + id TEXT PRIMARY KEY NOT NULL, 9 + type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image')), 10 + content TEXT, 11 + mimeType TEXT DEFAULT '', 12 + metadata TEXT DEFAULT '{}', 13 + syncId TEXT DEFAULT '', 14 + syncSource TEXT DEFAULT '', 15 + syncedAt INTEGER DEFAULT 0, 16 + createdAt INTEGER NOT NULL, 17 + updatedAt INTEGER NOT NULL, 18 + deletedAt INTEGER DEFAULT 0, 19 + starred INTEGER DEFAULT 0, 20 + archived INTEGER DEFAULT 0 21 + ); 22 + 23 + CREATE INDEX IF NOT EXISTS idx_items_type ON items(type); 24 + CREATE INDEX IF NOT EXISTS idx_items_syncId ON items(syncId); 25 + CREATE INDEX IF NOT EXISTS idx_items_deletedAt ON items(deletedAt); 26 + CREATE INDEX IF NOT EXISTS idx_items_createdAt ON items(createdAt DESC); 27 + CREATE INDEX IF NOT EXISTS idx_items_starred ON items(starred); 28 + 29 + -- Tag definitions with frecency tracking 30 + CREATE TABLE IF NOT EXISTS tags ( 31 + id TEXT PRIMARY KEY NOT NULL, 32 + name TEXT NOT NULL UNIQUE, 33 + frequency INTEGER DEFAULT 1, 34 + lastUsed INTEGER NOT NULL, 35 + frecencyScore REAL DEFAULT 0.0, 36 + createdAt INTEGER NOT NULL, 37 + updatedAt INTEGER NOT NULL 38 + ); 39 + 40 + CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); 41 + CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC); 42 + 43 + -- Junction table linking items to tags 44 + CREATE TABLE IF NOT EXISTS item_tags ( 45 + itemId TEXT NOT NULL, 46 + tagId TEXT NOT NULL, 47 + createdAt INTEGER NOT NULL 48 + ); 49 + 50 + CREATE INDEX IF NOT EXISTS idx_item_tags_itemId ON item_tags(itemId); 51 + CREATE INDEX IF NOT EXISTS idx_item_tags_tagId ON item_tags(tagId); 52 + CREATE UNIQUE INDEX IF NOT EXISTS idx_item_tags_unique ON item_tags(itemId, tagId);
+109
schema/generated/types.rs
··· 1 + // Generated by schema/codegen.js 2 + // Schema version: 1 3 + // Generated: 2026-01-29T14:37:23.296Z 4 + // DO NOT EDIT - regenerate with: yarn schema:codegen 5 + 6 + use serde::{Deserialize, Serialize}; 7 + 8 + /// Unified content storage - URLs, text notes, tagsets, and images 9 + #[derive(Debug, Clone, Serialize, Deserialize)] 10 + pub struct SchemaItems { 11 + /// Unique identifier (UUID or generated ID) 12 + pub id: String, 13 + /// Content type: url, text, tagset, or image 14 + pub r#type: String, 15 + /// URL for type=url, text content for type=text, null for tagset/image 16 + pub content: Option<String>, 17 + #[serde(rename = "mimeType")] 18 + /// MIME type (e.g., text/html, image/png) 19 + pub mime_type: String, 20 + /// JSON metadata object 21 + pub metadata: String, 22 + #[serde(rename = "syncId")] 23 + /// ID from originating device (for dedup during sync) 24 + pub sync_id: String, 25 + #[serde(rename = "syncSource")] 26 + /// Source device/platform identifier 27 + pub sync_source: String, 28 + #[serde(rename = "syncedAt")] 29 + /// Timestamp of last sync (Unix ms) 30 + pub synced_at: i64, 31 + #[serde(rename = "createdAt")] 32 + /// Creation timestamp (Unix ms) 33 + pub created_at: i64, 34 + #[serde(rename = "updatedAt")] 35 + /// Last update timestamp (Unix ms) 36 + pub updated_at: i64, 37 + #[serde(rename = "deletedAt")] 38 + /// Soft delete timestamp (0 = not deleted, Unix ms) 39 + pub deleted_at: i64, 40 + /// Starred/favorite flag (0/1) 41 + pub starred: i64, 42 + /// Archived flag (0/1) 43 + pub archived: i64, 44 + #[serde(rename = "visitCount")] 45 + /// Number of visits (local tracking) 46 + pub visit_count: i64, 47 + #[serde(rename = "lastVisitAt")] 48 + /// Last visit timestamp (local tracking) 49 + pub last_visit_at: i64, 50 + #[serde(rename = "frecencyScore")] 51 + /// Frecency score for ranking (local calculation) 52 + pub frecency_score: i64, 53 + /// Page title (cached locally for URL items) 54 + pub title: String, 55 + /// Domain extracted from URL (cached locally) 56 + pub domain: String, 57 + /// Favicon URL (cached locally) 58 + pub favicon: String, 59 + } 60 + 61 + /// Tag definitions with frecency tracking 62 + #[derive(Debug, Clone, Serialize, Deserialize)] 63 + pub struct SchemaTags { 64 + /// Unique identifier (TEXT UUID - not INTEGER) 65 + pub id: String, 66 + /// Tag name (unique, case-sensitive) 67 + pub name: String, 68 + /// Usage count 69 + pub frequency: i64, 70 + #[serde(rename = "lastUsed")] 71 + /// Last usage timestamp (Unix ms) 72 + pub last_used: i64, 73 + #[serde(rename = "frecencyScore")] 74 + /// Calculated frecency score 75 + pub frecency_score: f64, 76 + #[serde(rename = "createdAt")] 77 + /// Creation timestamp (Unix ms) 78 + pub created_at: i64, 79 + #[serde(rename = "updatedAt")] 80 + /// Last update timestamp (Unix ms) 81 + pub updated_at: i64, 82 + /// URL-safe slug (desktop only) 83 + pub slug: Option<String>, 84 + /// Display color (desktop only) 85 + pub color: String, 86 + #[serde(rename = "parentId")] 87 + /// Parent tag for hierarchy (desktop only) 88 + pub parent_id: String, 89 + /// Tag description (desktop only) 90 + pub description: String, 91 + /// JSON metadata (desktop only) 92 + pub metadata: String, 93 + } 94 + 95 + /// Junction table linking items to tags 96 + #[derive(Debug, Clone, Serialize, Deserialize)] 97 + pub struct SchemaItemTags { 98 + /// Link ID (desktop only - server uses composite PK) 99 + pub id: String, 100 + #[serde(rename = "itemId")] 101 + /// Reference to items.id 102 + pub item_id: String, 103 + #[serde(rename = "tagId")] 104 + /// Reference to tags.id 105 + pub tag_id: String, 106 + #[serde(rename = "createdAt")] 107 + /// Link creation timestamp (Unix ms) 108 + pub created_at: i64, 109 + }
+98
schema/generated/types.ts
··· 1 + /** 2 + * Generated by schema/codegen.js 3 + * Schema version: 1 4 + * Generated: 2026-01-29T14:37:23.296Z 5 + * DO NOT EDIT - regenerate with: yarn schema:codegen 6 + */ 7 + 8 + /** Unified content storage - URLs, text notes, tagsets, and images */ 9 + export interface SchemaItems { 10 + /** Unique identifier (UUID or generated ID) */ 11 + id: string; 12 + /** Content type: url, text, tagset, or image */ 13 + type: string; 14 + /** URL for type=url, text content for type=text, null for tagset/image */ 15 + content: string | null; 16 + /** MIME type (e.g., text/html, image/png) */ 17 + mimeType: string; 18 + /** JSON metadata object */ 19 + metadata: string; 20 + /** ID from originating device (for dedup during sync) */ 21 + syncId: string; 22 + /** Source device/platform identifier */ 23 + syncSource: string; 24 + /** Timestamp of last sync (Unix ms) */ 25 + syncedAt: number; 26 + /** Creation timestamp (Unix ms) */ 27 + createdAt: number; 28 + /** Last update timestamp (Unix ms) */ 29 + updatedAt: number; 30 + /** Soft delete timestamp (0 = not deleted, Unix ms) */ 31 + deletedAt: number; 32 + /** Starred/favorite flag (0/1) */ 33 + starred: number; 34 + /** Archived flag (0/1) */ 35 + archived: number; 36 + /** Number of visits (local tracking) */ 37 + visitCount: number; 38 + /** Last visit timestamp (local tracking) */ 39 + lastVisitAt: number; 40 + /** Frecency score for ranking (local calculation) */ 41 + frecencyScore: number; 42 + /** Page title (cached locally for URL items) */ 43 + title: string; 44 + /** Domain extracted from URL (cached locally) */ 45 + domain: string; 46 + /** Favicon URL (cached locally) */ 47 + favicon: string; 48 + } 49 + 50 + /** Tag definitions with frecency tracking */ 51 + export interface SchemaTags { 52 + /** Unique identifier (TEXT UUID - not INTEGER) */ 53 + id: string; 54 + /** Tag name (unique, case-sensitive) */ 55 + name: string; 56 + /** Usage count */ 57 + frequency: number; 58 + /** Last usage timestamp (Unix ms) */ 59 + lastUsed: number; 60 + /** Calculated frecency score */ 61 + frecencyScore: number; 62 + /** Creation timestamp (Unix ms) */ 63 + createdAt: number; 64 + /** Last update timestamp (Unix ms) */ 65 + updatedAt: number; 66 + /** URL-safe slug (desktop only) */ 67 + slug: string | null; 68 + /** Display color (desktop only) */ 69 + color: string; 70 + /** Parent tag for hierarchy (desktop only) */ 71 + parentId: string; 72 + /** Tag description (desktop only) */ 73 + description: string; 74 + /** JSON metadata (desktop only) */ 75 + metadata: string; 76 + } 77 + 78 + /** Junction table linking items to tags */ 79 + export interface SchemaItemTags { 80 + /** Link ID (desktop only - server uses composite PK) */ 81 + id: string; 82 + /** Reference to items.id */ 83 + itemId: string; 84 + /** Reference to tags.id */ 85 + tagId: string; 86 + /** Link creation timestamp (Unix ms) */ 87 + createdAt: number; 88 + } 89 + 90 + /** Valid sync table names */ 91 + export type SchemaSyncTableName = 'items' | 'tags' | 'item_tags'; 92 + 93 + /** Required sync columns by table */ 94 + export const REQUIRED_SYNC_COLUMNS: Record<SchemaSyncTableName, string[]> = { 95 + items: ["id","type","content","syncId","syncSource","syncedAt","createdAt","updatedAt","deletedAt"], 96 + tags: ["id","name","frequency","lastUsed","frecencyScore","createdAt","updatedAt"], 97 + item_tags: ["itemId","tagId","createdAt"], 98 + };
+62
schema/generated/validate.js
··· 1 + /** 2 + * Schema Validator 3 + * Generated by schema/codegen.js 4 + * Schema version: 1 5 + * DO NOT EDIT - regenerate with: yarn schema:codegen 6 + */ 7 + 8 + /** 9 + * Validate that a database has all required sync columns. 10 + * Works with any SQLite wrapper that supports PRAGMA table_info. 11 + * 12 + * @param {Function} getColumns - Function that takes table name and returns column names array 13 + * @returns {{ valid: boolean, missing: string[] }} Validation result 14 + */ 15 + export function validateSyncSchema(getColumns) { 16 + const required = { 17 + items: ["id","type","content","syncId","syncSource","syncedAt","createdAt","updatedAt","deletedAt"], 18 + tags: ["id","name","frequency","lastUsed","frecencyScore","createdAt","updatedAt"], 19 + item_tags: ["itemId","tagId","createdAt"], 20 + }; 21 + 22 + const missing = []; 23 + 24 + for (const [table, cols] of Object.entries(required)) { 25 + const actual = new Set(getColumns(table)); 26 + for (const col of cols) { 27 + if (!actual.has(col)) { 28 + missing.push(`${table}.${col}`); 29 + } 30 + } 31 + } 32 + 33 + return { 34 + valid: missing.length === 0, 35 + missing, 36 + }; 37 + } 38 + 39 + /** 40 + * Validate schema and throw if invalid. 41 + * @param {Function} getColumns - Function that takes table name and returns column names array 42 + * @throws {Error} If required columns are missing 43 + */ 44 + export function assertValidSyncSchema(getColumns) { 45 + const result = validateSyncSchema(getColumns); 46 + if (!result.valid) { 47 + throw new Error( 48 + `[schema] Required columns missing: ${result.missing.join(", ")}. ` + 49 + `Database may need migration. See schema/v1.json for canonical schema.` 50 + ); 51 + } 52 + } 53 + 54 + /** Schema version */ 55 + export const SCHEMA_VERSION = 1; 56 + 57 + /** Required sync columns by table */ 58 + export const REQUIRED_SYNC_COLUMNS = { 59 + items: ["id","type","content","syncId","syncSource","syncedAt","createdAt","updatedAt","deletedAt"], 60 + tags: ["id","name","frequency","lastUsed","frecencyScore","createdAt","updatedAt"], 61 + item_tags: ["itemId","tagId","createdAt"], 62 + };
+272
schema/v1.json
··· 1 + { 2 + "version": 1, 3 + "description": "Peek sync schema - items, tags, and item_tags tables", 4 + 5 + "tables": { 6 + "items": { 7 + "description": "Unified content storage - URLs, text notes, tagsets, and images", 8 + "columns": { 9 + "id": { 10 + "type": "text", 11 + "primary_key": true, 12 + "not_null": true, 13 + "sync": true, 14 + "description": "Unique identifier (UUID or generated ID)" 15 + }, 16 + "type": { 17 + "type": "text", 18 + "not_null": true, 19 + "check": "type IN ('url', 'text', 'tagset', 'image')", 20 + "sync": true, 21 + "description": "Content type: url, text, tagset, or image" 22 + }, 23 + "content": { 24 + "type": "text", 25 + "nullable": true, 26 + "sync": true, 27 + "description": "URL for type=url, text content for type=text, null for tagset/image" 28 + }, 29 + "mimeType": { 30 + "type": "text", 31 + "default": "''", 32 + "sync": true, 33 + "description": "MIME type (e.g., text/html, image/png)" 34 + }, 35 + "metadata": { 36 + "type": "text", 37 + "default": "'{}'", 38 + "sync": true, 39 + "description": "JSON metadata object" 40 + }, 41 + "syncId": { 42 + "type": "text", 43 + "default": "''", 44 + "sync": true, 45 + "description": "ID from originating device (for dedup during sync)" 46 + }, 47 + "syncSource": { 48 + "type": "text", 49 + "default": "''", 50 + "sync": true, 51 + "description": "Source device/platform identifier" 52 + }, 53 + "syncedAt": { 54 + "type": "integer", 55 + "default": "0", 56 + "sync": true, 57 + "description": "Timestamp of last sync (Unix ms)" 58 + }, 59 + "createdAt": { 60 + "type": "integer", 61 + "not_null": true, 62 + "sync": true, 63 + "description": "Creation timestamp (Unix ms)" 64 + }, 65 + "updatedAt": { 66 + "type": "integer", 67 + "not_null": true, 68 + "sync": true, 69 + "description": "Last update timestamp (Unix ms)" 70 + }, 71 + "deletedAt": { 72 + "type": "integer", 73 + "default": "0", 74 + "sync": true, 75 + "description": "Soft delete timestamp (0 = not deleted, Unix ms)" 76 + }, 77 + "starred": { 78 + "type": "integer", 79 + "default": "0", 80 + "sync": true, 81 + "description": "Starred/favorite flag (0/1)" 82 + }, 83 + "archived": { 84 + "type": "integer", 85 + "default": "0", 86 + "sync": true, 87 + "description": "Archived flag (0/1)" 88 + }, 89 + "visitCount": { 90 + "type": "integer", 91 + "default": "0", 92 + "sync": false, 93 + "description": "Number of visits (local tracking)" 94 + }, 95 + "lastVisitAt": { 96 + "type": "integer", 97 + "default": "0", 98 + "sync": false, 99 + "description": "Last visit timestamp (local tracking)" 100 + }, 101 + "frecencyScore": { 102 + "type": "integer", 103 + "default": "0", 104 + "sync": false, 105 + "description": "Frecency score for ranking (local calculation)" 106 + }, 107 + "title": { 108 + "type": "text", 109 + "default": "''", 110 + "sync": false, 111 + "description": "Page title (cached locally for URL items)" 112 + }, 113 + "domain": { 114 + "type": "text", 115 + "default": "''", 116 + "sync": false, 117 + "description": "Domain extracted from URL (cached locally)" 118 + }, 119 + "favicon": { 120 + "type": "text", 121 + "default": "''", 122 + "sync": false, 123 + "description": "Favicon URL (cached locally)" 124 + } 125 + }, 126 + "indexes": [ 127 + { "name": "idx_items_type", "columns": ["type"], "sync": true }, 128 + { "name": "idx_items_syncId", "columns": ["syncId"], "sync": true }, 129 + { "name": "idx_items_deletedAt", "columns": ["deletedAt"], "sync": true }, 130 + { "name": "idx_items_createdAt", "columns": ["createdAt"], "order": "DESC", "sync": true }, 131 + { "name": "idx_items_starred", "columns": ["starred"], "sync": true }, 132 + { "name": "idx_items_lastVisitAt", "columns": ["lastVisitAt"], "sync": false }, 133 + { "name": "idx_items_visitCount", "columns": ["visitCount"], "sync": false }, 134 + { "name": "idx_items_frecencyScore", "columns": ["frecencyScore"], "order": "DESC", "sync": false }, 135 + { "name": "idx_items_domain", "columns": ["domain"], "sync": false } 136 + ] 137 + }, 138 + 139 + "tags": { 140 + "description": "Tag definitions with frecency tracking", 141 + "columns": { 142 + "id": { 143 + "type": "text", 144 + "primary_key": true, 145 + "not_null": true, 146 + "sync": true, 147 + "description": "Unique identifier (TEXT UUID - not INTEGER)" 148 + }, 149 + "name": { 150 + "type": "text", 151 + "not_null": true, 152 + "unique": true, 153 + "sync": true, 154 + "description": "Tag name (unique, case-sensitive)" 155 + }, 156 + "frequency": { 157 + "type": "integer", 158 + "default": "1", 159 + "sync": true, 160 + "description": "Usage count" 161 + }, 162 + "lastUsed": { 163 + "type": "integer", 164 + "not_null": true, 165 + "sync": true, 166 + "description": "Last usage timestamp (Unix ms)" 167 + }, 168 + "frecencyScore": { 169 + "type": "real", 170 + "default": "0.0", 171 + "sync": true, 172 + "description": "Calculated frecency score" 173 + }, 174 + "createdAt": { 175 + "type": "integer", 176 + "not_null": true, 177 + "sync": true, 178 + "description": "Creation timestamp (Unix ms)" 179 + }, 180 + "updatedAt": { 181 + "type": "integer", 182 + "not_null": true, 183 + "sync": true, 184 + "description": "Last update timestamp (Unix ms)" 185 + }, 186 + "slug": { 187 + "type": "text", 188 + "nullable": true, 189 + "sync": false, 190 + "description": "URL-safe slug (desktop only)" 191 + }, 192 + "color": { 193 + "type": "text", 194 + "default": "'#999999'", 195 + "sync": false, 196 + "description": "Display color (desktop only)" 197 + }, 198 + "parentId": { 199 + "type": "text", 200 + "default": "''", 201 + "sync": false, 202 + "description": "Parent tag for hierarchy (desktop only)" 203 + }, 204 + "description": { 205 + "type": "text", 206 + "default": "''", 207 + "sync": false, 208 + "description": "Tag description (desktop only)" 209 + }, 210 + "metadata": { 211 + "type": "text", 212 + "default": "'{}'", 213 + "sync": false, 214 + "description": "JSON metadata (desktop only)" 215 + } 216 + }, 217 + "indexes": [ 218 + { "name": "idx_tags_name", "columns": ["name"], "sync": true }, 219 + { "name": "idx_tags_frecency", "columns": ["frecencyScore"], "order": "DESC", "sync": true }, 220 + { "name": "idx_tags_slug", "columns": ["slug"], "sync": false }, 221 + { "name": "idx_tags_parentId", "columns": ["parentId"], "sync": false } 222 + ] 223 + }, 224 + 225 + "item_tags": { 226 + "description": "Junction table linking items to tags", 227 + "columns": { 228 + "id": { 229 + "type": "text", 230 + "primary_key": true, 231 + "not_null": true, 232 + "sync": false, 233 + "description": "Link ID (desktop only - server uses composite PK)" 234 + }, 235 + "itemId": { 236 + "type": "text", 237 + "not_null": true, 238 + "sync": true, 239 + "description": "Reference to items.id" 240 + }, 241 + "tagId": { 242 + "type": "text", 243 + "not_null": true, 244 + "sync": true, 245 + "description": "Reference to tags.id" 246 + }, 247 + "createdAt": { 248 + "type": "integer", 249 + "not_null": true, 250 + "sync": true, 251 + "description": "Link creation timestamp (Unix ms)" 252 + } 253 + }, 254 + "indexes": [ 255 + { "name": "idx_item_tags_itemId", "columns": ["itemId"], "sync": true }, 256 + { "name": "idx_item_tags_tagId", "columns": ["tagId"], "sync": true }, 257 + { "name": "idx_item_tags_unique", "columns": ["itemId", "tagId"], "unique": true, "sync": true } 258 + ] 259 + } 260 + }, 261 + 262 + "validation": { 263 + "timestamp_type": "integer", 264 + "timestamp_unit": "milliseconds", 265 + "id_type": "text", 266 + "required_sync_columns": { 267 + "items": ["id", "type", "content", "syncId", "syncSource", "syncedAt", "createdAt", "updatedAt", "deletedAt"], 268 + "tags": ["id", "name", "frequency", "lastUsed", "frecencyScore", "createdAt", "updatedAt"], 269 + "item_tags": ["itemId", "tagId", "createdAt"] 270 + } 271 + } 272 + }