allez-orm 1.0.10 → 1.0.11

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,6 +1,6 @@
1
1
  {
2
2
  "name": "allez-orm",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "AllezORM: lightweight browser SQLite ORM (sql.js) + schema generator CLI",
5
5
  "type": "module",
6
6
  "main": "./allez-orm.mjs",
@@ -1,361 +1,283 @@
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
11
+ * - Auto-create stub schemas for FK target tables if missing
12
+ *
13
+ * Usage:
14
+ * 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
29
+ */
30
+
3
31
  import fs from "node:fs";
4
32
  import path from "node:path";
5
33
  import process from "node:process";
6
34
 
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;
13
-
14
- function die(msg, code = 1) { console.error(msg); process.exit(code); }
15
- function help() {
35
+ const argv = process.argv.slice(2);
36
+ const usage = () => {
16
37
  console.log(`
17
- AllezORM schema generator
18
-
19
38
  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
39
+ allez-orm create table <name> [options] [fields...]
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 action for *all* FKs (cascade|restrict|setnull|noaction). Default: none
45
+ -f, --force Overwrite existing file
46
+ --help Show this 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
44
51
  `);
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
52
  };
53
53
 
54
- const VALID_ACTIONS = new Set(["cascade", "restrict", "setnull", "setdefault"]);
55
-
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;
54
+ if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
55
+ usage();
56
+ process.exit(0);
63
57
  }
64
58
 
65
- function parseArgs(argv) {
59
+ const die = (m, code = 1) => { console.error(m); process.exit(code); };
60
+
61
+ function parseOptions(args) {
66
62
  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
63
+ dir: "schemas_cli",
64
+ stamps: false,
65
+ onDelete: null,
66
+ force: false,
67
+ cmd: null,
68
+ sub: null,
69
+ table: null,
70
+ fields: []
69
71
  };
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);
72
+ const positional = [];
73
+ for (const a of args) {
74
+ if (a.startsWith("--dir=")) {
75
+ out.dir = a.slice(6);
76
+ } else if (a === "--stamps") {
77
+ out.stamps = true;
78
+ } else if (a.startsWith("--onDelete=")) {
79
+ const v = a.slice(11).toLowerCase();
80
+ if (!["cascade","restrict","setnull","noaction"].includes(v)) {
81
+ die(`Invalid --onDelete value: ${v}`);
82
+ }
83
+ out.onDelete = v;
84
+ } else if (a === "-f" || a === "--force") {
85
+ out.force = true;
86
+ } else if (a.startsWith("-")) {
87
+ die(`Unknown option: ${a}`);
88
+ } else {
89
+ positional.push(a);
90
+ }
91
+ }
92
+ // pick up positional command pieces
93
+ out.cmd = positional[0] || null;
94
+ out.sub = positional[1] || null;
95
+ out.table = positional[2] || null;
96
+ out.fields = positional.slice(3);
97
+
98
+ // ✅ env var should not interfere with positional parsing
99
+ if (process.env.ALLEZ_FORCE === "1") {
100
+ out.force = true;
83
101
  }
84
- if (FORCE_EFFECTIVE) out.force = true;
85
-
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
102
  return out;
89
103
  }
90
104
 
91
- // ---- helper: parse "name[:type][shortFlags]" into parts ----
92
- function parseNameTypeFlags(base) {
93
- let name, rhs = "", explicitType = false;
105
+ const opts = parseOptions(argv);
94
106
 
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
101
- }
107
+ // Commands
108
+ if (opts.cmd !== "create" || opts.sub !== "table" || !opts.table) {
109
+ usage();
110
+ die("Expected: create table <name> [fields...]");
111
+ }
102
112
 
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
- }
113
+ // Ensure dir
114
+ fs.mkdirSync(opts.dir, { recursive: true });
119
115
 
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));
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)`);
119
+ }
128
120
 
129
- // Normalize the type token if present
130
- let type = typeToken ? (TYPE_ALIASES[typeToken.toLowerCase()] ?? typeToken.toUpperCase()) : "";
121
+ // ---- Field parsing ---------------------------------------------------------
131
122
 
132
- return { name, type, flags, explicitType };
133
- }
123
+ function parseFieldToken(tok) {
124
+ // Accept "col[:type][!][+][->target]" OR "col:type,unique,notnull"
125
+ const ret = { name: "", type: "TEXT", notnull: false, unique: false, fk: null };
126
+ if (!tok) return null;
134
127
 
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) };
128
+ // Split on "," for attribute list
129
+ let main = tok;
130
+ let flags = [];
131
+ if (tok.includes(",")) {
132
+ const [lhs, ...rhs] = tok.split(",");
133
+ main = lhs;
134
+ flags = rhs.map(s => s.trim().toLowerCase());
144
135
  }
145
136
 
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
- };
167
- }
137
+ // name : type -> target
138
+ let name = main;
139
+ let type = null;
140
+ let fkTarget = null;
168
141
 
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()
142
+ // ->target
143
+ const fkIdx = main.indexOf("->");
144
+ if (fkIdx >= 0) {
145
+ fkTarget = main.slice(fkIdx + 2).trim();
146
+ name = main.slice(0, fkIdx);
182
147
  }
183
148
 
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}'`);
149
+ // :type
150
+ const typeIdx = name.indexOf(":");
151
+ if (typeIdx >= 0) {
152
+ type = name.slice(typeIdx + 1).trim(); // may contain !/+
153
+ name = name.slice(0, typeIdx).trim();
154
+ } else {
155
+ type = null;
202
156
  }
