allez-orm 1.0.15 → 1.2.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/README.md CHANGED
@@ -1,7 +1,471 @@
1
- # AllezORM
2
- browser SQLite ORM + schema generator.
3
-
4
- ## Install
5
-
6
- npm i allez-orm
7
-
1
+ # AllezORM
2
+
3
+ **Ship a real SQLite database — in the browser — in under five minutes.**
4
+ Zero servers. Zero migrations. Zero "your data is gone because the user refreshed."
5
+
6
+ > Built for the next era of software: client-first apps, AI-generated code,
7
+ > and teams that ship faster than infrastructure can keep up.
8
+
9
+ [![npm](https://img.shields.io/npm/v/allez-orm.svg)](https://www.npmjs.com/package/allez-orm)
10
+ [![license](https://img.shields.io/badge/license-ISC-blue.svg)](LICENSE)
11
+ [![tests](https://img.shields.io/badge/tests-67%20passing-brightgreen.svg)](#tests)
12
+ [![node](https://img.shields.io/badge/node-%3E=18-success.svg)](package.json)
13
+
14
+ ---
15
+
16
+ ## Why this exists (read this part)
17
+
18
+ You've felt all three of these. Recently.
19
+
20
+ 1. **Your "simple" app needs a real query layer in the browser**, and you're
21
+ one library choice away from `localStorage.setItem(JSON.stringify(...))`
22
+ creeping into production. Again.
23
+ 2. **Your AI agent confidently rewrote your schema** on Tuesday and you
24
+ didn't notice until staging. The spec said one thing. The code did
25
+ another. Nobody owned the diff.
26
+ 3. **You shipped a "client-side prototype"** that became the product, and
27
+ now you're trying to graft IndexedDB persistence, foreign keys, and
28
+ migrations onto something that was never designed for them.
29
+
30
+ AllezORM is a small, sharp answer to all three.
31
+
32
+ Open it, point it at a schema, and you have a real SQLite database
33
+ running in the browser tab — persisted to IndexedDB on every write,
34
+ queried with prepared statements, protected by inline foreign keys.
35
+ The schema generator writes idiomatic, version-pinned files that your
36
+ agents **cannot** silently drift away from. Because every file is
37
+ anchored — by SHA-256 — back to the spec it came from.
38
+
39
+ That's the whole pitch. The rest of this README shows you the receipts.
40
+
41
+ ---
42
+
43
+ ## What you get in 60 seconds
44
+
45
+ ```bash
46
+ npm install allez-orm
47
+ ```
48
+
49
+ ```js
50
+ // app.js
51
+ import { AllezORM } from "allez-orm";
52
+ import users from "./schemas/users.schema.js";
53
+ import posts from "./schemas/posts.schema.js";
54
+
55
+ const orm = await AllezORM.init({ dbName: "myapp.db", schemas: [users, posts] });
56
+
57
+ // Real prepared statements. Real foreign keys. Real persistence.
58
+ await orm.table("users").upsert({ id: 1, email: "a@b.co", display_name: "Ada" });
59
+ await orm.table("posts").insert({ user_id: 1, title: "Hello world" });
60
+
61
+ const recent = await orm.query(
62
+ "SELECT u.email, p.title FROM posts p JOIN users u ON u.id = p.user_id ORDER BY p.id DESC LIMIT 10"
63
+ );
64
+ ```
65
+
66
+ That's it. The database is alive in the tab. Refresh the page — it's still there.
67
+ Open DevTools → Application → IndexedDB → `myapp.db`. The whole SQLite file is sitting there as a `Uint8Array`.
68
+
69
+ No backend. No service worker. No build step. No "but does it work in Safari" anxiety.
70
+
71
+ ---
72
+
73
+ ## The five things that make it different
74
+
75
+ ### 1. **Spec-anchored schemas your agents can't silently break**
76
+
77
+ Here's what a generated schema file looks like:
78
+
79
+ ```js
80
+ // users.schema.js (generated by allez-orm)
81
+ // DO NOT EDIT — regenerate by editing the spec, then running:
82
+ // allez-orm from-json ../spec.json
83
+ // SPEC ../spec.json
84
+ // SPEC_SHA256 0f1ab429adc01ddeaa093a4b18a87836547f437e1bc31f5d1babddf9935c47dc
85
+ // TABLE_SHA256 531b42a728116ec1332467a5fb83b93f230afaf5ea75f4b4019ef2cf5d997adc
86
+ const usersSchema = {
87
+ table: "users",
88
+ version: 1,
89
+ createSQL: `
90
+ CREATE TABLE IF NOT EXISTS users (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ email TEXT UNIQUE NOT NULL,
93
+ created_at TEXT NOT NULL,
94
+ updated_at TEXT NOT NULL,
95
+ deleted_at TEXT
96
+ );`
97
+ };
98
+ export default usersSchema;
99
+ ```
100
+
101
+ Every generated file carries two cryptographic anchors back to the spec it came from.
102
+ When an agent (or a junior dev, or future-you at 2am) opens this file, the very first
103
+ thing they see is *"DO NOT EDIT — regenerate from the spec."*
104
+
105
+ Don't trust the comment? Don't have to. Run drift detection:
106
+
107
+ ```bash
108
+ $ allez-orm verify schemas.spec.json --dir=./schemas
109
+ ✔ verify: 4 table(s) match spec schemas.spec.json
110
+ ```
111
+
112
+ Hand-edit a generated file:
113
+
114
+ ```bash
115
+ $ allez-orm verify schemas.spec.json --dir=./schemas
116
+ ✗ Drift detected in 1 file(s):
117
+ - ./schemas/users.schema.js
118
+
119
+ To resync, run: allez-orm from-json schemas.spec.json --dir=./schemas -f
120
+ ```
121
+
122
+ Wire that into CI. Wire it into your pre-commit hook. **Your agents do not get to
123
+ quietly mutate your contract anymore.**
124
+
125
+ You also get an `AGENTS.md` auto-emitted next to the schemas — picked up
126
+ natively by Claude Code, Cursor, and every IDE that respects the convention —
127
+ telling future agents exactly where the source of truth lives and how to
128
+ regenerate.
129
+
130
+ > This is the part Luca asked about. Now it ships.
131
+
132
+ ### 2. **A SQLite database in the browser, persisted, with FKs that actually enforce**
133
+
134
+ This is not a Promise-flavored wrapper around `localStorage`. AllezORM runs
135
+ **real SQLite via sql.js (WASM)** — the same engine in your phone, your browser, every
136
+ plane in the air, and most cars sold this decade.
137
+
138
+ - `PRAGMA foreign_keys = ON` — and stays on, even across saves. (We found and
139
+ fixed a subtle sql.js bug where `db.export()` resets connection PRAGMAs.
140
+ Now they don't.)
141
+ - Prepared statements for every helper — no string concatenation, no
142
+ injection holes. SQL identifier validation rejects anything that
143
+ doesn't match `/^[A-Za-z_][A-Za-z0-9_]*$/`, and we test it against
144
+ the OWASP injection corpus.
145
+ - Debounced auto-save to IndexedDB after every write. Configurable.
146
+ - Versioned `onUpgrade(db, from, to)` hook per schema, so you can ship
147
+ v2 of your app and migrate users in-place.
148
+
149
+ ### 3. **A CLI that generates schemas the way you'd write them by hand**
150
+
151
+ ```bash
152
+ # Ad-hoc — one table, fast:
153
+ allez-orm create table users email:text!+ display_name:text! --stamps
154
+
155
+ # Or declarative — the source-of-truth path:
156
+ allez-orm from-json schemas.spec.json
157
+ ```
158
+
159
+ Compact field syntax that fits in your head: `name:type` with flag suffixes
160
+ (`!` = NOT NULL, `+` = UNIQUE, `->target` = inline foreign key, `--onDelete=`
161
+ for every FK at once). One mental model. No DSL to memorize.
162
+
163
+ Spec a referenced table that doesn't exist yet? The CLI auto-generates an
164
+ ID-only stub for it so your DDL compiles immediately and you can fill it in later.
165
+
166
+ ### 4. **AllezORM Studio — a visual workbench for your in-browser DB**
167
+
168
+ `npm run dev` opens [Studio](index.html): a clean, single-page workbench
169
+ with searchable tables, sortable columns, a SQL scratchpad, and a
170
+ **Create table** / **Add column** dialog that calls the CLI for you
171
+ and live-reloads the ORM.
172
+
173
+ It's the kind of tool you wish you had during the first week of a new
174
+ project. Now you do.
175
+
176
+ ### 5. **Tested with the seriousness of a database**
177
+
178
+ This repo currently ships with **67 automated tests across six suites**:
179
+
180
+ | Suite | What it covers |
181
+ |---|---|
182
+ | [test-cli.mjs](tests/test-cli.mjs) | CLI smoke tests, force/overwrite, FK stubs |
183
+ | [test-security.mjs](tests/test-security.mjs) | SQL identifier injection corpus, prepared-statement integration |
184
+ | [test-spec-anchor.mjs](tests/test-spec-anchor.mjs) | Spec-anchor headers, AGENTS.md, drift detection |
185
+ | [test-generator-thorough.mjs](tests/test-generator-thorough.mjs) | 21 edge-case generator scenarios, real sql.js compile-and-run, idempotency |
186
+ | [test-fk-persistence.mjs](tests/test-fk-persistence.mjs) | Regression: FK pragma survives `db.export()` and repeated saves |
187
+ | [test-e2e-studio.mjs](tests/test-e2e-studio.mjs) | Playwright E2E: full Studio flow including IndexedDB reload, FK violations |
188
+
189
+ ```bash
190
+ npm test # everything except the browser E2E
191
+ npm run test:e2e # browser E2E (requires playwright + chromium)
192
+ npm run test:all # both
193
+ ```
194
+
195
+ ---
196
+
197
+ ## Install
198
+
199
+ ```bash
200
+ npm install allez-orm
201
+ ```
202
+
203
+ You don't need a bundler. You don't need a framework. You don't need a server.
204
+ The package is **pure ESM**, runs in browsers natively, and exposes a single
205
+ default entry plus a CLI:
206
+
207
+ - `import { AllezORM } from "allez-orm"` — the ORM
208
+ - `npx allez-orm …` — the schema generator
209
+ - `import "allez-orm/cli"` — programmatic CLI access
210
+
211
+ WASM is loaded from [sql.js's CDN](https://sql.js.org) by default. Want to
212
+ self-host the WASM? Pass `wasmLocateFile` to `AllezORM.init`.
213
+
214
+ ---
215
+
216
+ ## Quickstart: from zero to a real database in 5 commands
217
+
218
+ ```bash
219
+ # 1. Init a project
220
+ npm init -y && npm install allez-orm
221
+
222
+ # 2. Generate a schema declaratively
223
+ cat > schemas.spec.json <<'JSON'
224
+ {
225
+ "outDir": "./schemas",
226
+ "defaultOnDelete": "cascade",
227
+ "tables": [
228
+ { "name": "users", "stamps": true,
229
+ "fields": [
230
+ { "name": "email", "type": "text", "unique": true, "notnull": true },
231
+ { "name": "display_name", "type": "text", "notnull": true }
232
+ ] },
233
+ { "name": "posts", "stamps": true,
234
+ "fields": [
235
+ { "name": "title", "type": "text", "notnull": true },
236
+ { "name": "user_id", "type": "integer", "fk": { "table": "users" } }
237
+ ] }
238
+ ]
239
+ }
240
+ JSON
241
+
242
+ # 3. Generate the schema files (and AGENTS.md)
243
+ npx allez-orm from-json schemas.spec.json
244
+
245
+ # 4. Verify alignment any time you like
246
+ npx allez-orm verify schemas.spec.json
247
+
248
+ # 5. Use it from your app
249
+ echo 'import { AllezORM } from "allez-orm";
250
+ import users from "./schemas/users.schema.js";
251
+ import posts from "./schemas/posts.schema.js";
252
+ const orm = await AllezORM.init({ schemas: [users, posts] });
253
+ await orm.table("users").upsert({ id: 1, email: "a@b.co", display_name: "Ada", created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
254
+ console.log(await orm.query("SELECT * FROM users"));
255
+ ' > app.mjs
256
+ ```
257
+
258
+ You now have a typed, persistent, foreign-key-enforced database
259
+ running in any modern browser, generated from a single source-of-truth file.
260
+
261
+ ---
262
+
263
+ ## The CLI
264
+
265
+ ```
266
+ allez-orm create table <name> [fields...] [options]
267
+ allez-orm from-json <config.json> [--dir=<outDir>] [-f|--force]
268
+ allez-orm verify <config.json> [--dir=<outDir>]
269
+ allez-orm --print-json-schema
270
+ ```
271
+
272
+ ### Field syntax (CLI form)
273
+
274
+ ```
275
+ col[:type][flags][->target]
276
+
277
+ Examples:
278
+ email:text!+ email TEXT UNIQUE NOT NULL
279
+ user_id:integer->users user_id INTEGER REFERENCES users(id)
280
+ parent_id:integer->nodes self-referencing FK; no stub generated
281
+ body body TEXT (type defaults to TEXT)
282
+ ```
283
+
284
+ Flags: `!` = NOT NULL, `+` = UNIQUE.
285
+ Type aliases (case-insensitive): `text|string`, `int|integer`, `real|float`, `numeric|number`, `blob`, `bool`.
286
+
287
+ ### Options
288
+
289
+ | Flag | What it does |
290
+ |---|---|
291
+ | `--dir=<outDir>` | Output directory (defaults to `schemas_cli`, or `outDir` from spec) |
292
+ | `--stamps` | Add `created_at` (NOT NULL), `updated_at` (NOT NULL), `deleted_at` (nullable) |
293
+ | `--onDelete=<mode>` | Apply ON DELETE behavior to every FK: `cascade\|restrict\|setnull\|noaction` |
294
+ | `-f, --force` | Overwrite existing files (also: `ALLEZ_FORCE=1`) |
295
+ | `--help` | Show help |
296
+
297
+ ### `from-json` config shape
298
+
299
+ Print the canonical JSON Schema:
300
+
301
+ ```bash
302
+ npx allez-orm --print-json-schema
303
+ ```
304
+
305
+ The config supports both **rich field objects** and **compact string tokens** in the same array — pick whichever fits the table:
306
+
307
+ ```json
308
+ {
309
+ "tables": [
310
+ { "name": "items", "fields": ["sku:text!+", "qty:integer!"] },
311
+ { "name": "memberships", "fields": [
312
+ { "name": "user_id", "type": "integer", "fk": { "table": "users" } },
313
+ { "name": "org_id", "type": "integer", "fk": { "table": "orgs" } }
314
+ ]}
315
+ ]
316
+ }
317
+ ```
318
+
319
+ ### `verify` — your CI safety net
320
+
321
+ `verify` re-runs generation **in memory** and byte-compares against what's on disk.
322
+ It exits non-zero on:
323
+
324
+ - **Drift** — a generated file was hand-edited.
325
+ - **Stale files** — the spec moved forward but files weren't regenerated.
326
+ - **Missing files** — the spec references a table whose file isn't there.
327
+
328
+ ```yaml
329
+ # .github/workflows/ci.yml
330
+ - run: npx allez-orm verify schemas.spec.json
331
+ ```
332
+
333
+ One line. No more 2am Slack messages about schema drift.
334
+
335
+ ---
336
+
337
+ ## The ORM API
338
+
339
+ ### Init
340
+
341
+ ```js
342
+ const orm = await AllezORM.init({
343
+ dbName: "myapp.db", // IndexedDB key; defaults to "allez.db"
344
+ autoSaveMs: 1500, // debounce window for IndexedDB writes
345
+ wasmLocateFile: f => `/wasm/${f}`, // self-host sql-wasm.wasm
346
+ schemas: [usersSchema, postsSchema]
347
+ });
348
+ ```
349
+
350
+ ### Per-table helpers (parameterized, FK-aware)
351
+
352
+ ```js
353
+ const users = orm.table("users");
354
+
355
+ await users.insert({ email: "a@b.co", display_name: "Ada" });
356
+ await users.upsert({ id: 1, email: "a@b.co", display_name: "Ada" });
357
+ await users.update(1, { display_name: "Ada Lovelace" });
358
+ await users.findById(1); // single row
359
+ await users.searchLike("ada", ["display_name"]); // LIKE %ada% across columns
360
+ await users.deleteSoft(1); // sets deleted_at / deletedAt
361
+ await users.remove(1); // hard delete
362
+ ```
363
+
364
+ ### Raw SQL — when you need it
365
+
366
+ ```js
367
+ const rows = await orm.query("SELECT * FROM users WHERE id IN (?, ?)", [1, 2]);
368
+ const one = await orm.get("SELECT * FROM users LIMIT 1");
369
+ await orm.execute("UPDATE users SET display_name = ? WHERE id = ?", ["X", 1]);
370
+ ```
371
+
372
+ ### Schema versioning
373
+
374
+ ```js
375
+ const usersSchema = {
376
+ table: "users",
377
+ version: 2,
378
+ createSQL: `CREATE TABLE IF NOT EXISTS users (...);`,
379
+ async onUpgrade(db, from, to) {
380
+ if (from < 2) db.exec(`ALTER TABLE users ADD COLUMN avatar_url TEXT`);
381
+ }
382
+ };
383
+ ```
384
+
385
+ ### Manual save
386
+
387
+ ```js
388
+ await orm.saveNow(); // force a write to IndexedDB right now
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Security
394
+
395
+ This package treats SQL identifiers as an attack surface. Every table and
396
+ column name passed to the `table()` helpers goes through `safeIdent()`,
397
+ which validates against a strict character class and rejects every
398
+ payload in the OWASP injection corpus. You can audit the test for yourself
399
+ at [tests/test-security.mjs](tests/test-security.mjs) (13 assertions).
400
+
401
+ All values are bound via prepared statements. There is no string
402
+ concatenation of user input into SQL anywhere in the runtime.
403
+
404
+ If you find a security issue, please open a private advisory in the
405
+ GitHub repo. We respond.
406
+
407
+ ---
408
+
409
+ ## What you avoid by using this
410
+
411
+ This is the part that's hard to feel until you've been burned. So picture it:
412
+
413
+ - **No "the IndexedDB schema is one thing, the SQL schema is another" debugging at midnight.** One file is the truth. One command verifies it.
414
+ - **No agents quietly mutating your contract.** The header on every generated file is unmissable. The verify command is automatic.
415
+ - **No `localStorage.setItem(JSON.stringify(...))`** sprawling across your codebase as you slowly reinvent indexes. The Studio is the indexed database you wanted on day one.
416
+ - **No "we'll add a real backend later"** technical debt. You may discover you never need one. Plenty of apps don't.
417
+ - **No surprise FK behavior.** We tested the pragma persistence so you don't have to.
418
+
419
+ ---
420
+
421
+ ## Roadmap & status
422
+
423
+ AllezORM is **production-shape for client-only apps**: tests gate every
424
+ release, the API surface is small and stable, and the schema generator's
425
+ output format is now SHA-anchored (so we'll bump major if we ever change it).
426
+
427
+ Coming soon:
428
+
429
+ - WAL-style append journaling for crash-safe writes
430
+ - Optional Web Worker mode for off-main-thread queries
431
+ - An `allez-orm sync` command for two-way reconciliation with a remote SQL database (so the same schema works client-side and server-side)
432
+
433
+ Want one of those sooner? Open an issue and say so.
434
+
435
+ ---
436
+
437
+ ## Contributing
438
+
439
+ ```bash
440
+ git clone https://github.com/AllezMarketing/allez-orm.git
441
+ cd allez-orm
442
+ npm install
443
+ npm test # unit + integration
444
+ npx playwright install chromium
445
+ npm run test:e2e # browser E2E
446
+ npm run dev # launch Studio at http://localhost:5174
447
+ ```
448
+
449
+ PRs that touch generator output **must** keep the existing schema files
450
+ byte-stable (or bump version and update the SHA anchors deliberately).
451
+ `npm test` will tell you immediately.
452
+
453
+ ---
454
+
455
+ ## License
456
+
457
+ ISC © [Allez Marketing LLC](https://github.com/AllezMarketing)
458
+
459
+ ---
460
+
461
+ ## One last thing
462
+
463
+ If you read this far, you already know what to do. The install is one
464
+ command. The first table is one config. The verify is one CI line.
465
+
466
+ The friction between you and a working database is **smaller than it has
467
+ ever been.** Use the next ten minutes well.
468
+
469
+ ```bash
470
+ npm install allez-orm
471
+ ```
package/allez-orm.mjs CHANGED
@@ -27,6 +27,23 @@ const DEFAULT_DB_NAME = "allez.db";
27
27
  const DEFAULT_AUTOSAVE_MS = 1500;
28
28
  const isBrowser = typeof window !== "undefined";
29
29
 
30
+ // -------- identifier safety --------
31
+ const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
32
+
33
+ /** Validate and quote a SQL identifier (table or column name). */
34
+ function safeIdent(name) {
35
+ if (typeof name !== "string" || !name) {
36
+ throw new Error(`Invalid SQL identifier: ${JSON.stringify(name)}`);
37
+ }
38
+ if (!SAFE_IDENT_RE.test(name)) {
39
+ throw new Error(
40
+ `Unsafe SQL identifier rejected: ${JSON.stringify(name)}. ` +
41
+ `Identifiers must match /^[A-Za-z_][A-Za-z0-9_]*$/.`
42
+ );
43
+ }
44
+ return `"${name}"`;
45
+ }
46
+
30
47
  // -------- sql.js loader (browser-safe, no node core deps) --------
31
48
  async function loadSqlJs(opts = {}) {
32
49
  // 1) If user included <script src="https://sql.js.org/dist/sql-wasm.js">, use it.
@@ -141,54 +158,56 @@ export class AllezORM {
141
158
 
142
159
  table(table) {
143
160
  const self = this;
161
+ const t = safeIdent(table);
144
162
  return {
145
163
  async insert(obj) {
146
164
  const cols = Object.keys(obj);
165
+ const safeCols = cols.map(c => safeIdent(c));
147
166
  const qs = cols.map(() => "?").join(",");
148
167
  await self.execute(
149
- `INSERT INTO ${table} (${cols.join(",")}) VALUES (${qs})`,
168
+ `INSERT INTO ${t} (${safeCols.join(",")}) VALUES (${qs})`,
150
169
  cols.map(c => obj[c])
151
170
  );
152
171
  },
153
172
  async upsert(obj) {
154
173
  const cols = Object.keys(obj);
174
+ const safeCols = cols.map(c => safeIdent(c));
155
175
  const qs = cols.map(() => "?").join(",");
156
- const updates = cols.map(c => `${c}=excluded.${c}`).join(",");
176
+ const updates = safeCols.map(c => `${c}=excluded.${c}`).join(",");
157
177
  await self.execute(
158
- `INSERT INTO ${table} (${cols.join(",")}) VALUES (${qs})
159
- ON CONFLICT(id) DO UPDATE SET ${updates}`,
178
+ `INSERT INTO ${t} (${safeCols.join(",")}) VALUES (${qs})
179
+ ON CONFLICT("id") DO UPDATE SET ${updates}`,
160
180
  cols.map(c => obj[c])
161
181
  );
162
182
  },
163
183
  async update(id, patch) {
164
184
  const cols = Object.keys(patch);
165
185
  if (!cols.length) return;
166
- const assigns = cols.map(c => `${c}=?`).join(",");
186
+ const assigns = cols.map(c => `${safeIdent(c)}=?`).join(",");
167
187
  await self.execute(
168
- `UPDATE ${table} SET ${assigns} WHERE id=?`,
188
+ `UPDATE ${t} SET ${assigns} WHERE "id"=?`,
169
189
  [...cols.map(c => patch[c]), id]
170
190
  );
171
191
  },
172
192
  async deleteSoft(id, ts = new Date().toISOString()) {
173
- // keep naming consistent across projects
174
193
  try {
175
- await self.execute(`UPDATE ${table} SET deletedAt=? WHERE id=?`, [ts, id]);
194
+ await self.execute(`UPDATE ${t} SET "deletedAt"=? WHERE "id"=?`, [ts, id]);
176
195
  } catch {
177
- await self.execute(`UPDATE ${table} SET deleted_at=? WHERE id=?`, [ts, id]);
196
+ await self.execute(`UPDATE ${t} SET "deleted_at"=? WHERE "id"=?`, [ts, id]);
178
197
  }
179
198
  },
180
199
  async remove(id) {
181
- await self.execute(`DELETE FROM ${table} WHERE id=?`, [id]);
200
+ await self.execute(`DELETE FROM ${t} WHERE "id"=?`, [id]);
182
201
  },
183
202
  async findById(id) {
184
- return await self.get(`SELECT * FROM ${table} WHERE id=?`, [id]);
203
+ return await self.get(`SELECT * FROM ${t} WHERE "id"=?`, [id]);
185
204
  },
186
205
  async searchLike(q, columns, limit = 50) {
187
206
  if (!columns?.length) return [];
188
- const where = columns.map(c => `${table}.${c} LIKE ?`).join(" OR ");
207
+ const where = columns.map(c => `${t}.${safeIdent(c)} LIKE ?`).join(" OR ");
189
208
  const params = columns.map(() => `%${q}%`);
190
209
  return await self.query(
191
- `SELECT * FROM ${table} WHERE (${where}) LIMIT ?`,
210
+ `SELECT * FROM ${t} WHERE (${where}) LIMIT ?`,
192
211
  [...params, limit]
193
212
  );
194
213
  }
@@ -263,6 +282,10 @@ export class AllezORM {
263
282
 
264
283
  async saveNow() {
265
284
  const data = this.db.export(); // Uint8Array
285
+ // sql.js's db.export() resets connection-scoped PRAGMAs (including
286
+ // foreign_keys) to 0. Re-enable FK enforcement so subsequent writes
287
+ // stay constraint-checked.
288
+ this.db.exec("PRAGMA foreign_keys = ON;");
266
289
  if (isBrowser) await idbSet(this.dbName, data);
267
290
  }
268
291
 
@@ -381,5 +404,8 @@ export async function exec(db, sql, params = []) {
381
404
  await db.execute(sql, params);
382
405
  }
383
406
 
407
+ // Expose safeIdent for consumers who build custom SQL.
408
+ export { safeIdent };
409
+
384
410
  // Keep a default export for advanced consumers.
385
411
  export default AllezORM;
package/index.d.ts CHANGED
@@ -18,6 +18,13 @@ export interface InitOptions {
18
18
 
19
19
  export type Row = Record<string, any>;
20
20
 
21
+ /**
22
+ * Validate and double-quote a SQL identifier (table or column name).
23
+ * Throws if the name contains unsafe characters.
24
+ * Valid pattern: /^[A-Za-z_][A-Za-z0-9_]*$/
25
+ */
26
+ export function safeIdent(name: string): string;
27
+
21
28
  export interface TableHelper<T extends Row = Row> {
22
29
  insert(obj: Partial<T>): Promise<void>;
23
30
  upsert(obj: Partial<T>): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "allez-orm",
3
- "version": "1.0.15",
3
+ "version": "1.2.0",
4
4
  "description": "AllezORM: lightweight browser SQLite ORM (sql.js) + schema generator CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,14 @@
33
33
  "allez": "node tools/allez-orm.mjs",
34
34
  "test:cli": "node tests/test-cli.mjs",
35
35
  "ddl:audit": "node tools/ddl-audit.mjs",
36
- "prepublishOnly": "node tests/test-cli.mjs && node tools/ddl-audit.mjs"
36
+ "test:security": "node tests/test-security.mjs",
37
+ "test:anchor": "node tests/test-spec-anchor.mjs",
38
+ "test:thorough": "node tests/test-generator-thorough.mjs",
39
+ "test:fk": "node tests/test-fk-persistence.mjs",
40
+ "test:e2e": "node tests/test-e2e-studio.mjs",
41
+ "test": "node tests/test-cli.mjs && node tests/test-security.mjs && node tests/test-spec-anchor.mjs && node tests/test-generator-thorough.mjs && node tests/test-fk-persistence.mjs && node tools/ddl-audit.mjs",
42
+ "test:all": "npm test && npm run test:e2e",
43
+ "prepublishOnly": "node tests/test-cli.mjs && node tests/test-security.mjs && node tests/test-spec-anchor.mjs && node tests/test-generator-thorough.mjs && node tests/test-fk-persistence.mjs && node tools/ddl-audit.mjs"
37
44
  },
38
45
  "files": [
39
46
  "allez-orm.mjs",
@@ -66,6 +73,7 @@
66
73
  "sql.js": "^1.13.0"
67
74
  },
68
75
  "devDependencies": {
76
+ "playwright": "^1.60.0",
69
77
  "serve": "^14.2.5"
70
78
  },
71
79
  "engines": {