allez-orm 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,436 +1,581 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Allez ORM – schema generator CLI
4
- *
5
- * Generates a <table>.schema.js with:
6
- * - CREATE TABLE with inline foreign keys: col TYPE REFERENCES target(id) [ON DELETE ...]
7
- * - Optional "stamps": created_at, updated_at, deleted_at
8
- * - Optional unique / not-null markers
9
- * - Optional ON DELETE behavior for *all* FKs via --onDelete=
10
- * - (No extraSQL output by default)
11
- * - Auto-create stub schemas for FK target tables if missing
12
- *
13
- * New:
14
- * - from-json <file>: bulk-generate schemas from a JSON config (fields can be objects or string tokens)
15
- * - --print-json-schema: output the JSON Schema used for validation
16
- *
17
- * Usage:
18
- * allez-orm create table <name> [fields...] [--dir=schemas_cli] [--stamps] [-f|--force] [--onDelete=cascade|restrict|setnull|noaction]
19
- * allez-orm from-json <config.json> [--dir=schemas_cli] [-f|--force]
20
- * allez-orm --print-json-schema
21
- */
22
-
23
- import fs from "node:fs";
24
- import path from "node:path";
25
- import process from "node:process";
26
-
27
- const argv = process.argv.slice(2);
28
-
29
- const usage = () => {
30
- console.log(`
31
- Usage:
32
- allez-orm create table <name> [options] [fields...]
33
- allez-orm from-json <config.json> [--dir=<outDir>] [-f|--force]
34
- allez-orm --print-json-schema
35
-
36
- Options:
37
- --dir=<outDir> Output directory (default: schemas_cli)
38
- --stamps Add created_at, updated_at, deleted_at columns
39
- --onDelete=<mode> ON DELETE for *all* FKs (cascade|restrict|setnull|noaction). Default: none
40
- -f, --force Overwrite existing files
41
- --help Show help
42
-
43
- Field syntax:
44
- col[:type][!][+][->target] or "col:type,unique,notnull"
45
- Examples: email:text!+ user_id:text->users org_id:integer->orgs
46
- `);
47
- };
48
-
49
- const die = (m, code = 1) => { console.error(m); process.exit(code); };
50
-
51
- function parseOptions(args) {
52
- const out = {
53
- dir: "schemas_cli",
54
- stamps: false,
55
- onDelete: null,
56
- force: false,
57
- cmd: null,
58
- sub: null,
59
- table: null,
60
- fields: [],
61
- jsonFile: null,
62
- printJsonSchema: false,
63
- };
64
- const positional = [];
65
- for (const a of args) {
66
- if (a === "--help" || a === "-h") {
67
- usage(); process.exit(0);
68
- } else if (a.startsWith("--dir=")) {
69
- out.dir = a.slice(6);
70
- } else if (a === "--stamps") {
71
- out.stamps = true;
72
- } else if (a.startsWith("--onDelete=")) {
73
- const v = a.slice(11).toLowerCase();
74
- if (!["cascade","restrict","setnull","noaction"].includes(v)) {
75
- die(`Invalid --onDelete value: ${v}`);
76
- }
77
- out.onDelete = v;
78
- } else if (a === "-f" || a === "--force") {
79
- out.force = true;
80
- } else if (a === "--print-json-schema") {
81
- out.printJsonSchema = true;
82
- } else if (a.startsWith("-")) {
83
- die(`Unknown option: ${a}`);
84
- } else {
85
- positional.push(a);
86
- }
87
- }
88
-
89
- if (process.env.ALLEZ_FORCE === "1") out.force = true;
90
-
91
- out.cmd = positional[0] || null;
92
- out.sub = positional[1] || null;
93
-
94
- if (out.cmd === "create" && out.sub === "table") {
95
- out.table = positional[2] || null;
96
- out.fields = positional.slice(3);
97
- } else if (out.cmd === "from-json") {
98
- out.jsonFile = positional[1] || null;
99
- }
100
-
101
- return out;
102
- }
103
-
104
- const opts = parseOptions(argv);
105
-
106
- // ---------------- JSON Schema (string) ----------------
107
-
108
- const CONFIG_JSON_SCHEMA = JSON.stringify({
109
- $schema: "https://json-schema.org/draft/2020-12/schema",
110
- $id: "https://allez-orm.dev/allez.config.schema.json",
111
- type: "object",
112
- additionalProperties: false,
113
- properties: {
114
- outDir: { type: "string" },
115
- defaultOnDelete: { enum: ["cascade","restrict","setnull","noaction",null] },
116
- tables: {
117
- type: "array",
118
- items: {
119
- type: "object",
120
- additionalProperties: false,
121
- properties: {
122
- name: { type: "string", minLength: 1 },
123
- stamps: { type: "boolean" },
124
- // Accept either rich field objects or simple string tokens.
125
- fields: {
126
- type: "array",
127
- items: {
128
- anyOf: [
129
- {
130
- type: "object",
131
- additionalProperties: false,
132
- required: ["name"],
133
- properties: {
134
- name: { type: "string" },
135
- type: { type: "string" },
136
- unique: { type: "boolean" },
137
- notnull: { type: "boolean" },
138
- fk: {
139
- type: ["object", "null"],
140
- additionalProperties: false,
141
- properties: {
142
- table: { type: "string" },
143
- column: { type: "string", default: "id" }
144
- }
145
- }
146
- }
147
- },
148
- { type: "string" } // token form: "email:text!+->users"
149
- ]
150
- }
151
- }
152
- },
153
- required: ["name","fields"]
154
- }
155
- }
156
- },
157
- required: ["tables"]
158
- }, null, 2);
159
-
160
- // ---------------- command switchboard ----------------
161
-
162
- if (opts.printJsonSchema) {
163
- console.log(CONFIG_JSON_SCHEMA);
164
- process.exit(0);
165
- }
166
-
167
- if (!opts.cmd) {
168
- usage();
169
- process.exit(0);
170
- }
171
-
172
- if (opts.cmd === "from-json") {
173
- if (!opts.jsonFile) die("from-json requires a <config.json> path");
174
- runFromJson(opts).catch(e => die(e.stack || String(e)));
175
- } else if (opts.cmd === "create" && opts.sub === "table" && opts.table) {
176
- fs.mkdirSync(opts.dir, { recursive: true });
177
- generateOne({
178
- outDir: opts.dir,
179
- name: opts.table,
180
- stamps: opts.stamps,
181
- onDelete: opts.onDelete,
182
- force: opts.force,
183
- fieldTokens: opts.fields
184
- }).then(() => process.exit(0))
185
- .catch(e => die(e.stack || String(e)));
186
- } else {
187
- usage();
188
- die("Expected: create table <name> … or from-json <config.json>");
189
- }
190
-
191
- // ---------------- core generator (shared) ----------------
192
-
193
- async function generateOne({ outDir, name, stamps, onDelete, force, fieldTokens }) {
194
- const outFile = path.join(outDir, `${name}.schema.js`);
195
- if (!force && fs.existsSync(outFile)) {
196
- die(`Refusing to overwrite existing file: ${outFile}\n(use -f or ALLEZ_FORCE=1)`);
197
- }
198
-
199
- // Parse tokens -> field descriptors
200
- const fields = fieldTokens.map(parseFieldToken).filter(Boolean);
201
-
202
- // Ensure id PK
203
- if (!fields.some(f => f.name === "id")) {
204
- fields.unshift({ name: "id", type: "INTEGER", notnull: true, unique: false, fk: null, pk: true });
205
- }
206
-
207
- // stamps
208
- if (stamps) {
209
- fields.push(
210
- { name: "created_at", type: "TEXT", notnull: true },
211
- { name: "updated_at", type: "TEXT", notnull: true },
212
- { name: "deleted_at", type: "TEXT", notnull: false }
213
- );
214
- }
215
-
216
- // Fast column assembly
217
- const onDel = onDelete ? ({ cascade:"CASCADE", restrict:"RESTRICT", setnull:"SET NULL", noaction:"NO ACTION" })[onDelete] : null;
218
- const columnLines = fields.map(f => sqlForColumn(f, onDel));
219
-
220
- // Module text — no extraSQL emitted
221
- const moduleText =
222
- `// ${name}.schema.js (generated by tools/allez-orm.mjs)
223
- const ${camel(name)}Schema = {
224
- table: "${name}",
225
- version: 1,
226
- createSQL: \`
227
- CREATE TABLE IF NOT EXISTS ${name} (
228
- ${columnLines.join(",\n ")}
229
- );\`
230
- };
231
- export default ${camel(name)}Schema;
232
- `;
233
-
234
- fs.mkdirSync(outDir, { recursive: true });
235
- fs.writeFileSync(outFile, moduleText, "utf8");
236
- console.log(`Wrote ${outFile}`);
237
-
238
- // Stub FK targets (only if not self & missing)
239
- const fkTargets = new Set();
240
- for (const f of fields) if (f.fk && f.fk.table && f.fk.table !== name) fkTargets.add(f.fk.table);
241
- for (const t of fkTargets) {
242
- const stubPath = path.join(outDir, `${t}.schema.js`);
243
- if (!fs.existsSync(stubPath)) {
244
- const stub =
245
- `// ${t}.schema.js (generated by tools/allez-orm.mjs - stub for FK target)
246
- const ${camel(t)}Schema = {
247
- table: "${t}",
248
- version: 1,
249
- createSQL: \`
250
- CREATE TABLE IF NOT EXISTS ${t} (
251
- id INTEGER PRIMARY KEY AUTOINCREMENT
252
- );\`
253
- };
254
- export default ${camel(t)}Schema;
255
- `;
256
- fs.writeFileSync(stubPath, stub, "utf8");
257
- console.log(`Wrote stub ${stubPath}`);
258
- }
259
- }
260
- }
261
-
262
- function sqlForColumn(f, onDelUpper) {
263
- if (f.pk) return `id INTEGER PRIMARY KEY AUTOINCREMENT`;
264
- let s = `${f.name} ${f.type}`;
265
- // Keep order: UNIQUE then NOT NULL (matches tests/expectations)
266
- if (f.unique) s += ` UNIQUE`;
267
- if (f.notnull) s += ` NOT NULL`;
268
- if (f.fk) {
269
- s += ` REFERENCES ${f.fk.table}(${f.fk.column || "id"})`;
270
- if (onDelUpper) s += ` ON DELETE ${onDelUpper}`;
271
- }
272
- return s;
273
- }
274
-
275
- function parseFieldToken(tok) {
276
- // Accept:
277
- // - token string "col[:type][!][+][->target]" or "col:type,unique,notnull"
278
- // - object {name,type,unique,notnull,fk:{table,column}}
279
- if (tok == null) return null;
280
-
281
- // Fast path: token is already string
282
- if (typeof tok === "string") {
283
- return parseTokenString(tok);
284
- }
285
-
286
- // Object form -> tokenize once, then reuse the same parser
287
- if (typeof tok === "object") {
288
- const name = String(tok.name ?? tok.Name ?? "").trim();
289
- if (!name) return null;
290
- const type = String(tok.type ?? tok.Type ?? "TEXT").trim().toLowerCase();
291
- const unique = !!(tok.unique ?? tok.Unique);
292
- const notnull = !!(tok.notnull ?? tok.notNull ?? tok.NotNull);
293
- const fkRaw = tok.fk ?? tok.FK;
294
- const fkTable = fkRaw ? (fkRaw.table ?? fkRaw.Table) : null;
295
- const fkCol = fkRaw ? (fkRaw.column ?? fkRaw.Column ?? "id") : "id";
296
-
297
- let token = `${name}:${type}`;
298
- if (notnull) token += "!";
299
- if (unique) token += "+";
300
- if (fkTable) token += `->${fkTable}`;
301
-
302
- const parsed = parseTokenString(token);
303
- if (fkTable) parsed.fk.column = fkCol; // preserve non-id if provided
304
- return parsed;
305
- }
306
-
307
- return null;
308
- }
309
-
310
- function parseTokenString(tok) {
311
- // Split on "," for attribute list
312
- const ret = { name: "", type: "TEXT", notnull: false, unique: false, fk: null };
313
- let main = tok;
314
- let flags = [];
315
- if (tok.includes(",")) {
316
- const [lhs, ...rhs] = tok.split(",");
317
- main = lhs;
318
- flags = rhs.map(s => s.trim().toLowerCase());
319
- }
320
-
321
- // name : type -> target
322
- let name = main;
323
- let type = null;
324
- let fkTarget = null;
325
-
326
- const fkIdx = main.indexOf("->");
327
- if (fkIdx >= 0) {
328
- fkTarget = main.slice(fkIdx + 2).trim();
329
- name = main.slice(0, fkIdx);
330
- }
331
-
332
- const typeIdx = name.indexOf(":");
333
- if (typeIdx >= 0) {
334
- type = name.slice(typeIdx + 1).trim(); // may contain !/+
335
- name = name.slice(0, typeIdx).trim();
336
- } else {
337
- type = null;
338
- }
339
-
340
- // flags may appear on either side
341
- const nameHasBang = /!/.test(name);
342
- const nameHasPlus = /\+/.test(name);
343
- const typeHasBang = type ? /!/.test(type) : false;
344
- const typeHasPlus = type ? /\+/.test(type) : false;
345
-
346
- if (nameHasBang || typeHasBang) ret.notnull = true;
347
- if (nameHasPlus || typeHasPlus) ret.unique = true;
348
-
349
- name = name.replace(/[!+]+$/,"").trim();
350
- if (type) {
351
- type = type.replace(/[!+]+$/,"").trim();
352
- ret.type = type.toUpperCase();
353
- }
354
-
355
- if (fkTarget) ret.fk = { table: fkTarget, column: "id" };
356
-
357
- // also allow ",unique,notnull"
358
- for (const f of flags) {
359
- if (f === "unique") ret.unique = true;
360
- if (f === "notnull") ret.notnull = true;
361
- }
362
-
363
- ret.name = name;
364
- return ret;
365
- }
366
-
367
- function camel(s){return s.replace(/[-_](.)/g,(_,c)=>c.toUpperCase());}
368
-
369
- // ---------------- from-json implementation (resilient & fast) ----------------
370
-
371
- function pick(obj, ...keys) {
372
- for (const k of keys) { if (obj && obj[k] !== undefined) return obj[k]; }
373
- return undefined;
374
- }
375
-
376
- async function runFromJson(cliOpts) {
377
- const file = path.resolve(cliOpts.jsonFile);
378
- if (!fs.existsSync(file)) die(`Config not found: ${file}`);
379
-
380
- let cfg;
381
- try {
382
- cfg = JSON.parse(fs.readFileSync(file, "utf8"));
383
- } catch (e) {
384
- die(`Invalid JSON: ${e.message}`);
385
- }
386
-
387
- // allow OutDir/DefaultOnDelete/Tables
388
- const outDir = cliOpts.dir || pick(cfg, "outDir", "OutDir") || "schemas_cli";
389
- const defaultOnDelete = pick(cfg, "defaultOnDelete", "DefaultOnDelete") ?? null;
390
-
391
- const tables = pick(cfg, "tables", "Tables");
392
- if (!Array.isArray(tables)) die(`Config must have an array at "tables" (or "Tables").`);
393
-
394
- fs.mkdirSync(outDir, { recursive: true });
395
-
396
- for (let i = 0; i < tables.length; i++) {
397
- const t = tables[i] || {};
398
- const tName = pick(t, "name", "Name");
399
- if (!tName || typeof tName !== "string") die(`Table at index ${i} is missing "name".`);
400
-
401
- const tStamps = !!pick(t, "stamps", "Stamps");
402
-
403
- // Accept fields/Fields/columns/Columns; items can be objects or string tokens
404
- let fieldsList = pick(t, "fields", "Fields", "columns", "Columns");
405
- if (!Array.isArray(fieldsList)) die(`Table "${tName}" must have "fields" array.`);
406
-
407
- // Normalize to token strings for speed (object items are converted once)
408
- const tokens = fieldsList.map(f => {
409
- if (typeof f === "string") return f;
410
- const name = pick(f, "name", "Name");
411
- if (!name) die(`Table "${tName}" has a field without "name".`);
412
- const type = String(pick(f, "type", "Type") ?? "TEXT").toLowerCase();
413
- const unique = !!pick(f, "unique", "Unique");
414
- const notnull = !!pick(f, "notnull", "notNull", "NotNull");
415
- const fk = pick(f, "fk", "FK");
416
- const fkTable = fk ? pick(fk, "table", "Table") : null;
417
-
418
- let tok = `${name}:${type}`;
419
- if (notnull) tok += "!";
420
- if (unique) tok += "+";
421
- if (fkTable) tok += `->${fkTable}`;
422
- return tok;
423
- });
424
-
425
- await generateOne({
426
- outDir,
427
- name: tName,
428
- stamps: tStamps,
429
- onDelete: defaultOnDelete || null,
430
- force: cliOpts.force,
431
- fieldTokens: tokens
432
- });
433
- }
434
-
435
- process.exit(0);
436
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Allez ORM – schema generator CLI
4
+ *
5
+ * Generates a <table>.schema.js with:
6
+ * - CREATE TABLE with inline foreign keys: col TYPE REFERENCES target(id) [ON DELETE ...]
7
+ * - Optional "stamps": created_at, updated_at, deleted_at
8
+ * - Optional unique / not-null markers
9
+ * - Optional ON DELETE behavior for *all* FKs via --onDelete=
10
+ * - (No extraSQL output by default)
11
+ * - Auto-create stub schemas for FK target tables if missing
12
+ *
13
+ * Spec anchoring (so agents stay tied to the original spec):
14
+ * - Each generated file carries a SPEC header with the source spec path
15
+ * and SHA-256s for the whole spec and the per-table fragment.
16
+ * - `from-json` also writes an AGENTS.md in the output directory.
17
+ * - `allez-orm verify <spec.json>` re-generates in memory and reports drift.
18
+ *
19
+ * Usage:
20
+ * allez-orm create table <name> [fields...] [--dir=schemas_cli] [--stamps] [-f|--force] [--onDelete=cascade|restrict|setnull|noaction]
21
+ * allez-orm from-json <config.json> [--dir=schemas_cli] [-f|--force]
22
+ * allez-orm verify <config.json> [--dir=schemas_cli]
23
+ * allez-orm --print-json-schema
24
+ */
25
+
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+ import process from "node:process";
29
+ import crypto from "node:crypto";
30
+
31
+ const argv = process.argv.slice(2);
32
+
33
+ const usage = () => {
34
+ console.log(`
35
+ Usage:
36
+ allez-orm create table <name> [options] [fields...]
37
+ allez-orm from-json <config.json> [--dir=<outDir>] [-f|--force]
38
+ allez-orm verify <config.json> [--dir=<outDir>]
39
+ allez-orm --print-json-schema
40
+
41
+ Options:
42
+ --dir=<outDir> Output directory (default: schemas_cli)
43
+ --stamps Add created_at, updated_at, deleted_at columns
44
+ --onDelete=<mode> ON DELETE for *all* FKs (cascade|restrict|setnull|noaction). Default: none
45
+ -f, --force Overwrite existing files
46
+ --help Show help
47
+
48
+ Field syntax:
49
+ col[:type][!][+][->target] or "col:type,unique,notnull"
50
+ Examples: email:text!+ user_id:text->users org_id:integer->orgs
51
+ `);
52
+ };
53
+
54
+ const die = (m, code = 1) => { console.error(m); process.exit(code); };
55
+
56
+ function parseOptions(args) {
57
+ const out = {
58
+ dir: null,
59
+ dirExplicit: false,
60
+ stamps: false,
61
+ onDelete: null,
62
+ force: false,
63
+ cmd: null,
64
+ sub: null,
65
+ table: null,
66
+ fields: [],
67
+ jsonFile: null,
68
+ printJsonSchema: false,
69
+ };
70
+ const positional = [];
71
+ for (const a of args) {
72
+ if (a === "--help" || a === "-h") {
73
+ usage(); process.exit(0);
74
+ } else if (a.startsWith("--dir=")) {
75
+ out.dir = a.slice(6);
76
+ out.dirExplicit = true;
77
+ } else if (a === "--stamps") {
78
+ out.stamps = true;
79
+ } else if (a.startsWith("--onDelete=")) {
80
+ const v = a.slice(11).toLowerCase();
81
+ if (!["cascade","restrict","setnull","noaction"].includes(v)) {
82
+ die(`Invalid --onDelete value: ${v}`);
83
+ }
84
+ out.onDelete = v;
85
+ } else if (a === "-f" || a === "--force") {
86
+ out.force = true;
87
+ } else if (a === "--print-json-schema") {
88
+ out.printJsonSchema = true;
89
+ } else if (a.startsWith("-")) {
90
+ die(`Unknown option: ${a}`);
91
+ } else {
92
+ positional.push(a);
93
+ }
94
+ }
95
+
96
+ if (process.env.ALLEZ_FORCE === "1") out.force = true;
97
+
98
+ out.cmd = positional[0] || null;
99
+ out.sub = positional[1] || null;
100
+
101
+ if (out.cmd === "create" && out.sub === "table") {
102
+ out.table = positional[2] || null;
103
+ out.fields = positional.slice(3);
104
+ } else if (out.cmd === "from-json" || out.cmd === "verify") {
105
+ out.jsonFile = positional[1] || null;
106
+ }
107
+
108
+ return out;
109
+ }
110
+
111
+ const opts = parseOptions(argv);
112
+
113
+ // ---------------- JSON Schema (string) ----------------
114
+
115
+ const CONFIG_JSON_SCHEMA = JSON.stringify({
116
+ $schema: "https://json-schema.org/draft/2020-12/schema",
117
+ $id: "https://allez-orm.dev/allez.config.schema.json",
118
+ type: "object",
119
+ additionalProperties: false,
120
+ properties: {
121
+ outDir: { type: "string" },
122
+ defaultOnDelete: { enum: ["cascade","restrict","setnull","noaction",null] },
123
+ tables: {
124
+ type: "array",
125
+ items: {
126
+ type: "object",
127
+ additionalProperties: false,
128
+ properties: {
129
+ name: { type: "string", minLength: 1 },
130
+ stamps: { type: "boolean" },
131
+ // Accept either rich field objects or simple string tokens.
132
+ fields: {
133
+ type: "array",
134
+ items: {
135
+ anyOf: [
136
+ {
137
+ type: "object",
138
+ additionalProperties: false,
139
+ required: ["name"],
140
+ properties: {
141
+ name: { type: "string" },
142
+ type: { type: "string" },
143
+ unique: { type: "boolean" },
144
+ notnull: { type: "boolean" },
145
+ fk: {
146
+ type: ["object", "null"],
147
+ additionalProperties: false,
148
+ properties: {
149
+ table: { type: "string" },
150
+ column: { type: "string", default: "id" }
151
+ }
152
+ }
153
+ }
154
+ },
155
+ { type: "string" } // token form: "email:text!+->users"
156
+ ]
157
+ }
158
+ }
159
+ },
160
+ required: ["name","fields"]
161
+ }
162
+ }
163
+ },
164
+ required: ["tables"]
165
+ }, null, 2);
166
+
167
+ // ---------------- command switchboard ----------------
168
+
169
+ if (opts.printJsonSchema) {
170
+ console.log(CONFIG_JSON_SCHEMA);
171
+ process.exit(0);
172
+ }
173
+
174
+ if (!opts.cmd) {
175
+ usage();
176
+ process.exit(0);
177
+ }
178
+
179
+ if (opts.cmd === "from-json") {
180
+ if (!opts.jsonFile) die("from-json requires a <config.json> path");
181
+ runFromJson(opts).catch(e => die(e.stack || String(e)));
182
+ } else if (opts.cmd === "verify") {
183
+ if (!opts.jsonFile) die("verify requires a <config.json> path");
184
+ runVerify(opts).catch(e => die(e.stack || String(e)));
185
+ } else if (opts.cmd === "create" && opts.sub === "table" && opts.table) {
186
+ const outDir = opts.dir || "schemas_cli";
187
+ fs.mkdirSync(outDir, { recursive: true });
188
+ const text = buildModuleText({
189
+ name: opts.table,
190
+ stamps: opts.stamps,
191
+ onDelete: opts.onDelete,
192
+ fieldTokens: opts.fields,
193
+ specMeta: null, // CLI form has no spec to anchor to
194
+ });
195
+ writeModule({
196
+ outDir,
197
+ name: opts.table,
198
+ text,
199
+ force: opts.force,
200
+ fieldTokens: opts.fields,
201
+ });
202
+ process.exit(0);
203
+ } else {
204
+ usage();
205
+ die("Expected: create table <name> … or from-json <config.json> or verify <config.json>");
206
+ }
207
+
208
+ // ---------------- core generator (shared) ----------------
209
+
210
+ function resolveFields(name, stamps, fieldTokens) {
211
+ const fields = fieldTokens.map(parseFieldToken).filter(Boolean);
212
+ if (!fields.some(f => f.name === "id")) {
213
+ fields.unshift({ name: "id", type: "INTEGER", notnull: true, unique: false, fk: null, pk: true });
214
+ }
215
+ if (stamps) {
216
+ fields.push(
217
+ { name: "created_at", type: "TEXT", notnull: true },
218
+ { name: "updated_at", type: "TEXT", notnull: true },
219
+ { name: "deleted_at", type: "TEXT", notnull: false }
220
+ );
221
+ }
222
+ return fields;
223
+ }
224
+
225
+ function buildModuleText({ name, stamps, onDelete, fieldTokens, specMeta }) {
226
+ const fields = resolveFields(name, stamps, fieldTokens);
227
+ const onDel = onDelete ? ({ cascade:"CASCADE", restrict:"RESTRICT", setnull:"SET NULL", noaction:"NO ACTION" })[onDelete] : null;
228
+ const columnLines = fields.map(f => sqlForColumn(f, onDel));
229
+
230
+ const header = specMeta
231
+ ? `// ${name}.schema.js (generated by allez-orm)
232
+ // DO NOT EDIT — regenerate by editing the spec, then running:
233
+ // allez-orm from-json ${specMeta.specPath}
234
+ // SPEC ${specMeta.specPath}
235
+ // SPEC_SHA256 ${specMeta.specSha}
236
+ // TABLE_SHA256 ${specMeta.tableSha}
237
+ `
238
+ : `// ${name}.schema.js (generated by tools/allez-orm.mjs)\n`;
239
+
240
+ return `${header}const ${camel(name)}Schema = {
241
+ table: "${name}",
242
+ version: 1,
243
+ createSQL: \`
244
+ CREATE TABLE IF NOT EXISTS ${name} (
245
+ ${columnLines.join(",\n ")}
246
+ );\`
247
+ };
248
+ export default ${camel(name)}Schema;
249
+ `;
250
+ }
251
+
252
+ function buildStubText(targetTable) {
253
+ return `// ${targetTable}.schema.js (generated by tools/allez-orm.mjs - stub for FK target)
254
+ const ${camel(targetTable)}Schema = {
255
+ table: "${targetTable}",
256
+ version: 1,
257
+ createSQL: \`
258
+ CREATE TABLE IF NOT EXISTS ${targetTable} (
259
+ id INTEGER PRIMARY KEY AUTOINCREMENT
260
+ );\`
261
+ };
262
+ export default ${camel(targetTable)}Schema;
263
+ `;
264
+ }
265
+
266
+ function writeModule({ outDir, name, text, force, fieldTokens }) {
267
+ const outFile = path.join(outDir, `${name}.schema.js`);
268
+ if (!force && fs.existsSync(outFile)) {
269
+ die(`Refusing to overwrite existing file: ${outFile}\n(use -f or ALLEZ_FORCE=1)`);
270
+ }
271
+ fs.mkdirSync(outDir, { recursive: true });
272
+ fs.writeFileSync(outFile, text, "utf8");
273
+ console.log(`Wrote ${outFile}`);
274
+
275
+ // Stub FK targets (only if not self & missing)
276
+ const fields = (fieldTokens || []).map(parseFieldToken).filter(Boolean);
277
+ const fkTargets = new Set();
278
+ for (const f of fields) if (f.fk && f.fk.table && f.fk.table !== name) fkTargets.add(f.fk.table);
279
+ for (const t of fkTargets) {
280
+ const stubPath = path.join(outDir, `${t}.schema.js`);
281
+ if (!fs.existsSync(stubPath)) {
282
+ fs.writeFileSync(stubPath, buildStubText(t), "utf8");
283
+ console.log(`Wrote stub ${stubPath}`);
284
+ }
285
+ }
286
+ }
287
+
288
+ function sqlForColumn(f, onDelUpper) {
289
+ if (f.pk) return `id INTEGER PRIMARY KEY AUTOINCREMENT`;
290
+ let s = `${f.name} ${f.type}`;
291
+ // Keep order: UNIQUE then NOT NULL (matches tests/expectations)
292
+ if (f.unique) s += ` UNIQUE`;
293
+ if (f.notnull) s += ` NOT NULL`;
294
+ if (f.fk) {
295
+ s += ` REFERENCES ${f.fk.table}(${f.fk.column || "id"})`;
296
+ if (onDelUpper) s += ` ON DELETE ${onDelUpper}`;
297
+ }
298
+ return s;
299
+ }
300
+
301
+ function parseFieldToken(tok) {
302
+ // Accept:
303
+ // - token string "col[:type][!][+][->target]" or "col:type,unique,notnull"
304
+ // - object {name,type,unique,notnull,fk:{table,column}}
305
+ if (tok == null) return null;
306
+
307
+ // Fast path: token is already string
308
+ if (typeof tok === "string") {
309
+ return parseTokenString(tok);
310
+ }
311
+
312
+ // Object form -> tokenize once, then reuse the same parser
313
+ if (typeof tok === "object") {
314
+ const name = String(tok.name ?? tok.Name ?? "").trim();
315
+ if (!name) return null;
316
+ const type = String(tok.type ?? tok.Type ?? "TEXT").trim().toLowerCase();
317
+ const unique = !!(tok.unique ?? tok.Unique);
318
+ const notnull = !!(tok.notnull ?? tok.notNull ?? tok.NotNull);
319
+ const fkRaw = tok.fk ?? tok.FK;
320
+ const fkTable = fkRaw ? (fkRaw.table ?? fkRaw.Table) : null;
321
+ const fkCol = fkRaw ? (fkRaw.column ?? fkRaw.Column ?? "id") : "id";
322
+
323
+ let token = `${name}:${type}`;
324
+ if (notnull) token += "!";
325
+ if (unique) token += "+";
326
+ if (fkTable) token += `->${fkTable}`;
327
+
328
+ const parsed = parseTokenString(token);
329
+ if (fkTable) parsed.fk.column = fkCol; // preserve non-id if provided
330
+ return parsed;
331
+ }
332
+
333
+ return null;
334
+ }
335
+
336
+ function parseTokenString(tok) {
337
+ // Split on "," for attribute list
338
+ const ret = { name: "", type: "TEXT", notnull: false, unique: false, fk: null };
339
+ let main = tok;
340
+ let flags = [];
341
+ if (tok.includes(",")) {
342
+ const [lhs, ...rhs] = tok.split(",");
343
+ main = lhs;
344
+ flags = rhs.map(s => s.trim().toLowerCase());
345
+ }
346
+
347
+ // name : type -> target
348
+ let name = main;
349
+ let type = null;
350
+ let fkTarget = null;
351
+
352
+ const fkIdx = main.indexOf("->");
353
+ if (fkIdx >= 0) {
354
+ fkTarget = main.slice(fkIdx + 2).trim();
355
+ name = main.slice(0, fkIdx);
356
+ }
357
+
358
+ const typeIdx = name.indexOf(":");
359
+ if (typeIdx >= 0) {
360
+ type = name.slice(typeIdx + 1).trim(); // may contain !/+
361
+ name = name.slice(0, typeIdx).trim();
362
+ } else {
363
+ type = null;
364
+ }
365
+
366
+ // flags may appear on either side
367
+ const nameHasBang = /!/.test(name);
368
+ const nameHasPlus = /\+/.test(name);
369
+ const typeHasBang = type ? /!/.test(type) : false;
370
+ const typeHasPlus = type ? /\+/.test(type) : false;
371
+
372
+ if (nameHasBang || typeHasBang) ret.notnull = true;
373
+ if (nameHasPlus || typeHasPlus) ret.unique = true;
374
+
375
+ name = name.replace(/[!+]+$/,"").trim();
376
+ if (type) {
377
+ type = type.replace(/[!+]+$/,"").trim();
378
+ ret.type = type.toUpperCase();
379
+ }
380
+
381
+ if (fkTarget) ret.fk = { table: fkTarget, column: "id" };
382
+
383
+ // also allow ",unique,notnull"
384
+ for (const f of flags) {
385
+ if (f === "unique") ret.unique = true;
386
+ if (f === "notnull") ret.notnull = true;
387
+ }
388
+
389
+ ret.name = name;
390
+ return ret;
391
+ }
392
+
393
+ function camel(s){return s.replace(/[-_](.)/g,(_,c)=>c.toUpperCase());}
394
+
395
+ function sha256(s) {
396
+ return crypto.createHash("sha256").update(s).digest("hex");
397
+ }
398
+
399
+ // Canonical JSON of a table fragment stable across key order and irrelevant whitespace.
400
+ function canonicalTable(t) {
401
+ const name = pick(t, "name", "Name");
402
+ const stamps = !!pick(t, "stamps", "Stamps");
403
+ const rawFields = pick(t, "fields", "Fields", "columns", "Columns") || [];
404
+ const fields = rawFields.map(f => {
405
+ if (typeof f === "string") return { token: f };
406
+ return {
407
+ name: pick(f, "name", "Name"),
408
+ type: String(pick(f, "type", "Type") ?? "TEXT").toLowerCase(),
409
+ unique: !!pick(f, "unique", "Unique"),
410
+ notnull: !!pick(f, "notnull", "notNull", "NotNull"),
411
+ fk: (() => {
412
+ const fk = pick(f, "fk", "FK");
413
+ if (!fk) return null;
414
+ return { table: pick(fk, "table", "Table"), column: pick(fk, "column", "Column") ?? "id" };
415
+ })(),
416
+ };
417
+ });
418
+ return JSON.stringify({ name, stamps, fields });
419
+ }
420
+
421
+ // ---------------- from-json implementation (resilient & fast) ----------------
422
+
423
+ function pick(obj, ...keys) {
424
+ for (const k of keys) { if (obj && obj[k] !== undefined) return obj[k]; }
425
+ return undefined;
426
+ }
427
+
428
+ function loadSpec(jsonFile) {
429
+ const file = path.resolve(jsonFile);
430
+ if (!fs.existsSync(file)) die(`Config not found: ${file}`);
431
+ const raw = fs.readFileSync(file, "utf8");
432
+ let cfg;
433
+ try { cfg = JSON.parse(raw); } catch (e) { die(`Invalid JSON: ${e.message}`); }
434
+ const tables = pick(cfg, "tables", "Tables");
435
+ if (!Array.isArray(tables)) die(`Config must have an array at "tables" (or "Tables").`);
436
+ return { file, raw, cfg, tables, specSha: sha256(raw) };
437
+ }
438
+
439
+ function fieldsToTokens(fieldsList, tName) {
440
+ return fieldsList.map(f => {
441
+ if (typeof f === "string") return f;
442
+ const name = pick(f, "name", "Name");
443
+ if (!name) die(`Table "${tName}" has a field without "name".`);
444
+ const type = String(pick(f, "type", "Type") ?? "TEXT").toLowerCase();
445
+ const unique = !!pick(f, "unique", "Unique");
446
+ const notnull = !!pick(f, "notnull", "notNull", "NotNull");
447
+ const fk = pick(f, "fk", "FK");
448
+ const fkTable = fk ? pick(fk, "table", "Table") : null;
449
+ let tok = `${name}:${type}`;
450
+ if (notnull) tok += "!";
451
+ if (unique) tok += "+";
452
+ if (fkTable) tok += `->${fkTable}`;
453
+ return tok;
454
+ });
455
+ }
456
+
457
+ function planTables(spec, outDir) {
458
+ const defaultOnDelete = pick(spec.cfg, "defaultOnDelete", "DefaultOnDelete") ?? null;
459
+ const specPathRel = path.relative(path.resolve(outDir), spec.file).split(path.sep).join("/");
460
+
461
+ return spec.tables.map((t, i) => {
462
+ const name = pick(t, "name", "Name");
463
+ if (!name || typeof name !== "string") die(`Table at index ${i} is missing "name".`);
464
+ const stamps = !!pick(t, "stamps", "Stamps");
465
+ const fieldsList = pick(t, "fields", "Fields", "columns", "Columns");
466
+ if (!Array.isArray(fieldsList)) die(`Table "${name}" must have "fields" array.`);
467
+ const tableSha = sha256(canonicalTable(t));
468
+ const fieldTokens = fieldsToTokens(fieldsList, name);
469
+ return {
470
+ name,
471
+ stamps,
472
+ onDelete: defaultOnDelete || null,
473
+ fieldTokens,
474
+ specMeta: { specPath: specPathRel, specSha: spec.specSha, tableSha },
475
+ };
476
+ });
477
+ }
478
+
479
+ async function runFromJson(cliOpts) {
480
+ const spec = loadSpec(cliOpts.jsonFile);
481
+ const outDir = (cliOpts.dirExplicit && cliOpts.dir)
482
+ || pick(spec.cfg, "outDir", "OutDir")
483
+ || cliOpts.dir
484
+ || "schemas_cli";
485
+ fs.mkdirSync(outDir, { recursive: true });
486
+
487
+ const plans = planTables(spec, outDir);
488
+ const tableNames = new Set(plans.map(p => p.name));
489
+
490
+ for (const plan of plans) {
491
+ const text = buildModuleText(plan);
492
+ writeModule({
493
+ outDir,
494
+ name: plan.name,
495
+ text,
496
+ force: cliOpts.force,
497
+ fieldTokens: plan.fieldTokens,
498
+ });
499
+ }
500
+
501
+ writeAgentsMd(outDir, spec.file, plans);
502
+ process.exit(0);
503
+ }
504
+
505
+ function writeAgentsMd(outDir, specFile, plans) {
506
+ const specRel = path.relative(path.resolve(outDir), specFile).split(path.sep).join("/");
507
+ const tableList = plans.map(p => `- \`${p.name}\``).join("\n");
508
+ const body = `# AGENTS.md — schemas in this directory
509
+
510
+ These \`*.schema.js\` files are **generated artifacts**. The source of truth is:
511
+
512
+ ${specRel}
513
+
514
+ ## Rules for agents working in this directory
515
+
516
+ 1. **Do not edit the generated \`*.schema.js\` files directly.** Each file's
517
+ header carries SPEC and TABLE SHA-256 anchors that pin it to the spec
518
+ fragment it came from. Hand-edits silently desync from the spec.
519
+ 2. **To change a table:** edit the spec above, then re-run:
520
+ \`\`\`
521
+ allez-orm from-json ${specRel} --dir=. -f
522
+ \`\`\`
523
+ 3. **Before opening a PR:** run drift check:
524
+ \`\`\`
525
+ allez-orm verify ${specRel} --dir=.
526
+ \`\`\`
527
+ It will exit non-zero if any file has drifted from the spec.
528
+
529
+ ## Tables generated from this spec
530
+
531
+ ${tableList}
532
+
533
+ (This file is regenerated by \`allez-orm from-json\`. Edits to it will be overwritten.)
534
+ `;
535
+ const p = path.join(outDir, "AGENTS.md");
536
+ fs.writeFileSync(p, body, "utf8");
537
+ console.log(`Wrote ${p}`);
538
+ }
539
+
540
+ // ---------------- verify ----------------
541
+
542
+ async function runVerify(cliOpts) {
543
+ const spec = loadSpec(cliOpts.jsonFile);
544
+ const outDir = (cliOpts.dirExplicit && cliOpts.dir)
545
+ || pick(spec.cfg, "outDir", "OutDir")
546
+ || cliOpts.dir
547
+ || "schemas_cli";
548
+ if (!fs.existsSync(outDir)) die(`Output directory not found: ${outDir}`);
549
+
550
+ const plans = planTables(spec, outDir);
551
+ const drift = [];
552
+ const missing = [];
553
+
554
+ for (const plan of plans) {
555
+ const file = path.join(outDir, `${plan.name}.schema.js`);
556
+ if (!fs.existsSync(file)) {
557
+ missing.push(plan.name);
558
+ continue;
559
+ }
560
+ const expected = buildModuleText(plan);
561
+ const actual = fs.readFileSync(file, "utf8");
562
+ if (actual !== expected) {
563
+ drift.push({ name: plan.name, file });
564
+ }
565
+ }
566
+
567
+ if (missing.length === 0 && drift.length === 0) {
568
+ console.log(`✔ verify: ${plans.length} table(s) match spec ${path.relative(process.cwd(), spec.file)}`);
569
+ process.exit(0);
570
+ }
571
+
572
+ if (missing.length) {
573
+ console.error(`✗ Missing generated files for: ${missing.join(", ")}`);
574
+ }
575
+ if (drift.length) {
576
+ console.error(`✗ Drift detected in ${drift.length} file(s):`);
577
+ for (const d of drift) console.error(` - ${d.file}`);
578
+ console.error(`\nTo resync, run: allez-orm from-json ${path.relative(process.cwd(), spec.file)} --dir=${outDir} -f`);
579
+ }
580
+ process.exit(1);
581
+ }