allez-orm 1.0.10 → 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.
package/allez-orm.mjs CHANGED
@@ -197,23 +197,51 @@ export class AllezORM {
197
197
 
198
198
  // ---------------- schema registration ----------------
199
199
 
200
+ /** Soft-run an extra SQL statement; swallow known-unsupported patterns. */
201
+ async #tryExtra(sql) {
202
+ const s = (sql || "").trim();
203
+ if (!s) return;
204
+
205
+ // SQLite does not support adding FK constraints via ALTER TABLE.
206
+ const isAlterFk =
207
+ /^ALTER\s+TABLE\s+.+\s+ADD\s+FOREIGN\s+KEY/i.test(s);
208
+
209
+ try {
210
+ if (isAlterFk) {
211
+ console.warn(
212
+ "[AllezORM] Skipping unsupported statement (SQLite):",
213
+ s
214
+ );
215
+ return;
216
+ }
217
+ await this.execute(s);
218
+ } catch (err) {
219
+ console.warn("[AllezORM] extraSQL failed and was skipped:", s, err);
220
+ }
221
+ }
222
+
200
223
  /** @param {Schema[]} schemas */
201
224
  async registerSchemas(schemas) {
202
225
  const meta = await this.#currentVersions();
203
226
  for (const s of schemas) {
204
- if (!s?.table || !s?.createSQL) {
205
- // skip invalid schema silently to avoid breaking init
206
- continue;
207
- }
227
+ if (!s?.table || !s?.createSQL) continue;
228
+
208
229
  const exists = await this.get(
209
230
  `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
210
231
  [s.table]
211
232
  );
233
+
212
234
  if (!exists) {
235
+ // Hard-fail for invalid CREATE (developer action required)
213
236
  await this.execute(s.createSQL);
237
+
238
+ // Best-effort for side DDL (indexes/triggers/fts)
214
239
  if (Array.isArray(s.extraSQL)) {
215
- for (const x of s.extraSQL) await this.execute(x);
240
+ for (const x of s.extraSQL) {
241
+ await this.#tryExtra(x);
242
+ }
216
243
  }
244
+
217
245
  await this.execute(
218
246
  `INSERT OR REPLACE INTO allez_meta(table_name,version) VALUES(?,?)`,
219
247
  [s.table, s.version ?? 1]
package/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // index.d.ts
2
+
2
3
  export interface Schema {
3
4
  table: string;
4
5
  createSQL: string;
@@ -21,30 +22,53 @@ export interface TableHelper<T extends Row = Row> {
21
22
  insert(obj: Partial<T>): Promise<void>;
22
23
  upsert(obj: Partial<T>): Promise<void>;
23
24
  update(id: any, patch: Partial<T>): Promise<void>;
24
- /** Soft delete; implementation will try `deletedAt` then `deleted_at`. */
25
25
  deleteSoft(id: any, ts?: string): Promise<void>;
26
26
  remove(id: any): Promise<void>;
27
- findById(id: any): Promise<T | null>;
27
+ findById(id: any): Promise<T | undefined>;
28
28
  searchLike(q: string, columns: (keyof T | string)[], limit?: number): Promise<T[]>;
29
29
  }
30
30
 
31
31
  export class AllezORM {
32
32
  constructor(SQL: any, db: any, opts: InitOptions);
33
+
34
+ /** Initialize (loads sql.js, restores from IndexedDB, applies schemas). */
33
35
  static init(opts?: InitOptions): Promise<AllezORM>;
36
+
37
+ /** Persist the current database to IndexedDB immediately. */
34
38
  saveNow(): Promise<void>;
39
+
40
+ /** Execute arbitrary SQL and auto-save (convenience, returns true). */
35
41
  exec(sql: string, params?: any[]): Promise<boolean>;
42
+
43
+ /** Alias for exec. */
36
44
  run(sql: string, params?: any[]): Promise<boolean>;
45
+
46
+ /** Low-level execute; schedules a debounced save. */
37
47
  execute(sql: string, params?: any[]): Promise<void>;
48
+
49
+ /** SELECT helper returning plain objects. */
38
50
  query<T = Row>(sql: string, params?: any[]): Promise<T[]>;
39
- get<T = Row>(sql: string, params?: any[]): Promise<T | null>;
51
+
52
+ /** SELECT one row (undefined if no row). */
53
+ get<T = Row>(sql: string, params?: any[]): Promise<T | undefined>;
54
+
55
+ /** Table-scoped helpers. */
40
56
  table<T extends Row = Row>(table: string): TableHelper<T>;
57
+
58
+ /** Register / upgrade schemas. */
41
59
  registerSchemas(schemas: Schema[]): Promise<void>;
42
60
  }
43
61
 
44
- /** Browser helpers + Angular-friendly surface */
62
+ /** Open (or reuse) a browser DB by name. */
45
63
  export function openBrowserDb(name: string, opts?: InitOptions): Promise<AllezORM>;
64
+
65
+ /** Alias for openBrowserDb. */
46
66
  export const openDb: typeof openBrowserDb;
67
+
68
+ /** Apply an array of schemas to an opened AllezORM instance. */
47
69
  export function applySchemas(db: AllezORM, schemas?: Schema[]): Promise<void>;
70
+
71
+ /** Convenience helpers that operate on an AllezORM instance. */
48
72
  export function query<T = Row>(db: AllezORM, sql: string, params?: any[]): Promise<T[]>;
49
73
  export function exec(db: AllezORM, sql: string, params?: any[]): Promise<void>;
50
74
 
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "allez-orm",
3
- "version": "1.0.10",
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",
@@ -1,361 +1,403 @@
1
1
  #!/usr/bin/env node
2
- // tools/allez-orm.mjs — AllezORM schema generator with foreign-key support (SQLite)
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
+ * - Auto index per FK column in extraSQL (emitted as backticked strings)
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
15
+ * - --print-json-schema: output the JSON Schema used for validation
16
+ *
17
+ * Usage:
18
+ * node tools/allez-orm.mjs create table <name> [fields...] [--dir=schemas_cli] [--stamps] [-f|--force] [--onDelete=cascade|restrict|setnull|noaction]
19
+ * node tools/allez-orm.mjs from-json <config.json> [--dir=schemas_cli] [-f|--force]
20
+ * node tools/allez-orm.mjs --print-json-schema
21
+ */
22
+
3
23
  import fs from "node:fs";
4
24
  import path from "node:path";
5
25
  import process from "node:process";
6
26
 
7
- const CWD = process.cwd();
8
-
9
- // ---- robust force detection (works across shells) ----
10
- const FORCE_FROM_ENV = String(process.env.ALLEZ_FORCE || "").trim() === "1";
11
- const FORCE_FROM_ARGV = process.argv.includes("--force") || process.argv.includes("-f");
12
- const FORCE_EFFECTIVE = FORCE_FROM_ENV || FORCE_FROM_ARGV;
27
+ const argv = process.argv.slice(2);
13
28
 
14
- function die(msg, code = 1) { console.error(msg); process.exit(code); }
15
- function help() {
29
+ const usage = () => {
16
30
  console.log(`
17
- AllezORM schema generator
18
-
19
31
  Usage:
20
- allez-orm create table <name> [fieldSpec ...]
21
- [--dir=./schemas] [--ts] [--version=1]
22
- [--stamps] [--soft-delete] [--force|-f]
23
- [--onDelete=cascade|restrict|setnull|setdefault]
24
- [--onUpdate=cascade|restrict|setnull|setdefault]
25
- [--ensure-refs] [--no-fk-index]
26
-
27
- Env overrides:
28
- ALLEZ_FORCE=1 # forces overwrite even if your shell drops --force
29
-
30
- FieldSpec grammar (compact):
31
- name -> TEXT
32
- name! / name+ / name^ ... -> short flags directly after name (default type TEXT)
33
- name:type -> type = INTEGER|TEXT|REAL|BLOB|NUMERIC (aliases allowed)
34
- name:type!+^#~ -> ! NOT NULL, + UNIQUE, ^ INDEX, # PRIMARY KEY, ~ AUTOINCREMENT
35
- name:type,notnull,unique,index,pk,ai,default=<expr>
36
- ^col -> standalone index on col
37
- fk shorthand:
38
- col>table -> INTEGER REFERENCES table(id)
39
- col>table(col) -> INTEGER REFERENCES table(col)
40
- col:fk(table.col) -> same, leaves type as INTEGER unless you pass another
41
- PowerShell-safe FK alias:
42
- col->table[(col)] -> same as '>'
43
- Per-field FK options via commas: ,onDelete=cascade ,onUpdate=restrict ,defer ,deferrable
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
44
46
  `);
45
- }
46
-
47
- const TYPE_ALIASES = {
48
- int: "INTEGER", integer: "INTEGER", bool: "INTEGER",
49
- text: "TEXT", string: "TEXT", datetime: "TEXT", timestamp: "TEXT",
50
- real: "REAL", float: "REAL",
51
- number: "NUMERIC", numeric: "NUMERIC", blob: "BLOB"
52
47
  };
53
48
 
54
- const VALID_ACTIONS = new Set(["cascade", "restrict", "setnull", "setdefault"]);
49
+ const die = (m, code = 1) => { console.error(m); process.exit(code); };
55
50
 
56
- function kebabToPascal(name) {
57
- return name.split(/[_\- ]+/).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join("");
58
- }
59
- function sanitizeTable(name) {
60
- const ok = name.trim().toLowerCase().replace(/[^\w]/g, "_");
61
- if (!ok) die("Invalid table name.");
62
- return ok;
63
- }
64
-
65
- function parseArgs(argv) {
51
+ function parseOptions(args) {
66
52
  const out = {
67
- nonFlags: [], dir: "schemas", asTS: false, version: 1, stamps: false, softDelete: false, force: false,
68
- ensureRefs: true, onDelete: null, onUpdate: null, noFkIndex: false
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,
69
63
  };
70
- for (const a of argv) {
71
- if (a === "--") continue; // ignore npm passthrough token
72
- if (a === "--ts") out.asTS = true;
73
- else if (a.startsWith("--dir=")) out.dir = a.split("=")[1];
74
- else if (a.startsWith("--version=")) out.version = Number(a.split("=")[1] || "1") || 1;
75
- else if (a === "--stamps") out.stamps = true;
76
- else if (a === "--soft-delete") out.softDelete = true;
77
- else if (a === "--force" || a === "-f") out.force = true;
78
- else if (a === "--ensure-refs") out.ensureRefs = true;
79
- else if (a === "--no-fk-index") out.noFkIndex = true;
80
- else if (a.startsWith("--onDelete=")) out.onDelete = a.split("=")[1].toLowerCase();
81
- else if (a.startsWith("--onUpdate=")) out.onUpdate = a.split("=")[1].toLowerCase();
82
- else out.nonFlags.push(a);
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
+ }
83
87
  }
84
- if (FORCE_EFFECTIVE) out.force = true;
85
88
 
86
- if (out.onDelete && !VALID_ACTIONS.has(out.onDelete)) die("Invalid --onDelete action");
87
- if (out.onUpdate && !VALID_ACTIONS.has(out.onUpdate)) die("Invalid --onUpdate action");
88
- return out;
89
- }
89
+ // env var ALLEZ_FORCE=1 is honored (does not break positional parsing)
90
+ if (process.env.ALLEZ_FORCE === "1") out.force = true;
90
91
 
91
- // ---- helper: parse "name[:type][shortFlags]" into parts ----
92
- function parseNameTypeFlags(base) {
93
- let name, rhs = "", explicitType = false;
92
+ out.cmd = positional[0] || null;
93
+ out.sub = positional[1] || null;
94
94
 
95
- if (base.includes(":")) {
96
- [name, rhs = ""] = base.split(":");
97
- explicitType = true;
98
- } else {
99
- name = base;
100
- rhs = ""; // default TEXT; short flags may be on name itself
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
  }
102
101
 
103
- name = String(name || "").trim();
104
- if (!name) die(`Bad field spec '${base}'`);
105
-
106
- // If no ":", short flags might be appended to name (e.g., "name!+")
107
- // If ":", short flags might be appended to type token (e.g., "text!+")
108
- let typeToken = rhs;
109
- let shortFlagsStr = "";
110
- if (rhs) {
111
- const m = rhs.match(/^(.*?)([!+^#~?]+)?$/);
112
- typeToken = (m?.[1] || "").trim();
113
- shortFlagsStr = m?.[2] || "";
114
- } else {
115
- const m = name.match(/^(.*?)([!+^#~?]+)?$/);
116
- name = (m?.[1] || "").trim();
117
- shortFlagsStr = m?.[2] || "";
118
- }
102
+ return out;
103
+ }
104
+
105
+ const opts = parseOptions(argv);
106
+
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);
119
154
 
120
- const flags = new Set(shortFlagsStr.split("").map(c =>
121
- c === "!" ? "notnull" :
122
- c === "+" ? "unique" :
123
- c === "^" ? "index" :
124
- c === "#" ? "pk" :
125
- c === "~" ? "ai" :
126
- ""
127
- ).filter(Boolean));
155
+ // ---------------- command switchboard ----------------
128
156
 
129
- // Normalize the type token if present
130
- let type = typeToken ? (TYPE_ALIASES[typeToken.toLowerCase()] ?? typeToken.toUpperCase()) : "";
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
+ }
131
166
 
132
- return { name, type, flags, explicitType };
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 {
183
+ usage();
184
+ die("Expected: create table <name> … or from-json <config.json>");
133
185
  }
134
186
 
135
- // -------- Field parsing (with FK forms) ------------------------------------
136
- function parseFieldSpec(specRaw) {
137
- // Handle "^col" index-only
138
- if (specRaw.startsWith("^")
139
- && !specRaw.includes(":")
140
- && !specRaw.includes(",")
141
- && !specRaw.includes(">")
142
- && !specRaw.includes("->")) {
143
- return { kind: "indexOnly", name: specRaw.slice(1) };
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)`);
144
193
  }
145
194
 
146
- // FK shorthand "left>table(col)" or PowerShell-safe "left->table(col)"
147
- if ((specRaw.includes(">") || specRaw.includes("->")) && !specRaw.includes(":fk")) {
148
- const [left, rhs] = specRaw.includes("->") ? specRaw.split("->") : specRaw.split(">");
149
- const { name, type, flags, explicitType } = parseNameTypeFlags(left);
150
- const m = rhs.match(/^([a-zA-Z0-9_]+)(?:\(([a-zA-Z0-9_]+)\))?$/);
151
- if (!m) die(`Bad FK ref '${specRaw}'`);
152
- const table = sanitizeTable(m[1]);
153
- const refCol = m[2] || "id";
154
-
155
- // default FK storage type is INTEGER unless caller annotated a type
156
- const resolvedType = explicitType ? (type || "TEXT") : "INTEGER";
157
- const finalType = TYPE_ALIASES[(resolvedType || "").toLowerCase()] ?? (resolvedType || "TEXT");
158
-
159
- return {
160
- kind: "field",
161
- name,
162
- type: finalType,
163
- flags,
164
- fk: { table, col: refCol, opts: [] },
165
- wantsIndex: flags.has("index")
166
- };
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 });
167
202
  }