203
157
 
204
- return { kind: "field", name, type, flags, fk, wantsIndex: flags.has("index") };
205
- }
158
+ // Collect flags from BOTH the name token and the type token
159
+ const nameHasBang = /!/.test(name);
160
+ const nameHasPlus = /\+/.test(name);
161
+ const typeHasBang = type ? /!/.test(type) : false;
162
+ const typeHasPlus = type ? /\+/.test(type) : false;
206
163
 
207
- function buildColumnSQL(f, globalFK) {
208
- const parts = [`${f.name} ${f.type}`];
164
+ if (nameHasBang || typeHasBang) ret.notnull = true;
165
+ if (nameHasPlus || typeHasPlus) ret.unique = true;
209
166
 
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(" "));
167
+ // Clean trailing !/+ off name and type segments
168
+ name = name.replace(/[!+]+$/,"").trim();
169
+ if (type) {
170
+ type = type.replace(/[!+]+$/,"").trim();
171
+ ret.type = type.toUpperCase();
229
172
  }
230
173
 
231
- const def = [...(f.flags || [])].find(x => String(x).startsWith("default="));
232
- if (def) parts.push("DEFAULT " + String(def).split("=")[1]);
174
+ if (fkTarget) ret.fk = { table: fkTarget, column: "id" };
175
+
176
+ // also allow ",unique,notnull"
177
+ for (const f of flags) {
178
+ if (f === "unique") ret.unique = true;
179
+ if (f === "notnull") ret.notnull = true;
180
+ }
233
181
 
234
- return parts.join(" ");
182
+ ret.name = name;
183
+ return ret;
235
184
  }
236
185
 
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 '--'
186
+ const fields = opts.fields.map(parseFieldToken).filter(Boolean);
242
187
 
243
- if (!a.length || ["-h", "--help", "help"].includes(a[0])) return help();
244
- if (!(a[0] === "create" && a[1] === "table")) return help();
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
+ }
245
193
 
246
- const table = sanitizeTable(a[2] || "");
247
- if (!table) die("Missing table name.");
248
- const pascal = kebabToPascal(table) + "Schema";
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
+ }
249
202
 
250
- const specs = (a.slice(3) || []).map(parseFieldSpec);
203
+ // ---- SQL assembly ----------------------------------------------------------
251
204
 
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"]) });
205
+ function sqlForColumn(f) {
206
+ if (f.pk) return `id INTEGER PRIMARY KEY AUTOINCREMENT`;
207
+ let s = `${f.name} ${f.type}`;
256
208
 
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
- }
209
+ // Match test ordering: UNIQUE first, then NOT NULL
210
+ if (f.unique) s += ` UNIQUE`;
211
+ if (f.notnull) s += ` NOT NULL`;
263
212
 
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"]) });
267
- }
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});`);
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]}`;
284
218
  }
285
219
  }
220
+ return s;
221
+ }
286
222
 
287
- const fileJS =
288
- `// ./schemas/${table}.schema.js
289
- const ${pascal} = {
290
- table: "${table}",
291
- version: ${cfg.version},
223
+ const columnLines = fields.map(sqlForColumn);
292
224
 
293
- createSQL: \`
294
- ${ddl.split("\n").join("\n ")}
295
- \`,
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
+ );
232
+ }
233
+ }
296
234
 
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
+ );\`,
297
244
  extraSQL: [
298
- ${[...extraIdx].map(s => ` \`${s}\``).join("\n")}
245
+ ${extraSQL.join("\n ")}
299
246
  ]
300
247
  };
301
-
302
- export default ${pascal};
248
+ export default ${camel(opts.table)}Schema;
303
249
  `;
304
250
 
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},
251
+ fs.writeFileSync(outFile, moduleText, "utf8");
252
+ console.log(`Wrote ${outFile}`);
311
253
 
312
- createSQL: \`
313
- ${ddl.split("\n").join("\n ")}
314
- \`,
315
-
316
- extraSQL: [
317
- ${[...extraIdx].map(s => ` \`${s}\``).join("\n")}
318
- ]
319
- };
320
-
321
- export default ${pascal};
322
- `;
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);
323
257
 
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}",
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}",
345
264
  version: 1,
346
265
  createSQL: \`
347
- CREATE TABLE IF NOT EXISTS ${r.table} (
348
- id INTEGER PRIMARY KEY AUTOINCREMENT
349
- );
350
- \`,
351
- extraSQL: []
266
+ CREATE TABLE IF NOT EXISTS ${t} (
267
+ id INTEGER PRIMARY KEY AUTOINCREMENT
268
+ );\`,
269
+ extraSQL: [
270
+
271
+ ]
352
272
  };
353
- export default ${pas};
273
+ export default ${camel(t)}Schema;
354
274
  `;
355
- fs.writeFileSync(refPathJS, stub, "utf8");
356
- console.log(`✔ Created FK target stub ${path.relative(CWD, refPathJS)}`);
357
- }
275
+ fs.writeFileSync(stubPath, stub, "utf8");
276
+ console.log(`Wrote stub ${stubPath}`);
358
277
  }
359
278
  }
360
279
 
361
- await main();
280
+ process.exit(0);
281
+
282
+ // ---- helpers ---------------------------------------------------------------
283
+ function camel(s){return s.replace(/[-_](.)/g,(_,c)=>c.toUpperCase());}
@@ -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
  }