allez-orm 1.0.11 → 1.0.12

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.
Files changed (2) hide show
  1. package/package.json +4 -4
  2. package/tools/allez-orm.mjs +249 -129
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "allez-orm",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "AllezORM: lightweight browser SQLite ORM (sql.js) + schema generator CLI",
5
5
  "type": "module",
6
+ "bin": {
7
+ "allez-orm": "tools/allez-orm.mjs"
8
+ },
6
9
  "main": "./allez-orm.mjs",
7
10
  "module": "./allez-orm.mjs",
8
11
  "browser": "./allez-orm.mjs",
@@ -32,9 +35,6 @@
32
35
  "ddl:audit": "node tools/ddl-audit.mjs",
33
36
  "prepublishOnly": "node tests/test-cli.mjs && node tools/ddl-audit.mjs"
34
37
  },
35
- "bin": {
36
- "allez-orm": "tools/allez-orm.mjs"
37
- },
38
38
  "files": [
39
39
  "allez-orm.mjs",
40
40
  "index.d.ts",
@@ -7,25 +7,17 @@
7
7
  * - Optional "stamps": created_at, updated_at, deleted_at
8
8
  * - Optional unique / not-null markers
9
9
  * - Optional ON DELETE behavior for *all* FKs via --onDelete=
10
- * - Auto index per FK column in extraSQL
10
+ * - Auto index per FK column in extraSQL (emitted as backticked strings)
11
11
  * - Auto-create stub schemas for FK target tables if missing
12
12
  *
13
+ * New:
14
+ * - from-json <file>: bulk-generate schemas from a JSON config
15
+ * - --print-json-schema: output the JSON Schema used for validation
16
+ *
13
17
  * Usage:
14
18
  * node tools/allez-orm.mjs create table <name> [fields...] [--dir=schemas_cli] [--stamps] [-f|--force] [--onDelete=cascade|restrict|setnull|noaction]
15
- *
16
- * Field syntax (comma or symbol sugar):
17
- * name -> bare column "name TEXT"
18
- * name! -> NOT NULL
19
- * name:text -> explicit SQL type
20
- * name:text! -> TEXT NOT NULL
21
- * email:text!+ -> TEXT UNIQUE NOT NULL
22
- * user_id:text->users
23
- * org_id:integer->orgs
24
- * slug:text,unique -> you can also use ",unique" or ",notnull"
25
- *
26
- * Defaults:
27
- * - Adds "id INTEGER PRIMARY KEY AUTOINCREMENT" if you don't provide an "id" column yourself
28
- * - Default type is TEXT when omitted
19
+ * node tools/allez-orm.mjs from-json <config.json> [--dir=schemas_cli] [-f|--force]
20
+ * node tools/allez-orm.mjs --print-json-schema
29
21
  */
30
22
 
31
23
  import fs from "node:fs";
@@ -33,17 +25,20 @@ import path from "node:path";
33
25
  import process from "node:process";
34
26
 
35
27
  const argv = process.argv.slice(2);
28
+
36
29
  const usage = () => {
37
30
  console.log(`
38
31
  Usage:
39
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
40
35
 
41
36
  Options:
42
37
  --dir=<outDir> Output directory (default: schemas_cli)
43
38
  --stamps Add created_at, updated_at, deleted_at columns
44
- --onDelete=<mode> ON DELETE action for *all* FKs (cascade|restrict|setnull|noaction). Default: none
45
- -f, --force Overwrite existing file
46
- --help Show this help
39
+ --onDelete=<mode> ON DELETE for *all* FKs (cascade|restrict|setnull|noaction). Default: none
40
+ -f, --force Overwrite existing files
41
+ --help Show help
47
42
 
48
43
  Field syntax:
49
44
  col[:type][!][+][->target] or "col:type,unique,notnull"
@@ -51,11 +46,6 @@ Field syntax:
51
46
  `);
52
47
  };
53
48
 
54
- if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
55
- usage();
56
- process.exit(0);
57
- }
58
-
59
49
  const die = (m, code = 1) => { console.error(m); process.exit(code); };
60
50
 