168
203
 
169
- // General field (may include ":fk(table.col)" in type OR short flags on name/type)
170
- const parts = specRaw.split(",");
171
- const base = parts.shift();
172
- const ntf = parseNameTypeFlags(base);
173
- let { name, type, flags } = ntf;
174
-
175
- let fk = null;
176
- const typeLower = (type || "").toLowerCase();
177
- if (typeLower.startsWith("fk(") && typeLower.endsWith(")")) {
178
- const inside = type.slice(3, -1);
179
- const [t, c = "id"] = inside.split(".");
180
- fk = { table: sanitizeTable(t), col: c, opts: [] };
181
- type = "INTEGER"; // storage type for fk()
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
+ );
182
211
  }
183
212
 
184
- // default type if still empty
185
- if (!type) type = "TEXT";
186
-
187
- // long flags
188
- for (const f of parts) {
189
- const [k, v] = f.split("=");
190
- const key = (k || "").toLowerCase().trim();
191
- if (!key) continue;
192
- if (["notnull", "nn", "!"].includes(key)) flags.add("notnull");
193
- else if (["unique", "u", "+"].includes(key)) flags.add("unique");
194
- else if (["index", "idx", "^"].includes(key)) flags.add("index");
195
- else if (["pk", "primary", "#"].includes(key)) flags.add("pk");
196
- else if (["ai", "autoincrement", "~"].includes(key)) flags.add("ai");
197
- else if (["default", "def", "="].includes(key)) flags.add(`default=${v}`);
198
- else if (["ondelete", "on_delete"].includes(key)) fk?.opts.push(`ON DELETE ${v.toUpperCase()}`);
199
- else if (["onupdate", "on_update"].includes(key)) fk?.opts.push(`ON UPDATE ${v.toUpperCase()}`);
200
- else if (["defer", "deferrable"].includes(key)) fk?.opts.push(`DEFERRABLE INITIALLY DEFERRED`);
201
- else die(`Unknown flag '${key}' in '${specRaw}'`);
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
+ }
202
224
  }
