experiments in a post-browser web
at main 314 lines 11 kB view raw
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 12import { readFileSync, writeFileSync, mkdirSync } from 'fs'; 13import { dirname, join } from 'path'; 14import { fileURLToPath } from 'url'; 15 16const __filename = fileURLToPath(import.meta.url); 17const __dirname = dirname(__filename); 18 19// Type mapping 20const 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 42function 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('-- DO NOT EDIT - regenerate with: yarn schema:codegen'); 47 lines.push(''); 48 49 for (const [tableName, table] of Object.entries(schema.tables)) { 50 lines.push(`-- ${table.description || tableName}`); 51 lines.push(`CREATE TABLE IF NOT EXISTS ${tableName} (`); 52 53 const columnDefs = []; 54 for (const [colName, col] of Object.entries(table.columns)) { 55 // Skip non-sync columns if syncOnly 56 if (syncOnly && col.sync === false) continue; 57 58 let def = ` ${colName} ${typeMap.sqlite[col.type]}`; 59 60 if (col.primary_key) def += ' PRIMARY KEY'; 61 if (col.not_null) def += ' NOT NULL'; 62 if (col.unique) def += ' UNIQUE'; 63 if (col.check) def += ` CHECK(${col.check})`; 64 if (col.default !== undefined) { 65 def += ` DEFAULT ${col.default}`; 66 } 67 68 columnDefs.push(def); 69 } 70 71 lines.push(columnDefs.join(',\n')); 72 lines.push(');'); 73 lines.push(''); 74 75 // Indexes 76 for (const idx of (table.indexes || [])) { 77 // Skip non-sync indexes if syncOnly 78 if (syncOnly && idx.sync === false) continue; 79 80 const unique = idx.unique ? 'UNIQUE ' : ''; 81 const cols = idx.columns.join(', '); 82 const order = idx.order ? ` ${idx.order}` : ''; 83 lines.push(`CREATE ${unique}INDEX IF NOT EXISTS ${idx.name} ON ${tableName}(${cols}${order});`); 84 } 85 lines.push(''); 86 } 87 88 return lines.join('\n'); 89} 90 91// Generate TypeScript interfaces 92function generateTypescript(schema, syncOnly = false) { 93 const lines = []; 94 lines.push('/**'); 95 lines.push(' * Generated by schema/codegen.js'); 96 lines.push(` * Schema version: ${schema.version}`); 97 lines.push(' * DO NOT EDIT - regenerate with: yarn schema:codegen'); 98 lines.push(' */'); 99 lines.push(''); 100 101 for (const [tableName, table] of Object.entries(schema.tables)) { 102 const interfaceName = tableName.charAt(0).toUpperCase() + 103 tableName.slice(1).replace(/_./g, x => x[1].toUpperCase()); 104 105 lines.push(`/** ${table.description || tableName} */`); 106 lines.push(`export interface Schema${interfaceName} {`); 107 108 for (const [colName, col] of Object.entries(table.columns)) { 109 // Skip non-sync columns if syncOnly 110 if (syncOnly && col.sync === false) continue; 111 112 const tsType = typeMap.typescript[col.type]; 113 const nullable = col.nullable ? ' | null' : ''; 114 const comment = col.description ? ` /** ${col.description} */` : ''; 115 116 if (comment) lines.push(comment); 117 lines.push(` ${colName}: ${tsType}${nullable};`); 118 } 119 120 lines.push('}'); 121 lines.push(''); 122 } 123 124 // Generate table names type 125 lines.push('/** Valid sync table names */'); 126 lines.push('export type SchemaSyncTableName = ' + 127 Object.keys(schema.tables).map(t => `'${t}'`).join(' | ') + ';'); 128 lines.push(''); 129 130 // Generate validation constants 131 lines.push('/** Required sync columns by table */'); 132 lines.push('export const REQUIRED_SYNC_COLUMNS: Record<SchemaSyncTableName, string[]> = {'); 133 for (const [tableName, cols] of Object.entries(schema.validation.required_sync_columns)) { 134 lines.push(` ${tableName}: ${JSON.stringify(cols)},`); 135 } 136 lines.push('};'); 137 lines.push(''); 138 139 return lines.join('\n'); 140} 141 142// Generate Rust structs 143function generateRust(schema, syncOnly = false) { 144 const lines = []; 145 lines.push('// Generated by schema/codegen.js'); 146 lines.push(`// Schema version: ${schema.version}`); 147 lines.push('// DO NOT EDIT - regenerate with: yarn schema:codegen'); 148 lines.push(''); 149 lines.push('use serde::{Deserialize, Serialize};'); 150 lines.push(''); 151 152 for (const [tableName, table] of Object.entries(schema.tables)) { 153 const structName = tableName.split('_') 154 .map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(''); 155 156 lines.push(`/// ${table.description || tableName}`); 157 lines.push('#[derive(Debug, Clone, Serialize, Deserialize)]'); 158 lines.push(`pub struct Schema${structName} {`); 159 160 for (const [colName, col] of Object.entries(table.columns)) { 161 // Skip non-sync columns if syncOnly 162 if (syncOnly && col.sync === false) continue; 163 164 let rustType = typeMap.rust[col.type]; 165 if (col.nullable) { 166 rustType = `Option<${rustType}>`; 167 } 168 169 // Convert camelCase to snake_case for Rust 170 let snakeName = colName.replace(/[A-Z]/g, m => `_${m.toLowerCase()}`); 171 172 // Handle Rust reserved keywords 173 const rustReserved = ['type', 'struct', 'enum', 'fn', 'mod', 'use', 'pub', 'self', 'super', 'crate']; 174 if (rustReserved.includes(snakeName)) { 175 snakeName = `r#${snakeName}`; 176 } 177 178 // Add serde rename if names differ (compare without r# prefix) 179 const cleanSnakeName = snakeName.replace(/^r#/, ''); 180 if (cleanSnakeName !== colName) { 181 lines.push(` #[serde(rename = "${colName}")]`); 182 } 183 184 lines.push(` /// ${col.description || colName}`); 185 lines.push(` pub ${snakeName}: ${rustType},`); 186 } 187 188 lines.push('}'); 189 lines.push(''); 190 } 191 192 return lines.join('\n'); 193} 194 195// Generate validateSchema function 196function generateValidator(schema) { 197 const lines = []; 198 lines.push('/**'); 199 lines.push(' * Schema Validator'); 200 lines.push(' * Generated by schema/codegen.js'); 201 lines.push(` * Schema version: ${schema.version}`); 202 lines.push(' * DO NOT EDIT - regenerate with: yarn schema:codegen'); 203 lines.push(' */'); 204 lines.push(''); 205 lines.push('/**'); 206 lines.push(' * Validate that a database has all required sync columns.'); 207 lines.push(' * Works with any SQLite wrapper that supports PRAGMA table_info.'); 208 lines.push(' *'); 209 lines.push(' * @param {Function} getColumns - Function that takes table name and returns column names array'); 210 lines.push(' * @returns {{ valid: boolean, missing: string[] }} Validation result'); 211 lines.push(' */'); 212 lines.push('export function validateSyncSchema(getColumns) {'); 213 lines.push(' const required = {'); 214 215 for (const [tableName, cols] of Object.entries(schema.validation.required_sync_columns)) { 216 lines.push(` ${tableName}: ${JSON.stringify(cols)},`); 217 } 218 219 lines.push(' };'); 220 lines.push(''); 221 lines.push(' const missing = [];'); 222 lines.push(''); 223 lines.push(' for (const [table, cols] of Object.entries(required)) {'); 224 lines.push(' const actual = new Set(getColumns(table));'); 225 lines.push(' for (const col of cols) {'); 226 lines.push(' if (!actual.has(col)) {'); 227 lines.push(' missing.push(`${table}.${col}`);'); 228 lines.push(' }'); 229 lines.push(' }'); 230 lines.push(' }'); 231 lines.push(''); 232 lines.push(' return {'); 233 lines.push(' valid: missing.length === 0,'); 234 lines.push(' missing,'); 235 lines.push(' };'); 236 lines.push('}'); 237 lines.push(''); 238 lines.push('/**'); 239 lines.push(' * Validate schema and throw if invalid.'); 240 lines.push(' * @param {Function} getColumns - Function that takes table name and returns column names array'); 241 lines.push(' * @throws {Error} If required columns are missing'); 242 lines.push(' */'); 243 lines.push('export function assertValidSyncSchema(getColumns) {'); 244 lines.push(' const result = validateSyncSchema(getColumns);'); 245 lines.push(' if (!result.valid) {'); 246 lines.push(' throw new Error('); 247 lines.push(' `[schema] Required columns missing: ${result.missing.join(", ")}. ` +'); 248 lines.push(' `Database may need migration. See schema/v1.json for canonical schema.`'); 249 lines.push(' );'); 250 lines.push(' }'); 251 lines.push('}'); 252 lines.push(''); 253 lines.push('/** Schema version */'); 254 lines.push(`export const SCHEMA_VERSION = ${schema.version};`); 255 lines.push(''); 256 lines.push('/** Required sync columns by table */'); 257 lines.push('export const REQUIRED_SYNC_COLUMNS = {'); 258 for (const [tableName, cols] of Object.entries(schema.validation.required_sync_columns)) { 259 lines.push(` ${tableName}: ${JSON.stringify(cols)},`); 260 } 261 lines.push('};'); 262 lines.push(''); 263 264 return lines.join('\n'); 265} 266 267// Main 268function main() { 269 console.log('[codegen] Reading schema/v1.json...'); 270 271 const schemaPath = join(__dirname, 'v1.json'); 272 const content = readFileSync(schemaPath, 'utf-8'); 273 const schema = JSON.parse(content); 274 275 console.log(`[codegen] Schema version: ${schema.version}`); 276 console.log(`[codegen] Tables: ${Object.keys(schema.tables).join(', ')}`); 277 278 const outDir = join(__dirname, 'generated'); 279 mkdirSync(outDir, { recursive: true }); 280 281 // Generate full SQLite (all columns) 282 console.log('[codegen] Generating SQLite (full)...'); 283 const sqliteFull = generateSqlite(schema, false); 284 writeFileSync(join(outDir, 'sqlite-full.sql'), sqliteFull); 285 286 // Generate sync-only SQLite (for server) 287 console.log('[codegen] Generating SQLite (sync-only)...'); 288 const sqliteSync = generateSqlite(schema, true); 289 writeFileSync(join(outDir, 'sqlite-sync.sql'), sqliteSync); 290 291 // Generate TypeScript 292 console.log('[codegen] Generating TypeScript...'); 293 const typescript = generateTypescript(schema, false); 294 writeFileSync(join(outDir, 'types.ts'), typescript); 295 296 // Generate Rust 297 console.log('[codegen] Generating Rust...'); 298 const rust = generateRust(schema, false); 299 writeFileSync(join(outDir, 'types.rs'), rust); 300 301 // Generate validator 302 console.log('[codegen] Generating validator...'); 303 const validator = generateValidator(schema); 304 writeFileSync(join(outDir, 'validate.js'), validator); 305 306 console.log('[codegen] Done! Generated files in schema/generated/'); 307 console.log(' - sqlite-full.sql (all columns for desktop)'); 308 console.log(' - sqlite-sync.sql (sync columns only for server)'); 309 console.log(' - types.ts (TypeScript interfaces)'); 310 console.log(' - types.rs (Rust structs)'); 311 console.log(' - validate.js (Schema validator)'); 312} 313 314main();