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 +471 -7
- package/allez-orm.mjs +39 -13
- package/index.d.ts +7 -0
- package/package.json +10 -2
- package/tools/allez-orm.mjs +581 -436
package/README.md
CHANGED
|
@@ -1,7 +1,471 @@
|
|
|
1
|
-
# AllezORM
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/allez-orm)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
[](#tests)
|
|
12
|
+
[](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 ${
|
|
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 =
|
|
176
|
+
const updates = safeCols.map(c => `${c}=excluded.${c}`).join(",");
|
|
157
177
|
await self.execute(
|
|
158
|
-
`INSERT INTO ${
|
|
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 ${
|
|
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 ${
|
|
194
|
+
await self.execute(`UPDATE ${t} SET "deletedAt"=? WHERE "id"=?`, [ts, id]);
|
|
176
195
|
} catch {
|
|
177
|
-
await self.execute(`UPDATE ${
|
|
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 ${
|
|
200
|
+
await self.execute(`DELETE FROM ${t} WHERE "id"=?`, [id]);
|
|
182
201
|
},
|
|
183
202
|
async findById(id) {
|
|
184
|
-
return await self.get(`SELECT * FROM ${
|
|
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 => `${
|
|
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 ${
|
|
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
|
|
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
|
-
"
|
|
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": {
|