203
225
 
204
- return { kind: "field", name, type, flags, fk, wantsIndex: flags.has("index") };
205
- }
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
+ `;
206
241
 
207
- function buildColumnSQL(f, globalFK) {
208
- const parts = [`${f.name} ${f.type}`];
242
+ fs.mkdirSync(outDir, { recursive: true });
243
+ fs.writeFileSync(outFile, moduleText, "utf8");
244
+ console.log(`Wrote ${outFile}`);
209
245
 
210
- if (f.flags?.has("pk")) parts.push("PRIMARY KEY");
211
- if (f.flags?.has("ai")) {
212
- if (!(f.flags.has("pk") && f.type === "INTEGER")) die(`AUTOINCREMENT requires INTEGER PRIMARY KEY on '${f.name}'`);
213
- parts.push("AUTOINCREMENT");
214
- }
215
- if (f.flags?.has("unique")) parts.push("UNIQUE");
216
- if (f.flags?.has("notnull")) parts.push("NOT NULL");
217
-
218
- // FK inline constraint
219
- const fk = f.fk;
220
- if (fk) {
221
- const segs = [`REFERENCES ${fk.table}(${fk.col})`];
222
- const scoped = [];
223
- const gDel = globalFK?.onDelete ? `ON DELETE ${globalFK.onDelete.toUpperCase()}` : null;
224
- const gUpd = globalFK?.onUpdate ? `ON UPDATE ${globalFK.onUpdate.toUpperCase()}` : null;
225
- if (fk.opts?.length) scoped.push(...fk.opts);
226
- else { if (gDel) scoped.push(gDel); if (gUpd) scoped.push(gUpd); }
227
- parts.push(segs.join(" "));
228
- if (scoped.length) parts.push(scoped.join(" "));
229
- }
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);
230
249
 
231
- const def = [...(f.flags || [])].find(x => String(x).startsWith("default="));
232
- if (def) parts.push("DEFAULT " + String(def).split("=")[1]);
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
+ }
271
+ }
233
272
 
234
- return parts.join(" ");
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;
235
287
  }
236
288
 
237
- // ---------------- main ----------------
238
- async function main() {
239
- const cfg = parseArgs(process.argv.slice(2));
240
- const raw = cfg.nonFlags;
241
- const a = raw.length && raw[0] === "--" ? raw.slice(1) : raw; // belt & suspenders for stray '--'
289
+ function parseFieldToken(tok) {
290
+ // Accept "col[:type][!][+][->target]" OR "col:type,unique,notnull"
291
+ const ret = { name: "", type: "TEXT", notnull: false, unique: false, fk: null };
292
+ if (!tok) return null;
293
+
294
+ // Split on "," for attribute list
295
+ let main = tok;
296
+ let flags = [];
297
+ if (tok.includes(",")) {
298
+ const [lhs, ...rhs] = tok.split(",");
299
+ main = lhs;
300
+ flags = rhs.map(s => s.trim().toLowerCase());
301
+ }
242
302
 
243
- if (!a.length || ["-h", "--help", "help"].includes(a[0])) return help();
244
- if (!(a[0] === "create" && a[1] === "table")) return help();
303
+ // name : type -> target
304
+ let name = main;
305
+ let type = null;
306
+ let fkTarget = null;
245
307
 
246
- const table = sanitizeTable(a[2] || "");
247
- if (!table) die("Missing table name.");
248
- const pascal = kebabToPascal(table) + "Schema";
308
+ // ->target
309
+ const fkIdx = main.indexOf("->");
310
+ if (fkIdx >= 0) {
311
+ fkTarget = main.slice(fkIdx + 2).trim();
312
+ name = main.slice(0, fkIdx);
313
+ }
249
314
 
250
- const specs = (a.slice(3) || []).map(parseFieldSpec);
315
+ // :type
316
+ const typeIdx = name.indexOf(":");
317
+ if (typeIdx >= 0) {
318
+ type = name.slice(typeIdx + 1).trim(); // may contain !/+
319
+ name = name.slice(0, typeIdx).trim();
320
+ } else {
321
+ type = null;
322
+ }
251
323
 
252
- // Default PK if none supplied
253
- const hasUserPK = specs.some(f => f.kind === "field" && f.flags?.has("pk"));
254
- const cols = [];
255
- if (!hasUserPK) cols.push({ name: "id", type: "INTEGER", flags: new Set(["pk", "ai"]) });
324
+ // flags from both name and type segments
325
+ const nameHasBang = /!/.test(name);
326
+ const nameHasPlus = /\+/.test(name);
327
+ const typeHasBang = type ? /!/.test(type) : false;
328
+ const typeHasPlus = type ? /\+/.test(type) : false;
256
329
 
257
- const fkRefs = []; // { table, col }
258
- for (const f of specs) {
259
- if (f.kind === "indexOnly") continue;
260
- cols.push(f);
261
- if (f.fk) fkRefs.push({ table: f.fk.table, col: f.fk.col });
262
- }
330
+ if (nameHasBang || typeHasBang) ret.notnull = true;
331
+ if (nameHasPlus || typeHasPlus) ret.unique = true;
263
332
 
264
- if (cfg.stamps) {
265
- cols.push({ name: "created_at", type: "TEXT", flags: new Set(["notnull"]) });
266
- cols.push({ name: "updated_at", type: "TEXT", flags: new Set(["notnull"]) });
333
+ // Clean trailing !/+ off name and type
334
+ name = name.replace(/[!+]+$/,"").trim();
335
+ if (type) {
336
+ type = type.replace(/[!+]+$/,"").trim();
337
+ ret.type = type.toUpperCase();
267
338
  }
268
- if (cfg.softDelete || cfg.stamps) cols.push({ name: "deleted_at", type: "TEXT", flags: new Set() });
269
-
270
- // Compose DDL
271
- const ddl =
272
- `CREATE TABLE IF NOT EXISTS ${table} (
273
- ${cols.map(c => buildColumnSQL(c, { onDelete: cfg.onDelete, onUpdate: cfg.onUpdate })).join(",\n ")}
274
- );`;
275
-
276
- // Extra indexes: any explicit ^ plus FK columns (unless --no-fk-index)
277
- const extraIdx = new Set();
278
- for (const f of specs) {
279
- if (f.kind === "indexOnly") {
280
- extraIdx.add(`CREATE INDEX IF NOT EXISTS idx_${table}_${f.name} ON ${table}(${f.name});`);
281
- } else {
282
- if (f.wantsIndex) extraIdx.add(`CREATE INDEX IF NOT EXISTS idx_${table}_${f.name} ON ${table}(${f.name});`);
283
- if (f.fk && !cfg.noFkIndex) extraIdx.add(`CREATE INDEX IF NOT EXISTS idx_${table}_${f.name}_fk ON ${table}(${f.name});`);
284
- }
339
+
340
+ if (fkTarget) ret.fk = { table: fkTarget, column: "id" };
341
+
342
+ // also allow ",unique,notnull"
343
+ for (const f of flags) {
344
+ if (f === "unique") ret.unique = true;
345
+ if (f === "notnull") ret.notnull = true;
285
346
  }
286
347
 
287
- const fileJS =
288
- `// ./schemas/${table}.schema.js
289
- const ${pascal} = {
290
- table: "${table}",
291
- version: ${cfg.version},
348
+ ret.name = name;
349
+ return ret;
350
+ }
292
351
 
