convex-schema-translator.ts249 lines · main
1import { ValidationError } from '@briven/shared';
2
3/**
4 * Convex schema → briven schema DSL translator (offline; no convex
5 * deployment required). Accepts the literal TypeScript source of the
6 * customer's convex/schema.ts (paste-in flow from the admin row) and
7 * emits a draft briven/schema.ts the operator reviews before shipping.
8 *
9 * Scope today: defineTable() with v.string/number/int64/boolean/id/
10 * optional. union-of-literal types fall through to text() + a TODO
11 * comment so the operator picks the validation approach. Indexes parse
12 * but become inline TODOs in the briven schema (we don't have a
13 * stable enough indexes-on-table DSL output yet).
14 *
15 * Errors: ValidationError on completely unparseable input. Warnings
16 * collected per-table for the operator to read.
17 */
18
19const INPUT_CAP = 100_000;
20
21export interface TranslationResult {
22 brivenSchema: string;
23 warnings: readonly string[];
24 tables: ReadonlyArray<{ name: string; columns: number }>;
25}
26
27export function translateConvexSchema(source: string): TranslationResult {
28 if (!source || typeof source !== 'string') {
29 throw new ValidationError('schema source is required');
30 }
31 if (source.length > INPUT_CAP) {
32 throw new ValidationError(`schema source exceeds ${INPUT_CAP}-character cap`);
33 }
34
35 const warnings: string[] = [];
36 const tables: { name: string; columns: number }[] = [];
37
38 // Strip TS comments (line + block) so the parser doesn't trip on
39 // // defineTable references inside comments. Crude but reliable.
40 const cleaned = source
41 .replace(/\/\/[^\n]*/g, '')
42 .replace(/\/\*[\s\S]*?\*\//g, '');
43
44 // Find every `<key>: defineTable({ ... })` block. We don't try to
45 // parse a full TS AST — a balanced-brace walk after the open is
46 // enough for the patterns convex users actually write.
47 const tableRegex = /(\w+)\s*:\s*defineTable\s*\(\s*{/g;
48 const lines: string[] = [];
49 let match: RegExpExecArray | null;
50
51 while ((match = tableRegex.exec(cleaned)) !== null) {
52 const tableName = match[1]!;
53 const bodyStart = match.index + match[0].length;
54 const bodyEnd = findMatchingBrace(cleaned, bodyStart);
55 if (bodyEnd === -1) {
56 warnings.push(
57 `${tableName}: unbalanced braces in defineTable body — skipped. operator should hand-port this table.`,
58 );
59 continue;
60 }
61 const body = cleaned.slice(bodyStart, bodyEnd);
62 const columns = parseColumns(body, tableName, warnings);
63 tables.push({ name: tableName, columns: columns.length });
64 lines.push(emitTable(tableName, columns));
65 }
66
67 if (tables.length === 0) {
68 warnings.push(
69 'no defineTable() calls found. paste only the schema.ts content (defineSchema + every defineTable).',
70 );
71 }
72
73 const brivenSchema = renderSchemaFile(lines, tables);
74 return { brivenSchema, warnings, tables };
75}
76
77interface ColumnDecl {
78 name: string;
79 brivenType: string;
80 notNull: boolean;
81 references?: string;
82 todoComment?: string;
83}
84
85function findMatchingBrace(s: string, start: number): number {
86 let depth = 1;
87 for (let i = start; i < s.length; i++) {
88 const c = s[i];
89 if (c === '{') depth++;
90 else if (c === '}') {
91 depth--;
92 if (depth === 0) return i;
93 }
94 }
95 return -1;
96}
97
98const SIMPLE_TYPE_MAP: Record<string, string> = {
99 'v.string()': 'text()',
100 'v.number()': 'bigint()',
101 'v.int64()': 'bigint()',
102 'v.float64()': 'bigint()',
103 'v.boolean()': 'boolean()',
104 'v.bytes()': 'text()',
105 'v.null()': 'text()',
106 'v.any()': 'jsonb()',
107};
108
109function parseColumns(
110 body: string,
111 tableName: string,
112 warnings: string[],
113): ColumnDecl[] {
114 // Match `name: <expr>` pairs at the top level of the object literal.
115 // We walk the body char-by-char to handle nested parens/braces inside
116 // each value expression.
117 const cols: ColumnDecl[] = [];
118 let i = 0;
119 while (i < body.length) {
120 // Skip whitespace and commas.
121 while (i < body.length && /[\s,]/.test(body[i]!)) i++;
122 if (i >= body.length) break;
123 // Read name.
124 const nameStart = i;
125 while (i < body.length && /\w/.test(body[i]!)) i++;
126 const name = body.slice(nameStart, i);
127 if (!name) {
128 i++;
129 continue;
130 }
131 // Expect colon.
132 while (i < body.length && /\s/.test(body[i]!)) i++;
133 if (body[i] !== ':') {
134 // Not a key:value — probably a `.index(...)` call hanging off
135 // the closing brace. Bail.
136 break;
137 }
138 i++;
139 // Read value until top-level comma or end.
140 const valueStart = i;
141 let depth = 0;
142 while (i < body.length) {
143 const c = body[i]!;
144 if (c === '(' || c === '{' || c === '[') depth++;
145 else if (c === ')' || c === '}' || c === ']') depth--;
146 else if (c === ',' && depth === 0) break;
147 i++;
148 }
149 const valueExpr = body.slice(valueStart, i).trim();
150 const col = translateColumn(name, valueExpr, tableName, warnings);
151 if (col) cols.push(col);
152 }
153 return cols;
154}
155
156function translateColumn(
157 name: string,
158 expr: string,
159 tableName: string,
160 warnings: string[],
161): ColumnDecl | null {
162 let notNull = true;
163 let working = expr;
164 // Peel one layer of v.optional(...).
165 const optMatch = /^v\.optional\s*\(\s*([\s\S]+)\s*\)$/.exec(working);
166 if (optMatch) {
167 notNull = false;
168 working = optMatch[1]!.trim();
169 }
170 // v.id("users") → text().references("users", "id")
171 const idMatch = /^v\.id\s*\(\s*['"]([^'"]+)['"]\s*\)$/.exec(working);
172 if (idMatch) {
173 return {
174 name,
175 brivenType: `text().references('${idMatch[1]}', 'id')`,
176 notNull,
177 references: idMatch[1],
178 };
179 }
180 // Simple types.
181 if (SIMPLE_TYPE_MAP[working]) {
182 return { name, brivenType: SIMPLE_TYPE_MAP[working]!, notNull };
183 }
184 // Array / object — store as jsonb.
185 if (working.startsWith('v.array(') || working.startsWith('v.object(')) {
186 return {
187 name,
188 brivenType: 'jsonb()',
189 notNull,
190 todoComment: 'convex v.array/v.object → jsonb; refine to nested table if you query into it',
191 };
192 }
193 // Union — usually v.union(v.literal("a"), v.literal("b")). Treat as
194 // text() + a TODO. The narrower validation gets done in the function
195 // layer with zod, mirroring the docs guidance.
196 if (working.startsWith('v.union(')) {
197 return {
198 name,
199 brivenType: 'text()',
200 notNull,
201 todoComment: 'convex v.union — validate this enum at the function layer with zod',
202 };
203 }
204 warnings.push(
205 `${tableName}.${name}: couldn't translate "${expr.slice(0, 60)}". emitting text() — review manually.`,
206 );
207 return {
208 name,
209 brivenType: 'text()',
210 notNull,
211 todoComment: `unrecognised convex type: ${expr.slice(0, 80)}`,
212 };
213}
214
215function emitTable(name: string, columns: ColumnDecl[]): string {
216 const colLines = columns
217 .map((c) => {
218 const todo = c.todoComment ? ` // TODO: ${c.todoComment}\n` : '';
219 const notNull = c.notNull ? '.notNull()' : '';
220 return `${todo} ${c.name}: ${c.brivenType}${notNull},`;
221 })
222 .join('\n');
223 return ` ${name}: table({
224 columns: {
225${colLines}
226 },
227 }),`;
228}
229
230function renderSchemaFile(
231 tableLines: string[],
232 tables: { name: string; columns: number }[],
233): string {
234 const header = `import { bigint, boolean, jsonb, schema, table, text } from '@briven/cli/schema';
235
236// Generated from convex/schema.ts by the briven schema translator.
237// Review every TODO before deploying. Convex's _creationTime is implicit
238// in convex but explicit in briven — add createdAt: bigint().notNull()
239// to tables that need it. Convex indexes (.index("by_x", ["x"])) move
240// to a table-level \`indexes: [{ columns: ['x'] }]\` array; not yet
241// auto-emitted by the translator.
242
243`;
244 const summary = `// translator summary: ${tables.length} table${tables.length === 1 ? '' : 's'} — ${tables.map((t) => `${t.name}(${t.columns})`).join(', ') || 'none'}.\n\n`;
245 return `${header}${summary}export default schema({
246${tableLines.join('\n\n')}
247});
248`;
249}