61
51
  function parseOptions(args) {
@@ -67,11 +57,15 @@ function parseOptions(args) {
67
57
  cmd: null,
68
58
  sub: null,
69
59
  table: null,
70
- fields: []
60
+ fields: [],
61
+ jsonFile: null,
62
+ printJsonSchema: false,
71
63
  };
72
64
  const positional = [];
73
65
  for (const a of args) {
74
- if (a.startsWith("--dir=")) {
66
+ if (a === "--help" || a === "-h") {
67
+ usage(); process.exit(0);
68
+ } else if (a.startsWith("--dir=")) {
75
69
  out.dir = a.slice(6);
76
70
  } else if (a === "--stamps") {
77
71
  out.stamps = true;
@@ -83,42 +77,214 @@ function parseOptions(args) {
83
77
  out.onDelete = v;
84
78
  } else if (a === "-f" || a === "--force") {
85
79
  out.force = true;
80
+ } else if (a === "--print-json-schema") {
81
+ out.printJsonSchema = true;
86
82
  } else if (a.startsWith("-")) {
87
83
  die(`Unknown option: ${a}`);
88
84
  } else {
89
85
  positional.push(a);
90
86
  }
91
87
  }
92
- // pick up positional command pieces
88
+
89
+ // env var ALLEZ_FORCE=1 is honored (does not break positional parsing)
90
+ if (process.env.ALLEZ_FORCE === "1") out.force = true;
91
+
93
92
  out.cmd = positional[0] || null;
94
93
  out.sub = positional[1] || null;
95
- out.table = positional[2] || null;
96
- out.fields = positional.slice(3);
97
94
 
98
- // env var should not interfere with positional parsing
99
- if (process.env.ALLEZ_FORCE === "1") {
100
- out.force = true;
95
+ if (out.cmd === "create" && out.sub === "table") {
96
+ out.table = positional[2] || null;
97
+ out.fields = positional.slice(3);
98
+ } else if (out.cmd === "from-json") {
99
+ out.jsonFile = positional[1] || null;
101
100
  }
101
+
102
102
  return out;
103
103
  }
104
104
 
105
105
  const opts = parseOptions(argv);
106
106
 
107
- // Commands
108
- if (opts.cmd !== "create" || opts.sub !== "table" || !opts.table) {
107
+ // ---------------- JSON Schema (string) ----------------
108
+
109
+ const CONFIG_JSON_SCHEMA = JSON.stringify({
110
+ $schema: "https://json-schema.org/draft/2020-12/schema",
111
+ $id: "https://allez-orm.dev/allez.config.schema.json",
112
+ type: "object",
113
+ additionalProperties: false,
114
+ properties: {
115
+ outDir: { type: "string" },
116
+ defaultOnDelete: { enum: ["cascade","restrict","setnull","noaction",null] },
117
+ tables: {
118
+ type: "array",
119
+ items: {
120
+ type: "object",
121
+ additionalProperties: false,
122
+ properties: {
123
+ name: { type: "string", minLength: 1 },
124
+ stamps: { type: "boolean" },
125
+ fields: {
126
+ type: "array",
127
+ items: {
128
+ type: "object",
129
+ additionalProperties: false,
130
+ required: ["name"],
131
+ properties: {
132
+ name: { type: "string" },
133
+ type: { type: "string" }, // TEXT (default), INTEGER, etc
134
+ unique: { type: "boolean" },
135
+ notnull: { type: "boolean" },
136
+ fk: {
137
+ type: ["object", "null"],
138
+ additionalProperties: false,
139
+ properties: {
140
+ table: { type: "string" },
141
+ column: { type: "string", default: "id" }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ },
148
+ required: ["name","fields"]
149
+ }
150
+ }
151
+ },
152
+ required: ["tables"]
153
+ }, null, 2);
154
+
155
+ // ---------------- command switchboard ----------------
156
+
157
+ if (opts.printJsonSchema) {
158
+ console.log(CONFIG_JSON_SCHEMA);
159
+ process.exit(0);
160
+ }
161
+
162
+ if (!opts.cmd) {
163
+ usage();
164
+ process.exit(0);
165
+ }
166
+
167
+ if (opts.cmd === "from-json") {
168
+ if (!opts.jsonFile) die("from-json requires a <config.json> path");
169
+ runFromJson(opts).catch(e => die(e.stack || String(e)));
170
+ // will exit inside
171
+ } else if (opts.cmd === "create" && opts.sub === "table" && opts.table) {
172
+ fs.mkdirSync(opts.dir, { recursive: true });
173
+ generateOne({
174
+ outDir: opts.dir,
175
+ name: opts.table,
176
+ stamps: opts.stamps,
177
+ onDelete: opts.onDelete,
178
+ force: opts.force,
179
+ fieldTokens: opts.fields
180
+ }).then(() => process.exit(0))
181
+ .catch(e => die(e.stack || String(e)));
182
+ } else {
109
183
  usage();
110
- die("Expected: create table <name> [fields...]");
184
+ die("Expected: create table <name> … or from-json <config.json>");
111
185
  }
112
186
 
113
- // Ensure dir
114
- fs.mkdirSync(opts.dir, { recursive: true });
187
+ // ---------------- core generator (shared) ----------------
188
+
189
+ async function generateOne({ outDir, name, stamps, onDelete, force, fieldTokens }) {
190
+ const outFile = path.join(outDir, `${name}.schema.js`);
191
+ if (fs.existsSync(outFile) && !force) {
192
+ die(`Refusing to overwrite existing file: ${outFile}\n(use -f or ALLEZ_FORCE=1)`);
193
+ }
194
+
195
+ // Parse tokens into field descriptors
196
+ const fields = fieldTokens.map(parseFieldToken).filter(Boolean);
197
+
198
+ // Ensure id PK
199
+ const hasId = fields.some(f => f.name === "id");
200
+ if (!hasId) {
201
+ fields.unshift({ name: "id", type: "INTEGER", notnull: true, unique: false, fk: null, pk: true });
202
+ }
203
+
204
+ // stamps
205
+ if (stamps) {
206
+ fields.push(
207
+ { name: "created_at", type: "TEXT", notnull: true },
208
+ { name: "updated_at", type: "TEXT", notnull: true },
209
+ { name: "deleted_at", type: "TEXT", notnull: false }
210
+ );
211
+ }
212
+
213
+ // SQL
214
+ const columnLines = fields.map(f => sqlForColumn(f, onDelete));
215
+
216
+ // FK indexes
217
+ const extraSQL = [];
218
+ for (const f of fields) {
219
+ if (f.fk) {
220
+ extraSQL.push(
221
+ `\`CREATE INDEX IF NOT EXISTS idx_${name}_${f.name}_fk ON ${name}(${f.name});\``
222
+ );
223
+ }
224
+ }
225
+
226
+ // module text
227
+ const moduleText = `// ${name}.schema.js (generated by tools/allez-orm.mjs)
228
+ const ${camel(name)}Schema = {
229
+ table: "${name}",
230
+ version: 1,
231
+ createSQL: \`
232
+ CREATE TABLE IF NOT EXISTS ${name} (
233
+ ${columnLines.join(",\n ")}
234
+ );\`,
235
+ extraSQL: [
236
+ ${extraSQL.join("\n ")}
237
+ ]
238
+ };
239
+ export default ${camel(name)}Schema;
240
+ `;
241
+
242
+ fs.mkdirSync(outDir, { recursive: true });
243
+ fs.writeFileSync(outFile, moduleText, "utf8");
244
+ console.log(`Wrote ${outFile}`);
115
245
 
116
- const outFile = path.join(opts.dir, `${opts.table}.schema.js`);
117
- if (fs.existsSync(outFile) && !opts.force) {
118
- die(`Refusing to overwrite existing file: ${outFile}\n(use -f or ALLEZ_FORCE=1)`);
246
+ // stub FK targets
247
+ const fkTargets = Array.from(new Set(fields.filter(f => f.fk).map(f => f.fk.table)))
248
+ .filter(t => t && t !== name);
249
+
250
+ for (const t of fkTargets) {
251
+ const stubPath = path.join(outDir, `${t}.schema.js`);
252
+ if (!fs.existsSync(stubPath)) {
253
+ const stub = `// ${t}.schema.js (generated by tools/allez-orm.mjs - stub for FK target)
254
+ const ${camel(t)}Schema = {
255
+ table: "${t}",
256
+ version: 1,
257
+ createSQL: \`
258
+ CREATE TABLE IF NOT EXISTS ${t} (
259
+ id INTEGER PRIMARY KEY AUTOINCREMENT
260
+ );\`,
261
+ extraSQL: [
262
+
263
+ ]
264
+ };
265
+ export default ${camel(t)}Schema;
266
+ `;
267
+ fs.writeFileSync(stubPath, stub, "utf8");
268
+ console.log(`Wrote stub ${stubPath}`);
269
+ }
270
+ }
119
271
  }
120
272
 
121
- // ---- Field parsing ---------------------------------------------------------
273
+ function sqlForColumn(f, onDelete) {
274
+ if (f.pk) return `id INTEGER PRIMARY KEY AUTOINCREMENT`;
275
+ let s = `${f.name} ${f.type}`;
276
+ // ordering (UNIQUE then NOT NULL) matches tests
277
+ if (f.unique) s += ` UNIQUE`;
278
+ if (f.notnull) s += ` NOT NULL`;
279
+ if (f.fk) {
280
+ s += ` REFERENCES ${f.fk.table}(${f.fk.column || "id"})`;
281
+ if (onDelete) {
282
+ const map = { cascade: "CASCADE", restrict: "RESTRICT", setnull: "SET NULL", noaction: "NO ACTION" };
283
+ s += ` ON DELETE ${map[onDelete]}`;
284
+ }
285
+ }
286
+ return s;
287
+ }
122
288
 
123
289
  function parseFieldToken(tok) {
124
290
  // Accept "col[:type][!][+][->target]" OR "col:type,unique,notnull"
@@ -155,7 +321,7 @@ function parseFieldToken(tok) {
155
321
  type = null;
156
322
  }
157
323
 
158
- // Collect flags from BOTH the name token and the type token
324
+ // flags from both name and type segments
159
325
  const nameHasBang = /!/.test(name);
160
326
  const nameHasPlus = /\+/.test(name);
161
327
  const typeHasBang = type ? /!/.test(type) : false;
@@ -164,7 +330,7 @@ function parseFieldToken(tok) {
164
330
  if (nameHasBang || typeHasBang) ret.notnull = true;
165
331
  if (nameHasPlus || typeHasPlus) ret.unique = true;
166
332
 
167
- // Clean trailing !/+ off name and type segments
333
+ // Clean trailing !/+ off name and type
168
334
  name = name.replace(/[!+]+$/,"").trim();
169
335
  if (type) {
170
336
  type = type.replace(/[!+]+$/,"").trim();
@@ -183,101 +349,55 @@ function parseFieldToken(tok) {
183
349
  return ret;
184
350
  }
185
351
 
186
- const fields = opts.fields.map(parseFieldToken).filter(Boolean);
352
+ function camel(s){return s.replace(/[-_](.)/g,(_,c)=>c.toUpperCase());}
187
353
 
188
- // Ensure an id column if not provided
189
- const hasId = fields.some(f => f.name === "id");
190
- if (!hasId) {
191
- fields.unshift({ name: "id", type: "INTEGER", notnull: true, unique: false, fk: null, pk: true });
192
- }
354
+ // ---------------- from-json implementation ----------------
193
355
 
194
- // Stamps
195
- if (opts.stamps) {
196
- fields.push(
197
- { name: "created_at", type: "TEXT", notnull: true },
198
- { name: "updated_at", type: "TEXT", notnull: true },
199
- { name: "deleted_at", type: "TEXT", notnull: false }
200
- );
201
- }
356
+ async function runFromJson(cliOpts) {
357
+ const file = path.resolve(cliOpts.jsonFile);
358
+ if (!fs.existsSync(file)) die(`Config not found: ${file}`);
202
359
 
203
- // ---- SQL assembly ----------------------------------------------------------
360
+ const raw = fs.readFileSync(file, "utf8");
361
+ let cfg;
362
+ try { cfg = JSON.parse(raw); } catch (e) { die(`Invalid JSON: ${e.message}`); }
204
363
 
205
- function sqlForColumn(f) {
206
- if (f.pk) return `id INTEGER PRIMARY KEY AUTOINCREMENT`;
207
- let s = `${f.name} ${f.type}`;
364
+ // light validation against our schema
365
+ // (kept minimal to avoid bundling a validator)
366
+ if (!cfg || !Array.isArray(cfg.tables)) die(`Config must have a "tables" array.`);
208
367
 
209
- // Match test ordering: UNIQUE first, then NOT NULL
210
- if (f.unique) s += ` UNIQUE`;
211
- if (f.notnull) s += ` NOT NULL`;
368
+ const outDir = cliOpts.dir || cfg.outDir || "schemas_cli";
369
+ const defaultOnDelete = cfg.defaultOnDelete ?? null;
212
370
 
213
- if (f.fk) {
214
- s += ` REFERENCES ${f.fk.table}(${f.fk.column})`;
215
- if (opts.onDelete) {
216
- const map = { cascade: "CASCADE", restrict: "RESTRICT", setnull: "SET NULL", noaction: "NO ACTION" };
217
- s += ` ON DELETE ${map[opts.onDelete]}`;
218
- }
219
- }
220
- return s;
221
- }
371
+ fs.mkdirSync(outDir, { recursive: true });
222
372
 
223
- const columnLines = fields.map(sqlForColumn);
373
+ for (const t of cfg.tables) {
374
+ if (!t || !t.name || !Array.isArray(t.fields)) {
375
+ die(`Each table requires { name, fields[] }`);
376
+ }
377
+ // convert config fields -> tokens for existing generator
378
+ const tokens = [];
379
+ for (const f of t.fields) {
380
+ let token = f.name;
381
+ const type = (f.type || "TEXT").toLowerCase();
382
+
383
+ token += `:${type}`;
384
+ if (f.notnull) token += `!`;
385
+ if (f.unique) token += `+`;
386
+ if (f.fk && f.fk.table) {
387
+ token += `->${f.fk.table}`;
388
+ }
389
+ tokens.push(token);
390
+ }
224
391
 
225
- // Build extraSQL (indexes for FK columns) — emit with BACKTICKS to satisfy tests
226
- const extraSQL = [];
227
- for (const f of fields) {
228
- if (f.fk) {
229
- extraSQL.push(
230
- `\`CREATE INDEX IF NOT EXISTS idx_${opts.table}_${f.name}_fk ON ${opts.table}(${f.name});\``
231
- );
392
+ await generateOne({
393
+ outDir,
394
+ name: t.name,
395
+ stamps: !!t.stamps,
396
+ onDelete: defaultOnDelete || null,
397
+ force: cliOpts.force,
398
+ fieldTokens: tokens
399
+ });
232
400
  }
233
- }
234
401
 
235
- // Compose module text
236
- const moduleText = `// ${opts.table}.schema.js (generated by tools/allez-orm.mjs)
237
- const ${camel(opts.table)}Schema = {
238
- table: "${opts.table}",
239
- version: 1,
240
- createSQL: \`
241
- CREATE TABLE IF NOT EXISTS ${opts.table} (
242
- ${columnLines.join(",\n ")}
243
- );\`,
244
- extraSQL: [
245
- ${extraSQL.join("\n ")}
246
- ]
247
- };
248
- export default ${camel(opts.table)}Schema;
249
- `;
250
-
251
- fs.writeFileSync(outFile, moduleText, "utf8");
252
- console.log(`Wrote ${outFile}`);
253
-
254
- // ---- Auto-create stub schemas for FK targets (if missing) ------------------
255
- const fkTargets = Array.from(new Set(fields.filter(f => f.fk).map(f => f.fk.table)))
256
- .filter(t => t && t !== opts.table);
257
-
258
- for (const t of fkTargets) {
259
- const stubPath = path.join(opts.dir, `${t}.schema.js`);
260
- if (!fs.existsSync(stubPath)) {
261
- const stub = `// ${t}.schema.js (generated by tools/allez-orm.mjs - stub for FK target)
262
- const ${camel(t)}Schema = {
263
- table: "${t}",
264
- version: 1,
265
- createSQL: \`
266
- CREATE TABLE IF NOT EXISTS ${t} (
267
- id INTEGER PRIMARY KEY AUTOINCREMENT
268
- );\`,
269
- extraSQL: [
270
-
271
- ]
272
- };
273
- export default ${camel(t)}Schema;
274
- `;
275
- fs.writeFileSync(stubPath, stub, "utf8");
276
- console.log(`Wrote stub ${stubPath}`);
277
- }
402
+ process.exit(0);
278
403
  }
279
-
280
- process.exit(0);
281
-
282
- // ---- helpers ---------------------------------------------------------------
283
- function camel(s){return s.replace(/[-_](.)/g,(_,c)=>c.toUpperCase());}