293
- createSQL: \`
294
- ${ddl.split("\n").join("\n ")}
295
- \`,
352
+ function camel(s){return s.replace(/[-_](.)/g,(_,c)=>c.toUpperCase());}
296
353
 
297
- extraSQL: [
298
- ${[...extraIdx].map(s => ` \`${s}\``).join("\n")}
299
- ]
300
- };
354
+ // ---------------- from-json implementation ----------------
301
355
 
302
- export default ${pascal};
303
- `;
356
+ async function runFromJson(cliOpts) {
357
+ const file = path.resolve(cliOpts.jsonFile);
358
+ if (!fs.existsSync(file)) die(`Config not found: ${file}`);
304
359
 
305
- const fileTS =
306
- `// ./schemas/${table}.schema.ts
307
- import type { Schema } from "../allez-orm";
308
- const ${pascal}: Schema = {
309
- table: "${table}",
310
- version: ${cfg.version},
360
+ const raw = fs.readFileSync(file, "utf8");
361
+ let cfg;
362
+ try { cfg = JSON.parse(raw); } catch (e) { die(`Invalid JSON: ${e.message}`); }
311
363
 
312
- createSQL: \`
313
- ${ddl.split("\n").join("\n ")}
314
- \`,
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.`);
315
367
 
316
- extraSQL: [
317
- ${[...extraIdx].map(s => ` \`${s}\``).join("\n")}
318
- ]
319
- };
368
+ const outDir = cliOpts.dir || cfg.outDir || "schemas_cli";
369
+ const defaultOnDelete = cfg.defaultOnDelete ?? null;
320
370
 
