allez-orm 1.0.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.
package/allez-orm.mjs ADDED
@@ -0,0 +1,278 @@
1
+ // allez-orm.mjs
2
+ // AllezORM — minimal browser ORM on top of sql.js (WASM)
3
+ // - Pure client-side
4
+ // - IndexedDB persistence (debounced auto-save)
5
+ // - Plug-in schemas with optional versioned upgrades
6
+ // - Simple table helpers (insert/upsert/update/deleteSoft/remove/findById/searchLike)
7
+
8
+ /**
9
+ * @typedef {Object} Schema
10
+ * @property {string} table // table name
11
+ * @property {string} createSQL // CREATE TABLE IF NOT EXISTS ...
12
+ * @property {string[]=} extraSQL // indexes / triggers / FTS setup
13
+ * @property {number=} version // defaults to 1
14
+ * @property {(db:any,from:number,to:number)=>void|Promise<void>=} onUpgrade
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} InitOptions
19
+ * @property {string=} dbName
20
+ * @property {number=} autoSaveMs
21
+ * @property {(file:string)=>string=} wasmLocateFile
22
+ * @property {Schema[]=} schemas
23
+ * @property {Record<string,{default:Schema}>=} schemaModules // for bundlers (import.meta.glob)
24
+ */
25
+
26
+ const DEFAULT_DB_NAME = "allez.db";
27
+ const DEFAULT_AUTOSAVE_MS = 1500;
28
+
29
+ export class AllezORM {
30
+ /** @param {any} SQL @param {any} db @param {InitOptions} opts */
31
+ constructor(SQL, db, opts) {
32
+ this.SQL = SQL;
33
+ this.db = db;
34
+ this.dbName = opts.dbName ?? DEFAULT_DB_NAME;
35
+ this.autoSaveMs = opts.autoSaveMs ?? DEFAULT_AUTOSAVE_MS;
36
+ this.saveTimer = null;
37
+ }
38
+
39
+ // run arbitrary SQL (DDL/DML). Returns true on success.
40
+ // Usage: await orm.exec("ALTER TABLE posts ADD COLUMN ...");
41
+ async exec(sql, params = []) {
42
+ if (params && params.length) {
43
+ const stmt = this.db.prepare(sql);
44
+ try {
45
+ stmt.bind(params);
46
+ while (stmt.step()) { } // iterate to completion
47
+ } finally {
48
+ stmt.free();
49
+ }
50
+ } else {
51
+ this.db.exec(sql);
52
+ }
53
+ // persist to IndexedDB if your class exposes it
54
+ if (typeof this.saveNow === "function") await this.saveNow();
55
+ return true;
56
+ }
57
+
58
+ // nice alias
59
+ run(sql, params) { return this.exec(sql, params); }
60
+
61
+
62
+ /** @param {InitOptions=} opts */
63
+ static async init(opts = {}) {
64
+ // Resolve sql.js from global <script> or dynamic import
65
+ let SQL;
66
+ if (typeof window !== "undefined" && window.initSqlJs) {
67
+ SQL = await window.initSqlJs({
68
+ locateFile: opts.wasmLocateFile ?? (f => `https://sql.js.org/dist/${f}`)
69
+ });
70
+ } else {
71
+ const { default: initSqlJs } = await import("sql.js");
72
+ SQL = await initSqlJs({
73
+ locateFile: opts.wasmLocateFile ?? (f => `https://sql.js.org/dist/${f}`)
74
+ });
75
+ }
76
+
77
+ // Restore DB from IndexedDB, or create fresh
78
+ const saved = await idbGet(opts.dbName ?? DEFAULT_DB_NAME);
79
+ const db = saved ? new SQL.Database(saved) : new SQL.Database();
80
+ // after: const db = saved ? new SQL.Database(saved) : new SQL.Database();
81
+ const orm = new AllezORM(SQL, db, opts);
82
+ await orm.execute("PRAGMA foreign_keys = ON;"); // <-- add this line
83
+ await orm.#ensureMeta();
84
+
85
+ const schemas = collectSchemas(opts);
86
+ await orm.registerSchemas(schemas);
87
+ db.exec("PRAGMA foreign_keys = ON;");
88
+
89
+
90
+ return orm;
91
+ }
92
+
93
+ // ---------------- core SQL helpers ----------------
94
+
95
+ async execute(sql, params = []) {
96
+ const stmt = this.db.prepare(sql);
97
+ try {
98
+ stmt.bind(params);
99
+ while (stmt.step()) { } // drain
100
+ } finally {
101
+ stmt.free();
102
+ }
103
+ this.#scheduleSave();
104
+ }
105
+
106
+ async query(sql, params = []) {
107
+ const stmt = this.db.prepare(sql);
108
+ const out = [];
109
+ try {
110
+ stmt.bind(params);
111
+ while (stmt.step()) out.push(stmt.getAsObject());
112
+ } finally {
113
+ stmt.free();
114
+ }
115
+ return out;
116
+ }
117
+
118
+ async get(sql, params = []) {
119
+ const rows = await this.query(sql, params);
120
+ return rows[0];
121
+ }
122
+
123
+ // ---------------- table helper ----------------
124
+
125
+ table(table) {
126
+ const self = this;
127
+ return {
128
+ async insert(obj) {
129
+ const cols = Object.keys(obj);
130
+ const qs = cols.map(() => "?").join(",");
131
+ await self.execute(
132
+ `INSERT INTO ${table} (${cols.join(",")}) VALUES (${qs})`,
133
+ cols.map(c => obj[c])
134
+ );
135
+ },
136
+ async upsert(obj) {
137
+ const cols = Object.keys(obj);
138
+ const qs = cols.map(() => "?").join(",");
139
+ const updates = cols.map(c => `${c}=excluded.${c}`).join(",");
140
+ await self.execute(
141
+ `INSERT INTO ${table} (${cols.join(",")}) VALUES (${qs})
142
+ ON CONFLICT(id) DO UPDATE SET ${updates}`,
143
+ cols.map(c => obj[c])
144
+ );
145
+ },
146
+ async update(id, patch) {
147
+ const cols = Object.keys(patch);
148
+ if (!cols.length) return;
149
+ const assigns = cols.map(c => `${c}=?`).join(",");
150
+ await self.execute(
151
+ `UPDATE ${table} SET ${assigns} WHERE id=?`,
152
+ [...cols.map(c => patch[c]), id]
153
+ );
154
+ },
155
+ async deleteSoft(id, ts = new Date().toISOString()) {
156
+ await self.execute(`UPDATE ${table} SET deleted_at=? WHERE id=?`, [ts, id]);
157
+ },
158
+ async remove(id) {
159
+ await self.execute(`DELETE FROM ${table} WHERE id=?`, [id]);
160
+ },
161
+ async findById(id) {
162
+ return await self.get(`SELECT * FROM ${table} WHERE id=?`, [id]);
163
+ },
164
+ async searchLike(q, columns, limit = 50) {
165
+ if (!columns?.length) return [];
166
+ const where = columns.map(c => `${table}.${c} LIKE ?`).join(" OR ");
167
+ const params = columns.map(() => `%${q}%`);
168
+ return await self.query(
169
+ `SELECT * FROM ${table} WHERE (${where}) LIMIT ?`,
170
+ [...params, limit]
171
+ );
172
+ }
173
+ };
174
+ }
175
+
176
+ // ---------------- schema registration ----------------
177
+
178
+ /** @param {Schema[]} schemas */
179
+ async registerSchemas(schemas) {
180
+ const meta = await this.#currentVersions();
181
+ for (const s of schemas) {
182
+ const exists = await this.get(
183
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
184
+ [s.table]
185
+ );
186
+ if (!exists) {
187
+ await this.execute(s.createSQL);
188
+ if (Array.isArray(s.extraSQL)) {
189
+ for (const x of s.extraSQL) await this.execute(x);
190
+ }
191
+ await this.execute(
192
+ `INSERT OR REPLACE INTO allez_meta(table_name,version) VALUES(?,?)`,
193
+ [s.table, s.version ?? 1]
194
+ );
195
+ } else {
196
+ const cur = meta.get(s.table) ?? 1;
197
+ const next = s.version ?? cur;
198
+ if (s.onUpgrade && next > cur) {
199
+ await s.onUpgrade(this.db, cur, next);
200
+ await this.execute(
201
+ `UPDATE allez_meta SET version=? WHERE table_name=?`,
202
+ [next, s.table]
203
+ );
204
+ }
205
+ }
206
+ }
207
+ this.#scheduleSave();
208
+ }
209
+
210
+ async saveNow() {
211
+ const data = this.db.export(); // Uint8Array
212
+ await idbSet(this.dbName, data);
213
+ }
214
+
215
+ // ---------------- internals ----------------
216
+
217
+ async #ensureMeta() {
218
+ await this.execute(`
219
+ CREATE TABLE IF NOT EXISTS allez_meta (
220
+ table_name TEXT PRIMARY KEY,
221
+ version INTEGER NOT NULL
222
+ );
223
+ `);
224
+ }
225
+
226
+ async #currentVersions() {
227
+ const rows = await this.query(
228
+ `SELECT table_name, version FROM allez_meta`
229
+ );
230
+ return new Map(rows.map(r => [r.table_name, r.version]));
231
+ }
232
+
233
+ #scheduleSave() {
234
+ clearTimeout(this.saveTimer);
235
+ this.saveTimer = setTimeout(() => { void this.saveNow(); }, this.autoSaveMs);
236
+ }
237
+ }
238
+
239
+ // ---------------- helpers: schema collection + IndexedDB ----------------
240
+
241
+ /** @param {InitOptions} opts */
242
+ function collectSchemas(opts) {
243
+ const fromModules = opts.schemaModules
244
+ ? Object.values(opts.schemaModules).map(m => m.default)
245
+ : [];
246
+ const fromArray = opts.schemas ?? [];
247
+ return [...fromModules, ...fromArray];
248
+ }
249
+
250
+ function openIdb() {
251
+ return new Promise((resolve, reject) => {
252
+ const req = indexedDB.open("allez-orm-store", 1);
253
+ req.onupgradeneeded = () => req.result.createObjectStore("dbs");
254
+ req.onerror = () => reject(req.error);
255
+ req.onsuccess = () => resolve(req.result);
256
+ });
257
+ }
258
+
259
+ async function idbGet(key) {
260
+ const db = await openIdb();
261
+ return new Promise((resolve, reject) => {
262
+ const tx = db.transaction("dbs", "readonly");
263
+ const store = tx.objectStore("dbs");
264
+ const get = store.get(key);
265
+ get.onsuccess = () => resolve(get.result || null);
266
+ get.onerror = () => reject(get.error);
267
+ });
268
+ }
269
+
270
+ async function idbSet(key, value) {
271
+ const db = await openIdb();
272
+ return new Promise((resolve, reject) => {
273
+ const tx = db.transaction("dbs", "readwrite");
274
+ tx.objectStore("dbs").put(value, key);
275
+ tx.oncomplete = () => resolve();
276
+ tx.onerror = () => reject(tx.error);
277
+ });
278
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "allez-orm",
3
+ "version": "1.0.0",
4
+ "description": "AllezORM: lightweight browser SQLite ORM (sql.js) + schema generator CLI",
5
+ "type": "module",
6
+ "main": "./allez-orm.mjs",
7
+ "module": "./allez-orm.mjs",
8
+ "browser": "./allez-orm.mjs",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./allez-orm.mjs"
12
+ },
13
+ "./cli": "./tools/allez-orm.mjs"
14
+ },
15
+ "sideEffects": false,
16
+ "scripts": {
17
+ "dev": "node dev-server.mjs",
18
+ "dev:static": "serve -l 5173",
19
+ "allez": "node tools/allez-orm.mjs",
20
+ "test:cli": "node tests/test-cli.mjs",
21
+ "ddl:audit": "node tools/ddl-audit.mjs",
22
+ "prepublishOnly": "node tests/test-cli.mjs && node tools/ddl-audit.mjs"
23
+ },
24
+ "bin": {
25
+ "allez-orm": "tools/allez-orm.mjs"
26
+ },
27
+ "files": [
28
+ "allez-orm.mjs",
29
+ "tools/allez-orm.mjs",
30
+ "tools/ddl-audit.mjs",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "keywords": [
35
+ "sqlite",
36
+ "sql.js",
37
+ "wasm",
38
+ "browser",
39
+ "orm",
40
+ "cli",
41
+ "schema",
42
+ "ionic",
43
+ "angular",
44
+ "typescript"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+ssh://git@github.com/AllezMarketing/allez-orm.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/AllezMarketing/allez-orm/issues"
52
+ },
53
+ "homepage": "https://github.com/AllezMarketing/allez-orm#readme",
54
+ "license": "ISC",
55
+ "author": "Allez Marketing LLC",
56
+ "devDependencies": {
57
+ "serve": "^14.2.5"
58
+ },
59
+ "engines": {
60
+ "node": ">=18"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env node
2
+ // tools/allez-orm.mjs — AllezORM schema generator with foreign-key support (SQLite)
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+
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() {
16
+ console.log(`
17
+ AllezORM schema generator
18
+
19
+ 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
44
+ `);
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
+ };
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;
63
+ }
64
+
65
+ function parseArgs(argv) {
66
+ 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
69
+ };
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);
83
+ }
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
+ return out;
89
+ }
90
+
91
+ // ---- helper: parse "name[:type][shortFlags]" into parts ----
92
+ function parseNameTypeFlags(base) {
93
+ let name, rhs = "", explicitType = false;
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
101
+ }
102
+
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
+ }
119
+
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));
128
+
129
+ // Normalize the type token if present
130
+ let type = typeToken ? (TYPE_ALIASES[typeToken.toLowerCase()] ?? typeToken.toUpperCase()) : "";
131
+
132
+ return { name, type, flags, explicitType };
133
+ }
134
+
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) };
144
+ }
145
+
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
+ }
168
+
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()
182
+ }
183
+
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}'`);
202
+ }
203
+
204
+ return { kind: "field", name, type, flags, fk, wantsIndex: flags.has("index") };
205
+ }
206
+
207
+ function buildColumnSQL(f, globalFK) {
208
+ const parts = [`${f.name} ${f.type}`];
209
+
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
+ }
230
+
231
+ const def = [...(f.flags || [])].find(x => String(x).startsWith("default="));
232
+ if (def) parts.push("DEFAULT " + String(def).split("=")[1]);
233
+
234
+ return parts.join(" ");
235
+ }
236
+
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 '--'
242
+
243
+ if (!a.length || ["-h", "--help", "help"].includes(a[0])) return help();
244
+ if (!(a[0] === "create" && a[1] === "table")) return help();
245
+
246
+ const table = sanitizeTable(a[2] || "");
247
+ if (!table) die("Missing table name.");
248
+ const pascal = kebabToPascal(table) + "Schema";
249
+
250
+ const specs = (a.slice(3) || []).map(parseFieldSpec);
251
+
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"]) });
256
+
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
+ }
263
+
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});`);
284
+ }
285
+ }
286
+
287
+ const fileJS =
288
+ `// ./schemas/${table}.schema.js
289
+ const ${pascal} = {
290
+ table: "${table}",
291
+ version: ${cfg.version},
292
+
293
+ createSQL: \`
294
+ ${ddl.split("\n").join("\n ")}
295
+ \`,
296
+
297
+ extraSQL: [
298
+ ${[...extraIdx].map(s => ` \`${s}\``).join("\n")}
299
+ ]
300
+ };
301
+
302
+ export default ${pascal};
303
+ `;
304
+
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},
311
+
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
+ `;
323
+
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)}`);
357
+ }
358
+ }
359
+ }
360
+
361
+ await main();
@@ -0,0 +1,84 @@
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
28
+ }
29
+ return blocks;
30
+ }
31
+
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 };
40
+ }
41
+
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
+ }
76
+ }
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);
84
+ }