#!/usr/bin/env node /** * Schema Codegen * * Generates SQLite, TypeScript, and Rust code from schema/v1.json * * Usage: * node schema/codegen.js * yarn schema:codegen */ import { readFileSync, writeFileSync, mkdirSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Type mapping const typeMap = { sqlite: { text: 'TEXT', integer: 'INTEGER', real: 'REAL', boolean: 'INTEGER', }, typescript: { text: 'string', integer: 'number', real: 'number', boolean: 'number', // SQLite stores as 0/1 }, rust: { text: 'String', integer: 'i64', real: 'f64', boolean: 'i32', }, }; // Generate SQLite CREATE TABLE statements function generateSqlite(schema, syncOnly = false) { const lines = []; lines.push('-- Generated by schema/codegen.js'); lines.push(`-- Schema version: ${schema.version}`); lines.push('-- DO NOT EDIT - regenerate with: yarn schema:codegen'); lines.push(''); for (const [tableName, table] of Object.entries(schema.tables)) { lines.push(`-- ${table.description || tableName}`); lines.push(`CREATE TABLE IF NOT EXISTS ${tableName} (`); const columnDefs = []; for (const [colName, col] of Object.entries(table.columns)) { // Skip non-sync columns if syncOnly if (syncOnly && col.sync === false) continue; let def = ` ${colName} ${typeMap.sqlite[col.type]}`; if (col.primary_key) def += ' PRIMARY KEY'; if (col.not_null) def += ' NOT NULL'; if (col.unique) def += ' UNIQUE'; if (col.check) def += ` CHECK(${col.check})`; if (col.default !== undefined) { def += ` DEFAULT ${col.default}`; } columnDefs.push(def); } lines.push(columnDefs.join(',\n')); lines.push(');'); lines.push(''); // Indexes for (const idx of (table.indexes || [])) { // Skip non-sync indexes if syncOnly if (syncOnly && idx.sync === false) continue; const unique = idx.unique ? 'UNIQUE ' : ''; const cols = idx.columns.join(', '); const order = idx.order ? ` ${idx.order}` : ''; lines.push(`CREATE ${unique}INDEX IF NOT EXISTS ${idx.name} ON ${tableName}(${cols}${order});`); } lines.push(''); } return lines.join('\n'); } // Generate TypeScript interfaces function generateTypescript(schema, syncOnly = false) { const lines = []; lines.push('/**'); lines.push(' * Generated by schema/codegen.js'); lines.push(` * Schema version: ${schema.version}`); lines.push(' * DO NOT EDIT - regenerate with: yarn schema:codegen'); lines.push(' */'); lines.push(''); for (const [tableName, table] of Object.entries(schema.tables)) { const interfaceName = tableName.charAt(0).toUpperCase() + tableName.slice(1).replace(/_./g, x => x[1].toUpperCase()); lines.push(`/** ${table.description || tableName} */`); lines.push(`export interface Schema${interfaceName} {`); for (const [colName, col] of Object.entries(table.columns)) { // Skip non-sync columns if syncOnly if (syncOnly && col.sync === false) continue; const tsType = typeMap.typescript[col.type]; const nullable = col.nullable ? ' | null' : ''; const comment = col.description ? ` /** ${col.description} */` : ''; if (comment) lines.push(comment); lines.push(` ${colName}: ${tsType}${nullable};`); } lines.push('}'); lines.push(''); } // Generate table names type lines.push('/** Valid sync table names */'); lines.push('export type SchemaSyncTableName = ' + Object.keys(schema.tables).map(t => `'${t}'`).join(' | ') + ';'); lines.push(''); // Generate validation constants lines.push('/** Required sync columns by table */'); lines.push('export const REQUIRED_SYNC_COLUMNS: Record = {'); for (const [tableName, cols] of Object.entries(schema.validation.required_sync_columns)) { lines.push(` ${tableName}: ${JSON.stringify(cols)},`); } lines.push('};'); lines.push(''); return lines.join('\n'); } // Generate Rust structs function generateRust(schema, syncOnly = false) { const lines = []; lines.push('// Generated by schema/codegen.js'); lines.push(`// Schema version: ${schema.version}`); lines.push('// DO NOT EDIT - regenerate with: yarn schema:codegen'); lines.push(''); lines.push('use serde::{Deserialize, Serialize};'); lines.push(''); for (const [tableName, table] of Object.entries(schema.tables)) { const structName = tableName.split('_') .map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(''); lines.push(`/// ${table.description || tableName}`); lines.push('#[derive(Debug, Clone, Serialize, Deserialize)]'); lines.push(`pub struct Schema${structName} {`); for (const [colName, col] of Object.entries(table.columns)) { // Skip non-sync columns if syncOnly if (syncOnly && col.sync === false) continue; let rustType = typeMap.rust[col.type]; if (col.nullable) { rustType = `Option<${rustType}>`; } // Convert camelCase to snake_case for Rust let snakeName = colName.replace(/[A-Z]/g, m => `_${m.toLowerCase()}`); // Handle Rust reserved keywords const rustReserved = ['type', 'struct', 'enum', 'fn', 'mod', 'use', 'pub', 'self', 'super', 'crate']; if (rustReserved.includes(snakeName)) { snakeName = `r#${snakeName}`; } // Add serde rename if names differ (compare without r# prefix) const cleanSnakeName = snakeName.replace(/^r#/, ''); if (cleanSnakeName !== colName) { lines.push(` #[serde(rename = "${colName}")]`); } lines.push(` /// ${col.description || colName}`); lines.push(` pub ${snakeName}: ${rustType},`); } lines.push('}'); lines.push(''); } return lines.join('\n'); } // Generate validateSchema function function generateValidator(schema) { const lines = []; lines.push('/**'); lines.push(' * Schema Validator'); lines.push(' * Generated by schema/codegen.js'); lines.push(` * Schema version: ${schema.version}`); lines.push(' * DO NOT EDIT - regenerate with: yarn schema:codegen'); lines.push(' */'); lines.push(''); lines.push('/**'); lines.push(' * Validate that a database has all required sync columns.'); lines.push(' * Works with any SQLite wrapper that supports PRAGMA table_info.'); lines.push(' *'); lines.push(' * @param {Function} getColumns - Function that takes table name and returns column names array'); lines.push(' * @returns {{ valid: boolean, missing: string[] }} Validation result'); lines.push(' */'); lines.push('export function validateSyncSchema(getColumns) {'); lines.push(' const required = {'); for (const [tableName, cols] of Object.entries(schema.validation.required_sync_columns)) { lines.push(` ${tableName}: ${JSON.stringify(cols)},`); } lines.push(' };'); lines.push(''); lines.push(' const missing = [];'); lines.push(''); lines.push(' for (const [table, cols] of Object.entries(required)) {'); lines.push(' const actual = new Set(getColumns(table));'); lines.push(' for (const col of cols) {'); lines.push(' if (!actual.has(col)) {'); lines.push(' missing.push(`${table}.${col}`);'); lines.push(' }'); lines.push(' }'); lines.push(' }'); lines.push(''); lines.push(' return {'); lines.push(' valid: missing.length === 0,'); lines.push(' missing,'); lines.push(' };'); lines.push('}'); lines.push(''); lines.push('/**'); lines.push(' * Validate schema and throw if invalid.'); lines.push(' * @param {Function} getColumns - Function that takes table name and returns column names array'); lines.push(' * @throws {Error} If required columns are missing'); lines.push(' */'); lines.push('export function assertValidSyncSchema(getColumns) {'); lines.push(' const result = validateSyncSchema(getColumns);'); lines.push(' if (!result.valid) {'); lines.push(' throw new Error('); lines.push(' `[schema] Required columns missing: ${result.missing.join(", ")}. ` +'); lines.push(' `Database may need migration. See schema/v1.json for canonical schema.`'); lines.push(' );'); lines.push(' }'); lines.push('}'); lines.push(''); lines.push('/** Schema version */'); lines.push(`export const SCHEMA_VERSION = ${schema.version};`); lines.push(''); lines.push('/** Required sync columns by table */'); lines.push('export const REQUIRED_SYNC_COLUMNS = {'); for (const [tableName, cols] of Object.entries(schema.validation.required_sync_columns)) { lines.push(` ${tableName}: ${JSON.stringify(cols)},`); } lines.push('};'); lines.push(''); return lines.join('\n'); } // Main function main() { console.log('[codegen] Reading schema/v1.json...'); const schemaPath = join(__dirname, 'v1.json'); const content = readFileSync(schemaPath, 'utf-8'); const schema = JSON.parse(content); console.log(`[codegen] Schema version: ${schema.version}`); console.log(`[codegen] Tables: ${Object.keys(schema.tables).join(', ')}`); const outDir = join(__dirname, 'generated'); mkdirSync(outDir, { recursive: true }); // Generate full SQLite (all columns) console.log('[codegen] Generating SQLite (full)...'); const sqliteFull = generateSqlite(schema, false); writeFileSync(join(outDir, 'sqlite-full.sql'), sqliteFull); // Generate sync-only SQLite (for server) console.log('[codegen] Generating SQLite (sync-only)...'); const sqliteSync = generateSqlite(schema, true); writeFileSync(join(outDir, 'sqlite-sync.sql'), sqliteSync); // Generate TypeScript console.log('[codegen] Generating TypeScript...'); const typescript = generateTypescript(schema, false); writeFileSync(join(outDir, 'types.ts'), typescript); // Generate Rust console.log('[codegen] Generating Rust...'); const rust = generateRust(schema, false); writeFileSync(join(outDir, 'types.rs'), rust); // Generate validator console.log('[codegen] Generating validator...'); const validator = generateValidator(schema); writeFileSync(join(outDir, 'validate.js'), validator); console.log('[codegen] Done! Generated files in schema/generated/'); console.log(' - sqlite-full.sql (all columns for desktop)'); console.log(' - sqlite-sync.sql (sync columns only for server)'); console.log(' - types.ts (TypeScript interfaces)'); console.log(' - types.rs (Rust structs)'); console.log(' - validate.js (Schema validator)'); } main();