321
- export default ${pascal};
322
- `;
371
+ fs.mkdirSync(outDir, { recursive: true });
323
372
 
324
- // Ensure out dir
325
- const dir = path.resolve(CWD, cfg.dir);
326
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
327
-
328
- // Write this schema
329
- const outFile = path.join(dir, `${table}.schema.${cfg.asTS ? "ts" : "js"}`);
330
- if (fs.existsSync(outFile) && !cfg.force) die(`Refusing to overwrite: ${path.relative(CWD, outFile)} (use --force)`);
331
- fs.writeFileSync(outFile, cfg.asTS ? fileTS : fileJS, "utf8");
332
- console.log(`✔ Created ${path.relative(CWD, outFile)}`);
333
-
334
- // Ensure referenced schemas exist (minimal ones)
335
- if (cfg.ensureRefs && fkRefs.length) {
336
- for (const r of fkRefs) {
337
- const refPathJS = path.join(dir, `${r.table}.schema.js`);
338
- const refPathTS = path.join(dir, `${r.table}.schema.ts`);
339
- if (fs.existsSync(refPathJS) || fs.existsSync(refPathTS)) continue;
340
- const pas = kebabToPascal(r.table) + "Schema";
341
- const stub =
342
- `// ./schemas/${r.table}.schema.js (auto-created stub for FK target)
343
- const ${pas} = {
344
- table: "${r.table}",
345
- version: 1,
346
- createSQL: \`
347
- CREATE TABLE IF NOT EXISTS ${r.table} (
348
- id INTEGER PRIMARY KEY AUTOINCREMENT
349
- );
350
- \`,
351
- extraSQL: []
352
- };
353
- export default ${pas};
354
- `;
355
- fs.writeFileSync(refPathJS, stub, "utf8");
356
- console.log(`✔ Created FK target stub ${path.relative(CWD, refPathJS)}`);
373
+ for (const t of cfg.tables) {
374
+ if (!t || !t.name || !Array.isArray(t.fields)) {
375
+ die(`Each table requires { name, fields[] }`);
357
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
+ }
391
+
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
+ });
358
400
  }
