experiments in a post-browser web
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();