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 +278 -0
- package/package.json +65 -0
- package/tools/allez-orm.mjs +361 -0
- package/tools/ddl-audit.mjs +84 -0
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
|
+
}
|