···11+#!/usr/bin/env node
22+/**
33+ * Schema Fidelity Tests
44+ *
55+ * Verifies that all backend schema implementations match the canonical schema.
66+ * Run with: node schema/fidelity.test.js
77+ * yarn schema:test
88+ *
99+ * These tests:
1010+ * 1. Parse CREATE TABLE statements from each backend
1111+ * 2. Compare against required sync columns from schema/v1.json
1212+ * 3. Verify timestamp columns use INTEGER (not TEXT)
1313+ * 4. Verify id columns use TEXT (not INTEGER)
1414+ */
1515+1616+import { readFileSync } from 'fs';
1717+import { dirname, join } from 'path';
1818+import { fileURLToPath } from 'url';
1919+import { test, describe, before } from 'node:test';
2020+import assert from 'node:assert';
2121+2222+const __filename = fileURLToPath(import.meta.url);
2323+const __dirname = dirname(__filename);
2424+2525+// Load canonical schema
2626+const schema = JSON.parse(readFileSync(join(__dirname, 'v1.json'), 'utf-8'));
2727+const REQUIRED_SYNC_COLUMNS = schema.validation.required_sync_columns;
2828+2929+/**
3030+ * Parse CREATE TABLE statements and extract column info
3131+ */
3232+function parseCreateTable(sql, tableName) {
3333+ // Find the CREATE TABLE statement for this table
3434+ const regex = new RegExp(
3535+ `CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+${tableName}\\s*\\(([^;]+)\\)`,
3636+ 'i'
3737+ );
3838+ const match = sql.match(regex);
3939+ if (!match) return null;
4040+4141+ const columnsStr = match[1];
4242+ const columns = {};
4343+4444+ // Split by comma, but be careful about CHECK constraints that contain commas
4545+ const lines = [];
4646+ let depth = 0;
4747+ let current = '';
4848+ for (const char of columnsStr) {
4949+ if (char === '(') depth++;
5050+ else if (char === ')') depth--;
5151+ else if (char === ',' && depth === 0) {
5252+ lines.push(current.trim());
5353+ current = '';
5454+ continue;
5555+ }
5656+ current += char;
5757+ }
5858+ if (current.trim()) lines.push(current.trim());
5959+6060+ for (const line of lines) {
6161+ const trimmed = line.trim();
6262+ // Skip constraints (PRIMARY KEY, FOREIGN KEY, etc.)
6363+ if (/^(PRIMARY|FOREIGN|UNIQUE|CHECK|CONSTRAINT)/i.test(trimmed)) continue;
6464+6565+ // Parse column: name TYPE [constraints]
6666+ const colMatch = trimmed.match(/^(\w+)\s+(\w+)/);
6767+ if (colMatch) {
6868+ const [, colName, colType] = colMatch;
6969+ columns[colName] = {
7070+ name: colName,
7171+ type: colType.toUpperCase(),
7272+ definition: trimmed,
7373+ };
7474+ }
7575+ }
7676+7777+ return columns;
7878+}
7979+8080+/**
8181+ * Read schema from Electron datastore.ts
8282+ */
8383+function getElectronSchema() {
8484+ const path = join(__dirname, '../backend/electron/datastore.ts');
8585+ return readFileSync(path, 'utf-8');
8686+}
8787+8888+/**
8989+ * Read schema from Server db.js
9090+ */
9191+function getServerSchema() {
9292+ const path = join(__dirname, '../backend/server/db.js');
9393+ return readFileSync(path, 'utf-8');
9494+}
9595+9696+// ==================== Tests ====================
9797+9898+describe('Schema Fidelity Tests', () => {
9999+ let electronSql;
100100+ let serverSql;
101101+102102+ before(() => {
103103+ electronSql = getElectronSchema();
104104+ serverSql = getServerSchema();
105105+ });
106106+107107+ describe('Electron Backend', () => {
108108+ for (const [tableName, requiredCols] of Object.entries(REQUIRED_SYNC_COLUMNS)) {
109109+ test(`${tableName} has all required sync columns`, () => {
110110+ const columns = parseCreateTable(electronSql, tableName);
111111+ assert.ok(columns, `Table ${tableName} not found in Electron schema`);
112112+113113+ const missing = requiredCols.filter(col => !columns[col]);
114114+ assert.deepStrictEqual(missing, [], `Missing columns in Electron ${tableName}: ${missing.join(', ')}`);
115115+ });
116116+ }
117117+118118+ test('items.createdAt is INTEGER', () => {
119119+ const columns = parseCreateTable(electronSql, 'items');
120120+ assert.ok(columns, 'Table items not found');
121121+ assert.ok(columns.createdAt, 'Column createdAt not found');
122122+ assert.strictEqual(columns.createdAt.type, 'INTEGER', 'createdAt should be INTEGER');
123123+ });
124124+125125+ test('items.id is TEXT', () => {
126126+ const columns = parseCreateTable(electronSql, 'items');
127127+ assert.ok(columns, 'Table items not found');
128128+ assert.ok(columns.id, 'Column id not found');
129129+ assert.strictEqual(columns.id.type, 'TEXT', 'id should be TEXT');
130130+ });
131131+132132+ test('tags.id is TEXT (not INTEGER)', () => {
133133+ const columns = parseCreateTable(electronSql, 'tags');
134134+ assert.ok(columns, 'Table tags not found');
135135+ assert.ok(columns.id, 'Column id not found');
136136+ assert.strictEqual(columns.id.type, 'TEXT', 'tags.id should be TEXT (not INTEGER AUTOINCREMENT)');
137137+ });
138138+139139+ test('item_tags.tagId is TEXT', () => {
140140+ const columns = parseCreateTable(electronSql, 'item_tags');
141141+ assert.ok(columns, 'Table item_tags not found');
142142+ assert.ok(columns.tagId, 'Column tagId not found');
143143+ assert.strictEqual(columns.tagId.type, 'TEXT', 'tagId should be TEXT');
144144+ });
145145+ });
146146+147147+ describe('Server Backend', () => {
148148+ for (const [tableName, requiredCols] of Object.entries(REQUIRED_SYNC_COLUMNS)) {
149149+ test(`${tableName} has all required sync columns`, () => {
150150+ const columns = parseCreateTable(serverSql, tableName);
151151+ assert.ok(columns, `Table ${tableName} not found in Server schema`);
152152+153153+ const missing = requiredCols.filter(col => !columns[col]);
154154+ assert.deepStrictEqual(missing, [], `Missing columns in Server ${tableName}: ${missing.join(', ')}`);
155155+ });
156156+ }
157157+158158+ test('items.createdAt is INTEGER', () => {
159159+ const columns = parseCreateTable(serverSql, 'items');
160160+ assert.ok(columns, 'Table items not found');
161161+ assert.ok(columns.createdAt, 'Column createdAt not found');
162162+ assert.strictEqual(columns.createdAt.type, 'INTEGER', 'createdAt should be INTEGER');
163163+ });
164164+165165+ test('tags.id is TEXT', () => {
166166+ const columns = parseCreateTable(serverSql, 'tags');
167167+ assert.ok(columns, 'Table tags not found');
168168+ assert.ok(columns.id, 'Column id not found');
169169+ assert.strictEqual(columns.id.type, 'TEXT', 'tags.id should be TEXT');
170170+ });
171171+ });
172172+173173+ describe('Generated Schema Consistency', () => {
174174+ test('sqlite-sync.sql matches canonical schema', () => {
175175+ const generatedSql = readFileSync(join(__dirname, 'generated/sqlite-sync.sql'), 'utf-8');
176176+177177+ for (const [tableName, requiredCols] of Object.entries(REQUIRED_SYNC_COLUMNS)) {
178178+ const columns = parseCreateTable(generatedSql, tableName);
179179+ assert.ok(columns, `Table ${tableName} not found in generated SQL`);
180180+181181+ const missing = requiredCols.filter(col => !columns[col]);
182182+ assert.deepStrictEqual(missing, [], `Missing columns in generated ${tableName}: ${missing.join(', ')}`);
183183+ }
184184+ });
185185+186186+ test('generated types.ts exports REQUIRED_SYNC_COLUMNS', () => {
187187+ const typesTs = readFileSync(join(__dirname, 'generated/types.ts'), 'utf-8');
188188+ assert.ok(typesTs.includes('REQUIRED_SYNC_COLUMNS'), 'types.ts should export REQUIRED_SYNC_COLUMNS');
189189+ });
190190+191191+ test('generated validate.js has validateSyncSchema function', () => {
192192+ const validateJs = readFileSync(join(__dirname, 'generated/validate.js'), 'utf-8');
193193+ assert.ok(validateJs.includes('validateSyncSchema'), 'validate.js should export validateSyncSchema');
194194+ assert.ok(validateJs.includes('assertValidSyncSchema'), 'validate.js should export assertValidSyncSchema');
195195+ });
196196+ });
197197+198198+ describe('Cross-Backend Consistency', () => {
199199+ test('Electron and Server have matching required columns', () => {
200200+ for (const [tableName, requiredCols] of Object.entries(REQUIRED_SYNC_COLUMNS)) {
201201+ const electronCols = parseCreateTable(electronSql, tableName);
202202+ const serverCols = parseCreateTable(serverSql, tableName);
203203+204204+ assert.ok(electronCols, `Table ${tableName} not found in Electron`);
205205+ assert.ok(serverCols, `Table ${tableName} not found in Server`);
206206+207207+ for (const col of requiredCols) {
208208+ assert.ok(electronCols[col], `Electron ${tableName}.${col} missing`);
209209+ assert.ok(serverCols[col], `Server ${tableName}.${col} missing`);
210210+ }
211211+ }
212212+ });
213213+214214+ test('Timestamp columns use same type', () => {
215215+ const timestampCols = ['createdAt', 'updatedAt', 'deletedAt', 'syncedAt'];
216216+217217+ for (const col of timestampCols) {
218218+ const electronCols = parseCreateTable(electronSql, 'items');
219219+ const serverCols = parseCreateTable(serverSql, 'items');
220220+221221+ if (electronCols[col] && serverCols[col]) {
222222+ assert.strictEqual(
223223+ electronCols[col].type,
224224+ serverCols[col].type,
225225+ `items.${col} type mismatch: Electron=${electronCols[col].type}, Server=${serverCols[col].type}`
226226+ );
227227+ }
228228+ }
229229+ });
230230+ });
231231+});
232232+233233+// Run tests if executed directly
234234+const isMain = process.argv[1] === fileURLToPath(import.meta.url);
235235+if (isMain) {
236236+ console.log('Running schema fidelity tests...\n');
237237+}
+70
schema/generated/sqlite-full.sql
···11+-- Generated by schema/codegen.js
22+-- Schema version: 1
33+-- Generated: 2026-01-29T14:37:23.295Z
44+-- DO NOT EDIT - regenerate with: yarn schema:codegen
55+66+-- Unified content storage - URLs, text notes, tagsets, and images
77+CREATE TABLE IF NOT EXISTS items (
88+ id TEXT PRIMARY KEY NOT NULL,
99+ type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image')),
1010+ content TEXT,
1111+ mimeType TEXT DEFAULT '',
1212+ metadata TEXT DEFAULT '{}',
1313+ syncId TEXT DEFAULT '',
1414+ syncSource TEXT DEFAULT '',
1515+ syncedAt INTEGER DEFAULT 0,
1616+ createdAt INTEGER NOT NULL,
1717+ updatedAt INTEGER NOT NULL,
1818+ deletedAt INTEGER DEFAULT 0,
1919+ starred INTEGER DEFAULT 0,
2020+ archived INTEGER DEFAULT 0,
2121+ visitCount INTEGER DEFAULT 0,
2222+ lastVisitAt INTEGER DEFAULT 0,
2323+ frecencyScore INTEGER DEFAULT 0,
2424+ title TEXT DEFAULT '',
2525+ domain TEXT DEFAULT '',
2626+ favicon TEXT DEFAULT ''
2727+);
2828+2929+CREATE INDEX IF NOT EXISTS idx_items_type ON items(type);
3030+CREATE INDEX IF NOT EXISTS idx_items_syncId ON items(syncId);
3131+CREATE INDEX IF NOT EXISTS idx_items_deletedAt ON items(deletedAt);
3232+CREATE INDEX IF NOT EXISTS idx_items_createdAt ON items(createdAt DESC);
3333+CREATE INDEX IF NOT EXISTS idx_items_starred ON items(starred);
3434+CREATE INDEX IF NOT EXISTS idx_items_lastVisitAt ON items(lastVisitAt);
3535+CREATE INDEX IF NOT EXISTS idx_items_visitCount ON items(visitCount);
3636+CREATE INDEX IF NOT EXISTS idx_items_frecencyScore ON items(frecencyScore DESC);
3737+CREATE INDEX IF NOT EXISTS idx_items_domain ON items(domain);
3838+3939+-- Tag definitions with frecency tracking
4040+CREATE TABLE IF NOT EXISTS tags (
4141+ id TEXT PRIMARY KEY NOT NULL,
4242+ name TEXT NOT NULL UNIQUE,
4343+ frequency INTEGER DEFAULT 1,
4444+ lastUsed INTEGER NOT NULL,
4545+ frecencyScore REAL DEFAULT 0.0,
4646+ createdAt INTEGER NOT NULL,
4747+ updatedAt INTEGER NOT NULL,
4848+ slug TEXT,
4949+ color TEXT DEFAULT '#999999',
5050+ parentId TEXT DEFAULT '',
5151+ description TEXT DEFAULT '',
5252+ metadata TEXT DEFAULT '{}'
5353+);
5454+5555+CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
5656+CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC);
5757+CREATE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug);
5858+CREATE INDEX IF NOT EXISTS idx_tags_parentId ON tags(parentId);
5959+6060+-- Junction table linking items to tags
6161+CREATE TABLE IF NOT EXISTS item_tags (
6262+ id TEXT PRIMARY KEY NOT NULL,
6363+ itemId TEXT NOT NULL,
6464+ tagId TEXT NOT NULL,
6565+ createdAt INTEGER NOT NULL
6666+);
6767+6868+CREATE INDEX IF NOT EXISTS idx_item_tags_itemId ON item_tags(itemId);
6969+CREATE INDEX IF NOT EXISTS idx_item_tags_tagId ON item_tags(tagId);
7070+CREATE UNIQUE INDEX IF NOT EXISTS idx_item_tags_unique ON item_tags(itemId, tagId);
+52
schema/generated/sqlite-sync.sql
···11+-- Generated by schema/codegen.js
22+-- Schema version: 1
33+-- Generated: 2026-01-29T14:37:23.296Z
44+-- DO NOT EDIT - regenerate with: yarn schema:codegen
55+66+-- Unified content storage - URLs, text notes, tagsets, and images
77+CREATE TABLE IF NOT EXISTS items (
88+ id TEXT PRIMARY KEY NOT NULL,
99+ type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image')),
1010+ content TEXT,
1111+ mimeType TEXT DEFAULT '',
1212+ metadata TEXT DEFAULT '{}',
1313+ syncId TEXT DEFAULT '',
1414+ syncSource TEXT DEFAULT '',
1515+ syncedAt INTEGER DEFAULT 0,
1616+ createdAt INTEGER NOT NULL,
1717+ updatedAt INTEGER NOT NULL,
1818+ deletedAt INTEGER DEFAULT 0,
1919+ starred INTEGER DEFAULT 0,
2020+ archived INTEGER DEFAULT 0
2121+);
2222+2323+CREATE INDEX IF NOT EXISTS idx_items_type ON items(type);
2424+CREATE INDEX IF NOT EXISTS idx_items_syncId ON items(syncId);
2525+CREATE INDEX IF NOT EXISTS idx_items_deletedAt ON items(deletedAt);
2626+CREATE INDEX IF NOT EXISTS idx_items_createdAt ON items(createdAt DESC);
2727+CREATE INDEX IF NOT EXISTS idx_items_starred ON items(starred);
2828+2929+-- Tag definitions with frecency tracking
3030+CREATE TABLE IF NOT EXISTS tags (
3131+ id TEXT PRIMARY KEY NOT NULL,
3232+ name TEXT NOT NULL UNIQUE,
3333+ frequency INTEGER DEFAULT 1,
3434+ lastUsed INTEGER NOT NULL,
3535+ frecencyScore REAL DEFAULT 0.0,
3636+ createdAt INTEGER NOT NULL,
3737+ updatedAt INTEGER NOT NULL
3838+);
3939+4040+CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
4141+CREATE INDEX IF NOT EXISTS idx_tags_frecency ON tags(frecencyScore DESC);
4242+4343+-- Junction table linking items to tags
4444+CREATE TABLE IF NOT EXISTS item_tags (
4545+ itemId TEXT NOT NULL,
4646+ tagId TEXT NOT NULL,
4747+ createdAt INTEGER NOT NULL
4848+);
4949+5050+CREATE INDEX IF NOT EXISTS idx_item_tags_itemId ON item_tags(itemId);
5151+CREATE INDEX IF NOT EXISTS idx_item_tags_tagId ON item_tags(tagId);
5252+CREATE UNIQUE INDEX IF NOT EXISTS idx_item_tags_unique ON item_tags(itemId, tagId);