359
- }
360
401
 
361
- await main();
402
+ process.exit(0);
403
+ }
@@ -1,84 +1,30 @@
1
- // tools/ddl-audit.mjs — audit ONLY SQL template strings in schemas for bad tokens
2
- import fs from "node:fs";
3
- import path from "node:path";
4
-
5
- const TARGET_DIRS = process.argv.slice(2).length ? process.argv.slice(2) : ["schemas"];
6
-
7
- // patterns that should never appear inside SQL we execute
8
- const BAD = [
9
- {
10
- name: "dangling-short-flag",
11
- rx: / [A-Za-z_][A-Za-z0-9_]*! /g, // e.g., "name! " instead of "name TEXT NOT NULL"
12
- hint: 'Use "NOT NULL" — generator should emit it now.',
13
- },
14
- {
15
- name: "type-suffix-in-sql",
16
- rx: /:[A-Za-z]+/g, // e.g., "user_id:text" leaking into SQL
17
- hint: "Identifiers must not contain :type suffixes — generator should strip them.",
18
- },
19
- ];
20
-
21
- function getSqlBlocks(source) {
22
- // Grab all backticked segments; our schemas put SQL in template literals.
23
- const blocks = [];
24
- const re = /`([\s\S]*?)`/g;
25
- let m;
26
- while ((m = re.exec(source))) {
27
- blocks.push({ text: m[1], start: m.index + 1 }); // +1 to point inside the backtick
1
+ /**
2
+ * Simple DDL auditor for Allez ORM schemas.
3
+ * - Detects forbidden patterns for SQLite (e.g., ALTER TABLE ... ADD FOREIGN KEY ...)
4
+ * - Suggests inline FK form
5
+ */
6
+
7
+ export function auditCreateSQL(createSQL = "") {
8
+ const problems = [];
9
+
10
+ const badFk = /\bALTER\s+TABLE\b[^;]+ADD\s+FOREIGN\s+KEY/i;
11
+ if (badFk.test(createSQL)) {
12
+ problems.push({
13
+ rule: "sqlite.inline_fk",
14
+ message:
15
+ "SQLite does not support `ALTER TABLE ... ADD FOREIGN KEY`. Put `REFERENCES target(id)` on the column inside CREATE TABLE.",
16
+ fix: "Move the FK to the column definition: col TYPE REFERENCES other(id) [ON DELETE ...]"
17
+ });
28
18
  }
29
- return blocks;
30
- }
31
19
 
32
- function indexToLineCol(text, idx) {
33
- // line/col are 1-based
34
- let line = 1, col = 1;
35
- for (let i = 0; i < idx; i++) {
36
- if (text[i] === "\n") { line++; col = 1; }
37
- else { col++; }
38
- }
39
- return { line, col };
20
+ return problems;
40
21
  }
41
22
 
42
- let totalIssues = 0;
43
-
44
- for (const dir of TARGET_DIRS) {
45
- if (!fs.existsSync(dir)) continue;
46
- const files = fs.readdirSync(dir).filter(f => f.endsWith(".js") || f.endsWith(".ts"));
47
- for (const f of files) {
48
- const full = path.join(dir, f);
49
- const src = fs.readFileSync(full, "utf8");
50
- const blocks = getSqlBlocks(src);
51
- if (!blocks.length) continue;
52
-
53
- let fileIssues = 0;
54
- for (const { text, start } of blocks) {
55
- for (const rule of BAD) {
56
- rule.rx.lastIndex = 0; // reset global regex
57
- let m;
58
- while ((m = rule.rx.exec(text))) {
59
- const hitIdx = start + m.index; // index in whole file (unused except for future)
60
- const { line, col } = indexToLineCol(text, m.index);
61
- const ctxStart = Math.max(0, m.index - 40);
62
- const ctxEnd = Math.min(text.length, m.index + m[0].length + 40);
63
- const snippet = text.slice(ctxStart, ctxEnd).replace(/\n/g, "↵");
64
- if (fileIssues === 0) {
65
- console.log(`\n✖ ${full}`);
66
- }
67
- console.log(
68
- ` • ${rule.name} at SQL ${line}:${col} ${rule.hint}\n` +
69
- ` …${snippet}…`
70
- );
71
- fileIssues++;
72
- totalIssues++;
73
- }
74
- }
75
- }
23
+ export function assertClean(createSQL = "") {
24
+ const issues = auditCreateSQL(createSQL);
25
+ if (issues.length) {
26
+ const msg = issues.map(i => `• ${i.message}\n → ${i.fix}`).join("\n");
27
+ throw new Error(`DDL audit failed:\n${msg}`);
76
28
  }
77
- }
78
-
79
- if (totalIssues === 0) {
80
- console.log("✓ SQL audit: no offending tokens found.");
81
- } else {
82
- console.error(`\nFound ${totalIssues} offending token(s).`);
83
- process.exit(1);
29
+ return true;
84
30
  }