schematic-pg 0.1.8 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -1102
- package/dist/api/auth/jwt-crypto.d.ts +10 -0
- package/dist/api/auth/jwt-crypto.js +61 -0
- package/dist/api/auth/jwt-resolver.js +2 -27
- package/dist/api/auth/password/config.d.ts +32 -0
- package/dist/api/auth/password/config.js +35 -0
- package/dist/api/auth/password/errors.d.ts +8 -0
- package/dist/api/auth/password/errors.js +14 -0
- package/dist/api/auth/password/index.d.ts +3 -0
- package/dist/api/auth/password/index.js +3 -0
- package/dist/api/auth/password/password.d.ts +14 -0
- package/dist/api/auth/password/password.js +46 -0
- package/dist/api/auth/routes.d.ts +23 -0
- package/dist/api/auth/routes.js +118 -0
- package/dist/api/auth/token/config.d.ts +10 -0
- package/dist/api/auth/token/config.js +43 -0
- package/dist/api/auth/token/errors.d.ts +6 -0
- package/dist/api/auth/token/errors.js +12 -0
- package/dist/api/auth/token/index.d.ts +3 -0
- package/dist/api/auth/token/index.js +3 -0
- package/dist/api/auth/token/token.d.ts +11 -0
- package/dist/api/auth/token/token.js +31 -0
- package/dist/api/middleware/errors.js +11 -0
- package/dist/cli/init.js +3 -1
- package/dist/cli/templates/agents.md +290 -0
- package/dist/cli/templates.d.ts +5 -3
- package/dist/cli/templates.js +22 -6
- package/dist/cli/wait-for-database.js +1 -1
- package/dist/db/db-client-generator.js +20 -2
- package/dist/db/include/executor.d.ts +2 -2
- package/dist/db/include/executor.js +7 -7
- package/dist/db/include/json-agg.d.ts +2 -2
- package/dist/db/include/json-agg.js +4 -4
- package/dist/db/include/load.d.ts +3 -3
- package/dist/db/include/load.js +8 -8
- package/dist/db/index.d.ts +4 -0
- package/dist/db/index.js +2 -0
- package/dist/db/model-client.d.ts +2 -2
- package/dist/db/model-client.js +3 -3
- package/dist/db/queryable.d.ts +5 -0
- package/dist/db/queryable.js +1 -0
- package/dist/db/raw.d.ts +22 -0
- package/dist/db/raw.js +35 -0
- package/dist/db/transaction.d.ts +2 -0
- package/dist/db/transaction.js +24 -0
- package/dist/routes/auth.d.ts +3 -0
- package/dist/routes/auth.js +5 -0
- package/dist/types/generated-db.stub.d.ts +5 -1
- package/dist/types/generated-db.stub.js +8 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -7,28 +7,161 @@
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Documentation
|
|
11
|
+
|
|
12
|
+
- [Philosophy & features](docs/philosophy.md)
|
|
13
|
+
- [How it works](docs/how-it-works.md)
|
|
14
|
+
- [Database client](docs/database-client.md)
|
|
15
|
+
- [REST API](docs/rest-api.md)
|
|
16
|
+
- [Access control](docs/access-control.md)
|
|
17
|
+
- [Migrations tutorial](docs/migrations.md) — schema diffs, `db:migrate`, and GitHub Actions for staging/production
|
|
18
|
+
- [Project structure](docs/project-structure.md)
|
|
19
|
+
- [Contributing (this repo)](docs/contributing.md)
|
|
20
|
+
- [Why schematic-pg?](docs/why.md)
|
|
21
|
+
- [Roadmap](docs/roadmap.md)
|
|
11
22
|
|
|
12
|
-
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
Install the CLI and scaffold a new project:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx schematic-pg init my-app
|
|
31
|
+
cd my-app
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Edit `app.schema`, then start the full dev loop:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
make dev
|
|
38
|
+
# → starts PostgreSQL, generates code, bootstraps the DB, runs the dev server,
|
|
39
|
+
# and watches app.schema for changes (regenerate + bootstrap + restart)
|
|
40
|
+
# → http://localhost:3000
|
|
41
|
+
# → API docs at http://localhost:3000/docs
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or run each step individually:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Start PostgreSQL ( matches .env defaults)
|
|
48
|
+
docker compose up -d --wait
|
|
49
|
+
|
|
50
|
+
# Generate, bootstrap, start server, and watch app.schema (default)
|
|
51
|
+
npx schematic-pg dev
|
|
52
|
+
# → http://localhost:3000
|
|
53
|
+
|
|
54
|
+
# One-shot dev server without schema watching:
|
|
55
|
+
npx schematic-pg dev --no-watch
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Manual split when you need finer control:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx schematic-pg generate
|
|
62
|
+
npx schematic-pg db:bootstrap
|
|
63
|
+
npx schematic-pg dev --no-watch
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The `init` command creates everything you need to get running:
|
|
67
|
+
|
|
68
|
+
| File / directory | Purpose |
|
|
69
|
+
|------------------|---------|
|
|
70
|
+
| `AGENTS.md` | Agent-oriented guide for working with schematic-pg in this project |
|
|
71
|
+
| `app.schema` | Starter schema (one `User` model) — edit this |
|
|
72
|
+
| `.env` | `DATABASE_URL`, JWT settings |
|
|
73
|
+
| `docker-compose.yml` | Local PostgreSQL on `:5432` |
|
|
74
|
+
| `Makefile` | `make dev` — docker compose (with health wait) + `schematic-pg dev` |
|
|
75
|
+
| `tsconfig.json` | TypeScript config for `generated/` and `src/routes/` |
|
|
76
|
+
| `package.json` | `schematic-pg` + runtime deps (`hono`, `pg`, `zod`, …) |
|
|
77
|
+
| `src/routes/health.ts` | Example custom route mounted at `/health` |
|
|
78
|
+
|
|
79
|
+
After `generate`, your project also contains:
|
|
80
|
+
|
|
81
|
+
| Output | Purpose |
|
|
82
|
+
|--------|---------|
|
|
83
|
+
| `schema.sql` | Idempotent PostgreSQL DDL |
|
|
84
|
+
| `generated/db*.ts` | Type-safe DB client |
|
|
85
|
+
| `generated/app.ts` | Hono server entry point |
|
|
86
|
+
| `generated/routes/*.ts` | CRUD routers per model |
|
|
87
|
+
| `generated/policies.ts` | ACL metadata from `@policy` |
|
|
88
|
+
| `generated/schemas/validation.ts` | Zod request validators |
|
|
89
|
+
|
|
90
|
+
Generated code imports the runtime from the `schematic-pg` package (`schematic-pg/api/*`, `schematic-pg/db/*`). You do not copy framework source into your project.
|
|
91
|
+
|
|
92
|
+
### Environment variables
|
|
13
93
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
94
|
+
| Variable | Default | Purpose |
|
|
95
|
+
|----------|---------|---------|
|
|
96
|
+
| `DATABASE_URL` | — (required) | PostgreSQL connection string |
|
|
97
|
+
| `PORT` | `3000` | HTTP listen port |
|
|
98
|
+
| `JWT_SECRET` | — | HMAC secret for JWT sign + verify (required for auth) |
|
|
99
|
+
| `AUTH_PEPPER` | — | App-side pepper appended before Argon2 hash/verify (required for register/login) |
|
|
100
|
+
| `AUTH_ACCESS_TOKEN_TTL` | `1h` | Access token lifetime (`15m`, `1h`, or seconds) |
|
|
101
|
+
| `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
|
|
102
|
+
| `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
|
|
103
|
+
|
|
104
|
+
Set these in `.env` before running `dev`, `start`, or `db:bootstrap`.
|
|
18
105
|
|
|
19
106
|
---
|
|
20
107
|
|
|
21
|
-
##
|
|
108
|
+
## Authentication
|
|
22
109
|
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
110
|
+
schematic-pg verifies Bearer JWTs on every request and ships a reusable auth layer for **register / login / token issuance**. Runtime lives in the package (`schematic-pg/api/auth/*`); projects mount a thin custom route that auto-registers at `/auth`.
|
|
111
|
+
|
|
112
|
+
### Enable routes
|
|
113
|
+
|
|
114
|
+
`init` scaffolds `src/routes/auth.ts`:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { createAuthRouter } from 'schematic-pg/api/auth/routes';
|
|
118
|
+
|
|
119
|
+
export default createAuthRouter();
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
After `generate:api`, the custom-route scanner mounts it at `/auth`. Options let you map your user model/fields (`userModel`, `emailField`, `passwordHashField`, `roleField`, `defaultCreateFields`, …).
|
|
123
|
+
|
|
124
|
+
| Method | Path | Purpose |
|
|
125
|
+
|--------|------|---------|
|
|
126
|
+
| `POST` | `/auth/register` | Create user (hashes password, issues access token). Bypasses model `@policy` — do not weaken insert policies for signup. |
|
|
127
|
+
| `POST` | `/auth/login` | Verify password, optional rehash, issue access token |
|
|
128
|
+
| `GET` | `/auth/me` | Current `auth` context from the JWT middleware |
|
|
129
|
+
|
|
130
|
+
Register/login responses: `{ token, user }` with `passwordHash` omitted (`@omit` / `omitFields`).
|
|
131
|
+
|
|
132
|
+
### Password hashing
|
|
133
|
+
|
|
134
|
+
Use Argon2id via `schematic-pg/api/auth/password`:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { passwordService } from 'schematic-pg/api/auth/password';
|
|
138
|
+
import { UnauthorizedError } from 'schematic-pg/api/auth/errors';
|
|
139
|
+
|
|
140
|
+
const hash = await passwordService.hashPassword(password);
|
|
141
|
+
const valid = await passwordService.verifyPassword(password, user.passwordHash);
|
|
142
|
+
if (!valid) throw new UnauthorizedError();
|
|
143
|
+
if (passwordService.needsRehash(user.passwordHash)) {
|
|
144
|
+
const newHash = await passwordService.hashPassword(password);
|
|
145
|
+
await db.user.update({ where: { id: user.id }, data: { passwordHash: newHash } });
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Tokens
|
|
150
|
+
|
|
151
|
+
`createTokenService()` signs HS256 access tokens with `iat`/`exp`, using the same claim names as `createJwtResolver` (`sub` + `role` by default). The resolver rejects expired (`exp`) and not-yet-valid (`nbf`) tokens when those claims are present.
|
|
152
|
+
|
|
153
|
+
### Security notes
|
|
154
|
+
|
|
155
|
+
- **Argon2id** with automatic salt; encoded `$argon2id$…` digest stores algo, version, params, salt, and hash.
|
|
156
|
+
- **Pepper** (`AUTH_PEPPER`) is applied before hash/verify and never stored in the DB.
|
|
157
|
+
- **Verify** uses Argon2’s constant-time check — never compare hash strings manually.
|
|
158
|
+
- **No user enumeration** on login: same 401 message whether the email is missing or the password is wrong; verify always runs (dummy hash when no user).
|
|
159
|
+
- **Expiry enforcement** on JWT verify; issued tokens always carry `exp`.
|
|
160
|
+
- Never log passwords, hashes, pepper, or `JWT_SECRET`. Keep `passwordHash` `@omit` so it never appears in API JSON.
|
|
161
|
+
|
|
162
|
+
### Future extensions
|
|
163
|
+
|
|
164
|
+
Password reset, MFA, session/refresh-token management, and login rate limiting are intentionally out of scope for this release.
|
|
32
165
|
|
|
33
166
|
---
|
|
34
167
|
|
|
@@ -37,7 +170,6 @@ Most backend frameworks force you to scatter your truth across migrations, ORM m
|
|
|
37
170
|
```ts
|
|
38
171
|
extensions {
|
|
39
172
|
pgcrypto { version: "1.3" }
|
|
40
|
-
postgis
|
|
41
173
|
uuid-ossp
|
|
42
174
|
}
|
|
43
175
|
|
|
@@ -211,119 +343,7 @@ If either side declares `name`, the other side must use the same `name` (or omit
|
|
|
211
343
|
|
|
212
344
|
**Runtime keys.** `include` and API relation paths use the **field name** (`profile`, `orders`, `user`) — not the optional `name` argument. `name` is never used for SQL constraint names; foreign keys are named from table and column names.
|
|
213
345
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
## How It Works
|
|
217
|
-
|
|
218
|
-
```
|
|
219
|
-
┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐
|
|
220
|
-
│ schema.dsl │────▶│ Lexer + │────▶│ AST │
|
|
221
|
-
│ (your source) │ │ Parser │ │ (typed nodes) │
|
|
222
|
-
└─────────────────┘ └──────────────┘ └────────┬─────────┘
|
|
223
|
-
│
|
|
224
|
-
┌──────────────────────────────────────────┼──────────┐
|
|
225
|
-
│ │ │
|
|
226
|
-
▼ ▼ ▼
|
|
227
|
-
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
|
228
|
-
│ SQL DDL │ │ DB Client │ │ Hono Routes │
|
|
229
|
-
│ Generator │ │ Generator │ │ Generator │
|
|
230
|
-
└─────────────┘ └──────────────┘ └─────────────┘
|
|
231
|
-
│ │ │
|
|
232
|
-
▼ ▼ ▼
|
|
233
|
-
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
|
234
|
-
│ schema.sql │ │ generated/ │ │ Hono Routes │
|
|
235
|
-
│ (PostgreSQL)│ │ db.ts, types │ │ + policies │
|
|
236
|
-
└─────────────┘ └──────────────┘ └─────────────┘
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
1. **Parse** — The hand-written lexer and recursive-descent parser turn your `.schema` file into a typed AST.
|
|
240
|
-
2. **Generate SQL** — The DDL generator emits idempotent PostgreSQL: extensions, enums, tables, foreign keys, indexes, and triggers. All identifiers are automatically converted to `snake_case`.
|
|
241
|
-
3. **Generate DB client** — The client generator emits TypeScript interfaces (including `{Model}Include` types), relation metadata, and a `createDbClient(pool)` factory with per-model CRUD methods and nested `include` eager-loading. All SQL uses `$1`, `$2`, … placeholders — user input is never interpolated.
|
|
242
|
-
4. **Generate API** — The route generator emits Hono routers with:
|
|
243
|
-
- Zod-validated request bodies and path params (driven by `@regex` and `@range`)
|
|
244
|
-
- Full CRUD handlers backed by the generated DB client
|
|
245
|
-
- Role-based ACL enforcement (driven by `@policy`) with row-level `WHERE` injection
|
|
246
|
-
- Pluggable authentication middleware (default: Bearer JWT)
|
|
247
|
-
5. **Run** — `generated/app.ts` mounts all routers and starts a Node.js server. You get a validated REST API in seconds.
|
|
248
|
-
|
|
249
|
-
---
|
|
250
|
-
|
|
251
|
-
## Quick Start
|
|
252
|
-
|
|
253
|
-
Install the CLI and scaffold a new project:
|
|
254
|
-
|
|
255
|
-
```bash
|
|
256
|
-
npx schematic-pg init my-app
|
|
257
|
-
cd my-app
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
Edit `app.schema`, then start the full dev loop:
|
|
261
|
-
|
|
262
|
-
```bash
|
|
263
|
-
make dev
|
|
264
|
-
# → starts PostgreSQL, generates code, bootstraps the DB, runs the dev server,
|
|
265
|
-
# and watches app.schema for changes (regenerate + bootstrap + restart)
|
|
266
|
-
# → http://localhost:3000
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
Or run each step individually:
|
|
270
|
-
|
|
271
|
-
```bash
|
|
272
|
-
# Start PostgreSQL (PostGIS-enabled, matches .env defaults)
|
|
273
|
-
docker compose up -d --wait
|
|
274
|
-
|
|
275
|
-
# Generate, bootstrap, start server, and watch app.schema (default)
|
|
276
|
-
npx schematic-pg dev
|
|
277
|
-
# → http://localhost:3000
|
|
278
|
-
|
|
279
|
-
# One-shot dev server without schema watching:
|
|
280
|
-
npx schematic-pg dev --no-watch
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
Manual split when you need finer control:
|
|
284
|
-
|
|
285
|
-
```bash
|
|
286
|
-
npx schematic-pg generate
|
|
287
|
-
npx schematic-pg db:bootstrap
|
|
288
|
-
npx schematic-pg dev --no-watch
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
The `init` command creates everything you need to get running:
|
|
292
|
-
|
|
293
|
-
| File / directory | Purpose |
|
|
294
|
-
|------------------|---------|
|
|
295
|
-
| `app.schema` | Starter schema (one `User` model) — edit this |
|
|
296
|
-
| `.env` | `DATABASE_URL`, JWT settings |
|
|
297
|
-
| `docker-compose.yml` | Local PostGIS PostgreSQL on `:5432` |
|
|
298
|
-
| `Makefile` | `make dev` — docker compose (with health wait) + `schematic-pg dev` |
|
|
299
|
-
| `tsconfig.json` | TypeScript config for `generated/` and `src/routes/` |
|
|
300
|
-
| `package.json` | `schematic-pg` + runtime deps (`hono`, `pg`, `zod`, …) |
|
|
301
|
-
| `src/routes/health.ts` | Example custom route mounted at `/health` |
|
|
302
|
-
|
|
303
|
-
After `generate`, your project also contains:
|
|
304
|
-
|
|
305
|
-
| Output | Purpose |
|
|
306
|
-
|--------|---------|
|
|
307
|
-
| `schema.sql` | Idempotent PostgreSQL DDL |
|
|
308
|
-
| `generated/db*.ts` | Type-safe DB client |
|
|
309
|
-
| `generated/app.ts` | Hono server entry point |
|
|
310
|
-
| `generated/routes/*.ts` | CRUD routers per model |
|
|
311
|
-
| `generated/policies.ts` | ACL metadata from `@policy` |
|
|
312
|
-
| `generated/schemas/validation.ts` | Zod request validators |
|
|
313
|
-
|
|
314
|
-
Generated code imports the runtime from the `schematic-pg` package (`schematic-pg/api/*`, `schematic-pg/db/*`). You do not copy framework source into your project.
|
|
315
|
-
|
|
316
|
-
### Environment variables
|
|
317
|
-
|
|
318
|
-
| Variable | Default | Purpose |
|
|
319
|
-
|----------|---------|---------|
|
|
320
|
-
| `DATABASE_URL` | — (required) | PostgreSQL connection string |
|
|
321
|
-
| `PORT` | `3000` | HTTP listen port |
|
|
322
|
-
| `JWT_SECRET` | — | HMAC secret for the default Bearer JWT resolver |
|
|
323
|
-
| `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
|
|
324
|
-
| `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
|
|
325
|
-
|
|
326
|
-
Set these in `.env` before running `dev`, `start`, or `db:bootstrap`.
|
|
346
|
+
For `@policy` enforcement, JWT auth, and row-level filters, see [Access control](docs/access-control.md).
|
|
327
347
|
|
|
328
348
|
---
|
|
329
349
|
|
|
@@ -343,10 +363,10 @@ schematic-pg init [dir] [--skip-install] # Scaffold a new project (runs npm ins
|
|
|
343
363
|
schematic-pg generate [schema] # schema.sql + db client + API (all three)
|
|
344
364
|
schematic-pg generate:sql [schema] # SQL DDL to stdout
|
|
345
365
|
schematic-pg generate:client [schema] # generated/db*.ts only
|
|
346
|
-
schematic-pg generate:api [schema] # generated/app.ts, routes/, policies, schemas
|
|
366
|
+
schematic-pg generate:api [schema] # generated/app.ts, routes/, policies, schemas, openapi
|
|
347
367
|
```
|
|
348
368
|
|
|
349
|
-
Run `generate:client` before `generate:api` when using the split commands — routes depend on `generated/db.ts`.
|
|
369
|
+
Run `generate:client` before `generate:api` when using the split commands — routes depend on `generated/db.ts`. After the server starts, open [http://localhost:3000/docs](http://localhost:3000/docs) for Scalar docs (OpenAPI at `/openapi.json`).
|
|
350
370
|
|
|
351
371
|
### Lifecycle hooks scaffolding
|
|
352
372
|
|
|
@@ -438,6 +458,8 @@ schematic-pg db:migrate:status [schema] # Show snapshot + migration file stat
|
|
|
438
458
|
|
|
439
459
|
`db:bootstrap` is the recommended first-time setup. Use `db:diff` / `db:migrate` when evolving an existing database.
|
|
440
460
|
|
|
461
|
+
For a full walkthrough (mental model, local loop, and automating staging/production with GitHub Actions), see [Migrations tutorial](docs/migrations.md).
|
|
462
|
+
|
|
441
463
|
Alternatively, apply SQL manually:
|
|
442
464
|
|
|
443
465
|
```bash
|
|
@@ -452,976 +474,6 @@ schematic-pg --help
|
|
|
452
474
|
|
|
453
475
|
---
|
|
454
476
|
|
|
455
|
-
## Local development (this repo)
|
|
456
|
-
|
|
457
|
-
Contributors working on the framework itself clone the repo and use npm scripts (which delegate to the same CLI via `tsx`):
|
|
458
|
-
|
|
459
|
-
```bash
|
|
460
|
-
cp .env.example .env # configure DATABASE_URL
|
|
461
|
-
npm run build # compile src/ → dist/ (required for schematic-pg/* imports)
|
|
462
|
-
npm run docker:up # PostGIS-enabled PostgreSQL on :5432
|
|
463
|
-
npm run generate # write schema.sql from app.schema
|
|
464
|
-
npm run generate:client # write generated/db*.ts
|
|
465
|
-
npm run generate:api # write generated/app.ts, routes/, schemas/
|
|
466
|
-
npm run db:bootstrap # apply DDL + snapshot schema state
|
|
467
|
-
npm run dev:api # regenerate client + API and start server on :3000
|
|
468
|
-
npm run start # production server (migrate + run generated/app.ts)
|
|
469
|
-
npm test # unit tests
|
|
470
|
-
npm run test:integration # Docker + generate + DB client + ACL integration tests
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
---
|
|
474
|
-
|
|
475
|
-
## Database Client
|
|
476
|
-
|
|
477
|
-
A type-safe query layer generated from your schema AST. The API mirrors Prisma ergonomics (`db.user.create`, `db.user.findMany`, …) but every query is built as parameterized raw SQL against a `pg` `Pool` — no ORM, no query-builder library.
|
|
478
|
-
|
|
479
|
-
### Generate
|
|
480
|
-
|
|
481
|
-
```bash
|
|
482
|
-
npx schematic-pg generate:client
|
|
483
|
-
# or: npm run generate:client (inside a scaffolded project)
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
Outputs:
|
|
487
|
-
|
|
488
|
-
| File | Purpose |
|
|
489
|
-
|------|---------|
|
|
490
|
-
| `generated/db-types.ts` | Per-model interfaces: `User`, `UserCreateInput`, `UserWhereInput`, `UserInclude`, enum unions |
|
|
491
|
-
| `generated/db-model-meta.ts` | Serialized field/column and relation metadata consumed at runtime |
|
|
492
|
-
| `generated/db.ts` | `createDbClient(pool)` factory wiring all models |
|
|
493
|
-
|
|
494
|
-
### Usage
|
|
495
|
-
|
|
496
|
-
```typescript
|
|
497
|
-
import { Pool } from 'pg';
|
|
498
|
-
import { createDbClient } from './generated/db.js';
|
|
499
|
-
|
|
500
|
-
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
501
|
-
const db = createDbClient(pool);
|
|
502
|
-
|
|
503
|
-
// Create
|
|
504
|
-
const user = await db.user.create({
|
|
505
|
-
email: 'a@b.com',
|
|
506
|
-
name: 'Alice',
|
|
507
|
-
balance: 0,
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
// Read
|
|
511
|
-
const one = await db.user.findUnique({ id: user.id });
|
|
512
|
-
const first = await db.user.findFirst({
|
|
513
|
-
where: { role: 'ADMIN' },
|
|
514
|
-
orderBy: { createdAt: 'desc' },
|
|
515
|
-
});
|
|
516
|
-
const many = await db.user.findMany({
|
|
517
|
-
where: { role: { in: ['ADMIN', 'USER'] }, isActive: true },
|
|
518
|
-
orderBy: [{ role: 'asc' }, { createdAt: 'desc' }],
|
|
519
|
-
take: 10,
|
|
520
|
-
skip: 0,
|
|
521
|
-
});
|
|
522
|
-
const total = await db.user.count({ where: { role: 'ADMIN' } });
|
|
523
|
-
|
|
524
|
-
// Eager-load relations (nested)
|
|
525
|
-
const usersWithOrders = await db.user.findMany({
|
|
526
|
-
where: { role: 'ADMIN' },
|
|
527
|
-
include: {
|
|
528
|
-
profile: true,
|
|
529
|
-
orders: {
|
|
530
|
-
where: { status: 'PENDING' },
|
|
531
|
-
orderBy: { createdAt: 'desc' },
|
|
532
|
-
include: {
|
|
533
|
-
products: {
|
|
534
|
-
include: { product: true },
|
|
535
|
-
},
|
|
536
|
-
},
|
|
537
|
-
},
|
|
538
|
-
},
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
// include works on all read methods
|
|
542
|
-
const oneWithProfile = await db.user.findUnique(
|
|
543
|
-
{ id: user.id },
|
|
544
|
-
{ include: { profile: true } },
|
|
545
|
-
);
|
|
546
|
-
|
|
547
|
-
// Update
|
|
548
|
-
const updated = await db.user.update({
|
|
549
|
-
where: { id: user.id },
|
|
550
|
-
data: { name: 'Bob' },
|
|
551
|
-
});
|
|
552
|
-
const { count } = await db.user.updateMany({
|
|
553
|
-
where: { isActive: false },
|
|
554
|
-
data: { name: 'Inactive' },
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
// Delete
|
|
558
|
-
const deleted = await db.user.delete({ id: user.id });
|
|
559
|
-
await db.user.deleteMany({ where: { role: 'PUBLIC' } });
|
|
560
|
-
```
|
|
561
|
-
|
|
562
|
-
### Per-model API
|
|
563
|
-
|
|
564
|
-
Each model in `app.schema` becomes a camelCase property on the client (`User` → `db.user`, `ProductOrder` → `db.productOrder`) with these methods:
|
|
565
|
-
|
|
566
|
-
| Method | SQL shape |
|
|
567
|
-
|--------|-----------|
|
|
568
|
-
| `create(data)` | `INSERT INTO … VALUES ($1, …) RETURNING *` |
|
|
569
|
-
| `findUnique(where, { include, … })` | `SELECT * … WHERE … LIMIT 1` (+ batched relation queries when `include` is set) |
|
|
570
|
-
| `findFirst({ where, orderBy, include, … })` | `SELECT * … ORDER BY … LIMIT 1` (+ relation queries when `include` is set) |
|
|
571
|
-
| `findMany({ where, orderBy, take, skip, include, … })` | `SELECT * … ORDER BY … LIMIT … OFFSET …` (+ relation queries when `include` is set) |
|
|
572
|
-
| `count({ where })` | `SELECT COUNT(*) …` (no `include`) |
|
|
573
|
-
| `update({ where, data })` | `UPDATE … SET … WHERE … RETURNING *` |
|
|
574
|
-
| `updateMany({ where, data })` | `UPDATE … SET … WHERE … RETURNING *` |
|
|
575
|
-
| `delete(where)` | `DELETE … WHERE … RETURNING *` |
|
|
576
|
-
| `deleteMany({ where })` | `DELETE … WHERE … RETURNING *` |
|
|
577
|
-
|
|
578
|
-
Mutations return the full row (`RETURNING *`). Rows are mapped from `snake_case` columns to `camelCase` TypeScript fields.
|
|
579
|
-
|
|
580
|
-
### Eager loading (`include`)
|
|
581
|
-
|
|
582
|
-
Load related models in one call and get back a nested JSON tree. Relation fields are inferred from your schema (`orders: Order[]`, `profile: Profile?`, `@relation(fields: …, references: …)`).
|
|
583
|
-
|
|
584
|
-
**Supported on:** `findMany`, `findFirst`, and `findUnique`. Not on `count`.
|
|
585
|
-
|
|
586
|
-
```typescript
|
|
587
|
-
// Shorthand — load all columns for the relation
|
|
588
|
-
await db.user.findMany({
|
|
589
|
-
include: { profile: true, orders: true },
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
// Nested — arbitrary depth
|
|
593
|
-
await db.user.findMany({
|
|
594
|
-
include: {
|
|
595
|
-
orders: {
|
|
596
|
-
include: {
|
|
597
|
-
products: {
|
|
598
|
-
include: { product: true },
|
|
599
|
-
},
|
|
600
|
-
},
|
|
601
|
-
},
|
|
602
|
-
},
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
// Filter, sort, and paginate inner relations
|
|
606
|
-
await db.user.findMany({
|
|
607
|
-
include: {
|
|
608
|
-
orders: {
|
|
609
|
-
where: { status: 'PENDING' },
|
|
610
|
-
orderBy: { createdAt: 'desc' },
|
|
611
|
-
take: 5,
|
|
612
|
-
include: { products: true },
|
|
613
|
-
},
|
|
614
|
-
},
|
|
615
|
-
});
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
Each relation key accepts `true` or a `{ where?, orderBy?, take?, skip?, include? }` object (typed as `{Model}IncludeArgs` in `generated/db-types.ts`).
|
|
619
|
-
|
|
620
|
-
**Return shape:** scalar relation fields (`profile: Profile?`) become `Profile | null`. List relations (`orders: Order[]`) become arrays on the result object. Nested `include` keys are attached on each child row the same way.
|
|
621
|
-
|
|
622
|
-
#### Loading strategies
|
|
623
|
-
|
|
624
|
-
By default the client uses **query splitting**: one SQL query for the root rows, then one batched query per included relation level (`WHERE foreign_key = ANY($1)` with deduplicated parent keys). This avoids cartesian explosion when loading multiple `hasMany` relations at the same level (e.g. `profile` + `orders` on `User`).
|
|
625
|
-
|
|
626
|
-
```typescript
|
|
627
|
-
// Default — split queries (recommended for large result sets)
|
|
628
|
-
await db.user.findMany({
|
|
629
|
-
include: { orders: true },
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
// Optional — single round-trip via PostgreSQL LATERAL + json_agg
|
|
633
|
-
await db.user.findMany({
|
|
634
|
-
include: { profile: true, orders: true },
|
|
635
|
-
relationLoadStrategy: 'join',
|
|
636
|
-
});
|
|
637
|
-
```
|
|
638
|
-
|
|
639
|
-
| Strategy | Option | Behavior |
|
|
640
|
-
|----------|--------|----------|
|
|
641
|
-
| Split (default) | omit or `'split'` | One query per relation edge; no row duplication on the wire |
|
|
642
|
-
| Join | `'join'` | One SQL statement; nested JSON built in PostgreSQL |
|
|
643
|
-
|
|
644
|
-
Use `'join'` when latency dominates (fewer round trips). Use the default split strategy when loading many rows or several sibling collections.
|
|
645
|
-
|
|
646
|
-
#### How it works
|
|
647
|
-
|
|
648
|
-
```
|
|
649
|
-
findMany({ include }) → buildLoadPlan (relation tree from schema metadata)
|
|
650
|
-
→ SELECT root rows
|
|
651
|
-
→ for each included relation: SELECT … WHERE fk = ANY($parentIds)
|
|
652
|
-
→ stitch (hash-join children onto parents in O(n))
|
|
653
|
-
```
|
|
654
|
-
|
|
655
|
-
Relation metadata is generated into `generated/db-model-meta.ts` and resolved through an in-memory model registry inside `createDbClient`. Include depth is capped at 10 levels.
|
|
656
|
-
|
|
657
|
-
Generated types: `{Model}Include` and `{Model}IncludeArgs` in `generated/db-types.ts`.
|
|
658
|
-
|
|
659
|
-
### Where filters
|
|
660
|
-
|
|
661
|
-
Direct values are treated as equality. Structured operators are supported per field type:
|
|
662
|
-
|
|
663
|
-
```typescript
|
|
664
|
-
// Equality shorthand
|
|
665
|
-
{ email: 'a@b.com' }
|
|
666
|
-
|
|
667
|
-
// Explicit operators
|
|
668
|
-
{ email: { equals: 'a@b.com' } }
|
|
669
|
-
{ email: { contains: '@' } } // LIKE %@%
|
|
670
|
-
{ email: { startsWith: 'a' } } // LIKE a%
|
|
671
|
-
{ email: { endsWith: '.com' } } // LIKE %.com
|
|
672
|
-
{ balance: { gt: 100 } }
|
|
673
|
-
{ balance: { lte: 500 } }
|
|
674
|
-
{ role: { in: ['ADMIN', 'USER'] } }
|
|
675
|
-
|
|
676
|
-
// Logical groups
|
|
677
|
-
{
|
|
678
|
-
AND: [{ role: 'USER' }, { isActive: true }],
|
|
679
|
-
OR: [{ role: 'ADMIN' }, { role: 'PUBLIC' }],
|
|
680
|
-
NOT: { isActive: false },
|
|
681
|
-
}
|
|
682
|
-
```
|
|
683
|
-
|
|
684
|
-
### Naming and types
|
|
685
|
-
|
|
686
|
-
- **API**: camelCase field names (`createdAt`, `userId`)
|
|
687
|
-
- **SQL**: snake_case columns (`created_at`, `user_id`); reserved table names like `user` and `order` are quoted
|
|
688
|
-
- **Runtime mapping**:
|
|
689
|
-
|
|
690
|
-
| Schema type | TypeScript |
|
|
691
|
-
|-------------|------------|
|
|
692
|
-
| `UUID`, `VARCHAR`, `TEXT` | `string` |
|
|
693
|
-
| `INTEGER`, `SERIAL`, `SMALLINT` | `number` |
|
|
694
|
-
| `BOOLEAN` | `boolean` |
|
|
695
|
-
| `TIMESTAMP` | `Date` |
|
|
696
|
-
| `DECIMAL` | `string` (avoids float precision loss) |
|
|
697
|
-
| `JSONB` | `Record<string, unknown>` |
|
|
698
|
-
| `TEXT[]` | `string[]` |
|
|
699
|
-
| Enums | string literal union |
|
|
700
|
-
| Optional (`?`) | `T \| null` |
|
|
701
|
-
|
|
702
|
-
Fields with `@default` are optional on `CreateInput`. `@id` fields are omitted from create input when the database generates them.
|
|
703
|
-
|
|
704
|
-
### Error handling
|
|
705
|
-
|
|
706
|
-
PostgreSQL errors are mapped to typed exceptions:
|
|
707
|
-
|
|
708
|
-
| Class | PG code | When |
|
|
709
|
-
|-------|---------|------|
|
|
710
|
-
| `UniqueConstraintError` | `23505` | Duplicate unique column (includes `fields: string[]`) |
|
|
711
|
-
| `ForeignKeyConstraintError` | `23503` | Invalid relation reference |
|
|
712
|
-
| `NotFoundError` | — | Optional helper for missing records |
|
|
713
|
-
| `DatabaseError` | other | Generic wrapper with `code`, `detail`, `constraint` |
|
|
714
|
-
|
|
715
|
-
```typescript
|
|
716
|
-
import { UniqueConstraintError } from 'schematic-pg/db/errors';
|
|
717
|
-
|
|
718
|
-
try {
|
|
719
|
-
await db.user.create({ email: 'taken@b.com', name: 'X', balance: 0 });
|
|
720
|
-
} catch (error) {
|
|
721
|
-
if (error instanceof UniqueConstraintError) {
|
|
722
|
-
console.log(error.fields); // ['email']
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
```
|
|
726
|
-
|
|
727
|
-
### Runtime architecture
|
|
728
|
-
|
|
729
|
-
Generated code is a thin wrapper. The query engine ships inside the `schematic-pg` package (`schematic-pg/db/*`). In this repository, the source lives under `src/db/`:
|
|
730
|
-
|
|
731
|
-
```
|
|
732
|
-
schematic-pg/dist/db/ # Published runtime (import as schematic-pg/db/*)
|
|
733
|
-
├── query-builder.ts # INSERT / SELECT / UPDATE / DELETE / COUNT
|
|
734
|
-
├── where-translator.ts # WhereInput → SQL + params
|
|
735
|
-
├── model-client.ts # createModelClient factory
|
|
736
|
-
├── include/ # Eager-loading planner, executor, hydrator, json_agg
|
|
737
|
-
├── utils/relations.ts # Relation graph from schema AST
|
|
738
|
-
├── row-mapper.ts # snake_case rows → camelCase + coercion
|
|
739
|
-
└── errors.ts # UniqueConstraintError, ForeignKeyConstraintError, …
|
|
740
|
-
```
|
|
741
|
-
|
|
742
|
-
### Integration tests
|
|
743
|
-
|
|
744
|
-
One command starts Docker Postgres, generates the client and API, and runs all integration tests (DB client + ACL):
|
|
745
|
-
|
|
746
|
-
```bash
|
|
747
|
-
npm run test:integration
|
|
748
|
-
```
|
|
749
|
-
|
|
750
|
-
This resets the `public` schema, bootstraps from `app.schema`, seeds test data, and exercises:
|
|
751
|
-
|
|
752
|
-
- **DB client** — CRUD, filters, nested `include` eager-loading, and error handling ([`src/db/__tests__/db-client.integration.test.ts`](src/db/__tests__/db-client.integration.test.ts))
|
|
753
|
-
- **ACL over HTTP** — role checks, row-level filters, JWT auth, and open endpoints ([`src/api/__tests__/acl.integration.test.ts`](src/api/__tests__/acl.integration.test.ts))
|
|
754
|
-
|
|
755
|
-
Tests run in-process via Hono `app.request()` against the exported `createApp()` factory from `generated/app.ts`.
|
|
756
|
-
|
|
757
|
-
---
|
|
758
|
-
|
|
759
|
-
## REST API
|
|
760
|
-
|
|
761
|
-
A Hono-based HTTP layer generated from your schema AST. Each model gets a router with full CRUD endpoints. Request bodies and path parameters are validated with Zod schemas derived from field types and `@regex` / `@range` attributes — validation error messages come directly from the `message` parameter in your schema.
|
|
762
|
-
|
|
763
|
-
### Generate
|
|
764
|
-
|
|
765
|
-
```bash
|
|
766
|
-
npx schematic-pg generate:api
|
|
767
|
-
# or: npm run generate:api
|
|
768
|
-
```
|
|
769
|
-
|
|
770
|
-
Requires `generate:client` first (routes call `createDbClient` from `generated/db.ts`). Use `npx schematic-pg generate` to run both.
|
|
771
|
-
|
|
772
|
-
Outputs:
|
|
773
|
-
|
|
774
|
-
| File | Purpose |
|
|
775
|
-
|------|---------|
|
|
776
|
-
| `generated/app.ts` | Hono app entry point — mounts routers, auth + DB middleware, starts the server |
|
|
777
|
-
| `generated/policies.ts` | Per-model ACL metadata derived from `@policy` attributes |
|
|
778
|
-
| `generated/schemas/validation.ts` | Per-model Zod schemas: `{Model}CreateSchema`, `{Model}UpdateSchema`, `{Model}ParamSchema` |
|
|
779
|
-
| `generated/routes/*.ts` | One Hono router per model with GET / POST / PUT / DELETE handlers |
|
|
780
|
-
| `src/routes/*.ts` | *(hand-written)* Custom Hono routers auto-imported into `generated/app.ts` on each `generate:api` run |
|
|
781
|
-
|
|
782
|
-
### Start the server
|
|
783
|
-
|
|
784
|
-
```bash
|
|
785
|
-
npx schematic-pg dev
|
|
786
|
-
# or: npm run dev
|
|
787
|
-
# → regenerates client + API, then starts http://localhost:3000
|
|
788
|
-
```
|
|
789
|
-
|
|
790
|
-
For production (no regenerate, no schema watch):
|
|
791
|
-
|
|
792
|
-
```bash
|
|
793
|
-
npx schematic-pg start
|
|
794
|
-
# or: npm run start
|
|
795
|
-
# → waits for DB, applies pending migrations, starts http://localhost:3000
|
|
796
|
-
```
|
|
797
|
-
|
|
798
|
-
Or run the generated entry point directly after generation (skips migration wait):
|
|
799
|
-
|
|
800
|
-
```bash
|
|
801
|
-
npx tsx generated/app.ts
|
|
802
|
-
```
|
|
803
|
-
|
|
804
|
-
Environment variables (also see [Quick Start](#quick-start)):
|
|
805
|
-
|
|
806
|
-
| Variable | Default | Purpose |
|
|
807
|
-
|----------|---------|---------|
|
|
808
|
-
| `DATABASE_URL` | — (required) | PostgreSQL connection string (loaded from `.env`) |
|
|
809
|
-
| `PORT` | `3000` | HTTP listen port |
|
|
810
|
-
| `JWT_SECRET` | — | HMAC secret for the default Bearer JWT resolver |
|
|
811
|
-
| `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
|
|
812
|
-
| `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
|
|
813
|
-
|
|
814
|
-
The server uses `@hono/node-server` and connects via a shared `pg` `Pool`. The DB client and auth context are injected into every request through Hono context (`c.get('db')`, `c.get('auth')`).
|
|
815
|
-
|
|
816
|
-
### Routes
|
|
817
|
-
|
|
818
|
-
Each model in `app.schema` maps to a kebab-case plural base path. Handlers delegate to the generated DB client — no ORM, same parameterized SQL as the client layer.
|
|
819
|
-
|
|
820
|
-
| Model | Base path | Primary key route |
|
|
821
|
-
|-------|-----------|-------------------|
|
|
822
|
-
| `User` | `/users` | `/users/:id` |
|
|
823
|
-
| `Profile` | `/profiles` | `/profiles/:id` |
|
|
824
|
-
| `Order` | `/orders` | `/orders/:id` |
|
|
825
|
-
| `Log` | `/logs` | `/logs/:id` |
|
|
826
|
-
| `Product` | `/products` | `/products/:id` |
|
|
827
|
-
| `ProductOrder` | `/product-orders` | `/product-orders/:orderId/:productId` |
|
|
828
|
-
|
|
829
|
-
Models with composite primary keys (`@@id(fields: [...])`) expose one path segment per key field.
|
|
830
|
-
|
|
831
|
-
### Custom routes
|
|
832
|
-
|
|
833
|
-
Not every endpoint maps to a CRUD model. For auth flows, webhooks, health checks, or other app-specific handlers, add hand-written Hono routers under `src/routes/`. Running `schematic-pg generate:api` (or `schematic-pg dev`) discovers these files and wires them into `generated/app.ts` — same global middleware (`db`, `auth`, error handling) as schema-generated routes.
|
|
834
|
-
|
|
835
|
-
**Convention**
|
|
836
|
-
|
|
837
|
-
| Rule | Example |
|
|
838
|
-
|------|---------|
|
|
839
|
-
| Location | `src/routes/**/*.ts` |
|
|
840
|
-
| Export | `export default router` where `router` is `Hono<AppEnv>` |
|
|
841
|
-
| Mount path | File path relative to `src/routes/`, without extension |
|
|
842
|
-
| Regenerate | `schematic-pg generate:api` or `schematic-pg dev` after adding or renaming files |
|
|
843
|
-
|
|
844
|
-
**Path mapping**
|
|
845
|
-
|
|
846
|
-
| File | Mounted at |
|
|
847
|
-
|------|------------|
|
|
848
|
-
| `src/routes/health.ts` | `/health` |
|
|
849
|
-
| `src/routes/webhooks/stripe.ts` | `/webhooks/stripe` |
|
|
850
|
-
|
|
851
|
-
**Example** — `src/routes/health.ts`:
|
|
852
|
-
|
|
853
|
-
```typescript
|
|
854
|
-
import { Hono } from 'hono';
|
|
855
|
-
import type { AppEnv } from 'schematic-pg/api/types';
|
|
856
|
-
|
|
857
|
-
const router = new Hono<AppEnv>();
|
|
858
|
-
|
|
859
|
-
router.get('/', (c) => c.json({ ok: true }));
|
|
860
|
-
|
|
861
|
-
export default router;
|
|
862
|
-
```
|
|
863
|
-
|
|
864
|
-
After `schematic-pg generate:api`, `generated/app.ts` includes:
|
|
865
|
-
|
|
866
|
-
```typescript
|
|
867
|
-
import healthRouter from '../src/routes/health.js';
|
|
868
|
-
// ...
|
|
869
|
-
app.route('/health', healthRouter);
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
Custom routes are mounted **after** all schema-generated routers. Handlers can use the same request context as generated routes:
|
|
873
|
-
|
|
874
|
-
```typescript
|
|
875
|
-
router.get('/me', async (c) => {
|
|
876
|
-
const db = c.get('db');
|
|
877
|
-
const auth = c.get('auth');
|
|
878
|
-
// ...
|
|
879
|
-
});
|
|
880
|
-
```
|
|
881
|
-
|
|
882
|
-
**Skipped files** — The scanner ignores `*.test.ts`, `*.d.ts`, and any file or directory whose name starts with `_`.
|
|
883
|
-
|
|
884
|
-
**Do not edit** `generated/app.ts` manually for custom routes — add files under `src/routes/` and regenerate.
|
|
885
|
-
|
|
886
|
-
### Lifecycle hooks
|
|
887
|
-
|
|
888
|
-
Run business logic before or after write operations (POST, PUT, DELETE) without editing generated route files. Hooks live under `src/hooks/` and are discovered when you run `schematic-pg generate:api` (or `schematic-pg dev`).
|
|
889
|
-
|
|
890
|
-
**Convention**
|
|
891
|
-
|
|
892
|
-
| Rule | Example |
|
|
893
|
-
|------|---------|
|
|
894
|
-
| Location | `src/hooks/{Model}.ts` (PascalCase model name) |
|
|
895
|
-
| Export | `export default defineHooks(...)` |
|
|
896
|
-
| Scaffold | `schematic-pg hooks:add` (interactive model picker) |
|
|
897
|
-
| Regenerate | `schematic-pg generate:api` or `schematic-pg dev` after adding or editing hooks |
|
|
898
|
-
|
|
899
|
-
**Request flow**
|
|
900
|
-
|
|
901
|
-
```
|
|
902
|
-
validate → assertPolicy → beforeHooks (can cancel) → db.create/update/delete → afterHooks → response
|
|
903
|
-
```
|
|
904
|
-
|
|
905
|
-
Before-hooks run after policy checks — unauthorized requests never execute hook logic. After-hooks see the DB result and can mutate `ctx.result` before the response is sent.
|
|
906
|
-
|
|
907
|
-
**`next()` contract**
|
|
908
|
-
|
|
909
|
-
| Action | Effect |
|
|
910
|
-
|--------|--------|
|
|
911
|
-
| `await next()` | Proceed to the next hook, or to the DB operation when all hooks call `next()` |
|
|
912
|
-
| `return ctx.abort(status, message)` | Cancel; return that JSON error response; DB untouched |
|
|
913
|
-
| `return ctx.json(body, status)` | Cancel with a custom response body |
|
|
914
|
-
| Return without calling `next()` | Cancel with default `409` |
|
|
915
|
-
| `throw` | Cancel; mapped by global error handler |
|
|
916
|
-
|
|
917
|
-
**Scaffold a hook file**
|
|
918
|
-
|
|
919
|
-
```bash
|
|
920
|
-
schematic-pg hooks:add # interactive — pick a model
|
|
921
|
-
schematic-pg hooks:add --model User
|
|
922
|
-
# edit src/hooks/User.ts — delete unused hooks
|
|
923
|
-
schematic-pg generate:api
|
|
924
|
-
```
|
|
925
|
-
|
|
926
|
-
**Example** — `src/hooks/User.ts`:
|
|
927
|
-
|
|
928
|
-
```typescript
|
|
929
|
-
import { defineHooks } from 'schematic-pg/api/hooks';
|
|
930
|
-
import type { User, UserCreateInput, UserUpdateInput } from '../../generated/db-types.js';
|
|
931
|
-
|
|
932
|
-
export default defineHooks<User, UserCreateInput, UserUpdateInput>({
|
|
933
|
-
async beforeCreate(ctx, next) {
|
|
934
|
-
ctx.data.email = ctx.data.email.toLowerCase();
|
|
935
|
-
if (ctx.data.balance < 0) {
|
|
936
|
-
return ctx.abort(422, 'balance must be >= 0');
|
|
937
|
-
}
|
|
938
|
-
await next();
|
|
939
|
-
},
|
|
940
|
-
|
|
941
|
-
async afterCreate(ctx) {
|
|
942
|
-
await ctx.db.log.create({
|
|
943
|
-
level: 'info',
|
|
944
|
-
message: `User created: ${ctx.result.id}`,
|
|
945
|
-
});
|
|
946
|
-
},
|
|
947
|
-
|
|
948
|
-
async beforeDelete(ctx, next) {
|
|
949
|
-
if (ctx.auth.role !== 'ADMIN') {
|
|
950
|
-
return ctx.abort(403, 'Only admins can delete users');
|
|
951
|
-
}
|
|
952
|
-
await next();
|
|
953
|
-
},
|
|
954
|
-
});
|
|
955
|
-
```
|
|
956
|
-
|
|
957
|
-
After `schematic-pg generate:api`, `generated/routes/users.ts` wraps POST/PUT/DELETE with hook calls, and `generated/app.ts` loads the registry:
|
|
958
|
-
|
|
959
|
-
```typescript
|
|
960
|
-
import { HOOKS } from './hooks.js';
|
|
961
|
-
import { configureHooks } from 'schematic-pg/api/hooks';
|
|
962
|
-
// ...
|
|
963
|
-
configureHooks(HOOKS);
|
|
964
|
-
```
|
|
965
|
-
|
|
966
|
-
**Hook context**
|
|
967
|
-
|
|
968
|
-
| Field | beforeCreate / beforeUpdate | beforeDelete | after* |
|
|
969
|
-
|-------|----------------------------|--------------|--------|
|
|
970
|
-
| `ctx.data` | Create/update payload (mutable) | — | — |
|
|
971
|
-
| `ctx.params` | Route params (`:id`, composite PK fields) | Route params | Route params |
|
|
972
|
-
| `ctx.result` | — | — | DB row (mutable) |
|
|
973
|
-
| `ctx.auth` | JWT auth context | JWT auth context | JWT auth context |
|
|
974
|
-
| `ctx.db` | Generated DB client | Generated DB client | Generated DB client |
|
|
975
|
-
| `ctx.abort(status, msg)` | Cancel with JSON error | Cancel with JSON error | — |
|
|
976
|
-
| `ctx.json(body, status)` | Cancel with custom body | Cancel with custom body | — |
|
|
977
|
-
|
|
978
|
-
**Skipped files** — The scanner ignores `*.test.ts`, `*.d.ts`, and any file whose name starts with `_`.
|
|
979
|
-
|
|
980
|
-
**Do not edit** generated route hook wiring manually — add or edit files under `src/hooks/` and regenerate.
|
|
981
|
-
|
|
982
|
-
### Endpoints
|
|
983
|
-
|
|
984
|
-
Every router exposes the same CRUD shape. Models with `@policy` attributes enforce role checks and row-level filters on every handler; models without policies behave as open endpoints.
|
|
985
|
-
|
|
986
|
-
| Method | Path | Handler | Validation |
|
|
987
|
-
|--------|------|---------|------------|
|
|
988
|
-
| `GET` | `/` | `findMany({ where: mergeWhere(queryWhere, policyWhere), orderBy, take, skip, include })` | Query params |
|
|
989
|
-
| `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere), { include })` | Path params + query params |
|
|
990
|
-
| `POST` | `/` | `create(body)` — policy check only | JSON body |
|
|
991
|
-
| `PUT` | `/{pk}` | `update({ where: mergeWhere(pk, policyWhere), data })` | Path params + JSON body |
|
|
992
|
-
| `DELETE` | `/{pk}` | `delete(mergeWhere(pk, policyWhere))` | Path params |
|
|
993
|
-
|
|
994
|
-
`POST` returns `201 Created`. Missing records on `GET` return `404`. All handlers strip `@omit` fields from JSON responses before returning.
|
|
995
|
-
|
|
996
|
-
### Query filters (`GET /`)
|
|
997
|
-
|
|
998
|
-
All stored scalar fields are URL-filterable by default. Opt out with `@unfilterable` on a field. Fields marked `@omit` are never filterable.
|
|
999
|
-
|
|
1000
|
-
Query params use **API field names** (camelCase), not SQL column names:
|
|
1001
|
-
|
|
1002
|
-
| Param | Maps to |
|
|
1003
|
-
|-------|---------|
|
|
1004
|
-
| `?role=ADMIN` | `{ role: 'ADMIN' }` |
|
|
1005
|
-
| `?email_contains=@` | `{ email: { contains: '@' } }` |
|
|
1006
|
-
| `?balance_gte=100` | `{ balance: { gte: 100 } }` |
|
|
1007
|
-
| `?role_in=ADMIN,USER` | `{ role: { in: ['ADMIN', 'USER'] } }` |
|
|
1008
|
-
| `?limit=20` | `take: 20` (max 100) |
|
|
1009
|
-
| `?offset=40` | `skip: 40` |
|
|
1010
|
-
| `?sort=-createdAt` | `orderBy: { createdAt: 'desc' }` |
|
|
1011
|
-
| `?include=profile,orders` | `include: { profile: true, orders: true }` |
|
|
1012
|
-
| `?include=orders.products.product` | nested boolean includes |
|
|
1013
|
-
|
|
1014
|
-
On models with `@policy`, user filters are combined with the policy row filter via `mergeWhere` (AND). A USER calling `GET /users?role=ADMIN` still only sees rows allowed by policy.
|
|
1015
|
-
|
|
1016
|
-
```bash
|
|
1017
|
-
curl "http://localhost:3000/products?category=books&limit=10"
|
|
1018
|
-
curl "http://localhost:3000/users?role=USER&isActive=true" -H "Authorization: Bearer $TOKEN"
|
|
1019
|
-
curl "http://localhost:3000/users/USER_ID?include=profile,orders" -H "Authorization: Bearer $TOKEN"
|
|
1020
|
-
```
|
|
1021
|
-
|
|
1022
|
-
### Relation includes (`GET /`, `GET /{pk}`)
|
|
1023
|
-
|
|
1024
|
-
Load related models via the `include` query param. Paths are comma-separated; use dots for nesting:
|
|
1025
|
-
|
|
1026
|
-
```bash
|
|
1027
|
-
curl "http://localhost:3000/users?include=profile,orders"
|
|
1028
|
-
curl "http://localhost:3000/users/USER_ID?include=orders.products.product"
|
|
1029
|
-
```
|
|
1030
|
-
|
|
1031
|
-
Each segment must name a relation field on the current model (or nested target model). Unknown relations return `400`. Maximum depth and path count are capped (see `MAX_INCLUDE_DEPTH` / `MAX_INCLUDE_PATHS` in the runtime).
|
|
1032
|
-
|
|
1033
|
-
Opt out of HTTP includes on a relation field with `@unincludeable`:
|
|
1034
|
-
|
|
1035
|
-
```ts
|
|
1036
|
-
orders: Order[] @unincludeable
|
|
1037
|
-
```
|
|
1038
|
-
|
|
1039
|
-
**v1 limits:**
|
|
1040
|
-
|
|
1041
|
-
- Boolean includes only — no nested `where`, `take`, or `skip` via URL (use the DB client or a custom route for that).
|
|
1042
|
-
- `@policy` row filters apply to the **root** model only; included relations are not policy-filtered separately.
|
|
1043
|
-
- `@omit` fields are stripped recursively on nested included objects in read responses.
|
|
1044
|
-
|
|
1045
|
-
### Response shaping (`@omit`)
|
|
1046
|
-
|
|
1047
|
-
Mark sensitive stored fields with `@omit` to exclude them from generated route JSON responses. On read endpoints with `include`, omitted fields are stripped recursively on nested relation objects as well. Mutation responses (`POST`, `PUT`, `DELETE`) strip `@omit` fields on the root model only. The ORM client still returns full entities.
|
|
1048
|
-
|
|
1049
|
-
```ts
|
|
1050
|
-
passwordHash: VARCHAR(255) @omit @unfilterable @default("")
|
|
1051
|
-
```
|
|
1052
|
-
|
|
1053
|
-
Generated types include `{Model}Response` (for example `UserResponse = Omit<User, 'passwordHash'>`) in `generated/schemas/validation.ts`.
|
|
1054
|
-
|
|
1055
|
-
### Validation
|
|
1056
|
-
|
|
1057
|
-
Zod schemas are generated from stored fields (relation fields are excluded). Rules from the DSL:
|
|
1058
|
-
|
|
1059
|
-
```ts
|
|
1060
|
-
email: VARCHAR(255) @regex(pattern: "^[\\w.-]+@[\\w.-]+\\.\\w+$", message: "Invalid email address")
|
|
1061
|
-
age: SMALLINT? @range(min: 1, max: 120, message: "Age must be between 1 and 120")
|
|
1062
|
-
```
|
|
1063
|
-
|
|
1064
|
-
Become generated validators with the same messages:
|
|
1065
|
-
|
|
1066
|
-
```typescript
|
|
1067
|
-
email: z.string().regex(/^[\w.-]+@[\w.-]+\.\w+$/, { message: 'Invalid email address' }),
|
|
1068
|
-
age: z.number().int().min(1, { message: 'Age must be between 1 and 120' }).max(120, { message: 'Age must be between 1 and 120' }).nullable().optional(),
|
|
1069
|
-
```
|
|
1070
|
-
|
|
1071
|
-
Validation runs through middleware in `src/api/middleware/validate.ts`. On failure the API responds with:
|
|
1072
|
-
|
|
1073
|
-
```json
|
|
1074
|
-
{ "error": "Invalid email address" }
|
|
1075
|
-
```
|
|
1076
|
-
|
|
1077
|
-
Fields with `@default` or optional (`?`) types are optional on create. Update schemas make all non-PK fields optional (partial updates).
|
|
1078
|
-
|
|
1079
|
-
### Example requests
|
|
1080
|
-
|
|
1081
|
-
```bash
|
|
1082
|
-
# Health check (custom route from src/routes/health.ts)
|
|
1083
|
-
curl http://localhost:3000/health
|
|
1084
|
-
|
|
1085
|
-
# List users with filters
|
|
1086
|
-
curl "http://localhost:3000/users?role=USER&limit=10"
|
|
1087
|
-
|
|
1088
|
-
# List users
|
|
1089
|
-
curl http://localhost:3000/users
|
|
1090
|
-
|
|
1091
|
-
# Get one user
|
|
1092
|
-
curl http://localhost:3000/users/{uuid}
|
|
1093
|
-
|
|
1094
|
-
# Create a user
|
|
1095
|
-
curl -X POST http://localhost:3000/users \
|
|
1096
|
-
-H 'Content-Type: application/json' \
|
|
1097
|
-
-d '{"email":"alice@example.com","name":"Alice","balance":0}'
|
|
1098
|
-
|
|
1099
|
-
# Validation failure (schema message returned)
|
|
1100
|
-
curl -X POST http://localhost:3000/users \
|
|
1101
|
-
-H 'Content-Type: application/json' \
|
|
1102
|
-
-d '{"email":"not-an-email","name":"Alice","balance":0}'
|
|
1103
|
-
# → {"error":"Invalid email address"}
|
|
1104
|
-
|
|
1105
|
-
# Update a user
|
|
1106
|
-
curl -X PUT http://localhost:3000/users/{uuid} \
|
|
1107
|
-
-H 'Content-Type: application/json' \
|
|
1108
|
-
-d '{"name":"Alice Updated"}'
|
|
1109
|
-
|
|
1110
|
-
# Delete a user
|
|
1111
|
-
curl -X DELETE http://localhost:3000/users/{uuid}
|
|
1112
|
-
|
|
1113
|
-
# Composite primary key
|
|
1114
|
-
curl http://localhost:3000/product-orders/{orderId}/{productId}
|
|
1115
|
-
```
|
|
1116
|
-
|
|
1117
|
-
### Error responses
|
|
1118
|
-
|
|
1119
|
-
| Status | When |
|
|
1120
|
-
|--------|------|
|
|
1121
|
-
| `400` | Zod validation failure or foreign key violation |
|
|
1122
|
-
| `401` | Malformed or invalid JWT (when `Authorization: Bearer` is present) |
|
|
1123
|
-
| `403` | Role not allowed for the requested operation (`@policy` denial) |
|
|
1124
|
-
| `404` | Record not found on `GET`, or delete/update returned no rows |
|
|
1125
|
-
| `409` | Unique constraint violation |
|
|
1126
|
-
| `500` | Other database errors |
|
|
1127
|
-
|
|
1128
|
-
Global error handling lives in `src/api/middleware/errors.ts` and maps the same typed exceptions as the DB client layer.
|
|
1129
|
-
|
|
1130
|
-
### App configuration
|
|
1131
|
-
|
|
1132
|
-
The generated `app.ts` sets up:
|
|
1133
|
-
|
|
1134
|
-
```typescript
|
|
1135
|
-
import { Hono } from 'hono';
|
|
1136
|
-
import { logger } from 'hono/logger';
|
|
1137
|
-
import { prettyJSON } from 'hono/pretty-json';
|
|
1138
|
-
|
|
1139
|
-
const app = new Hono<AppEnv>();
|
|
1140
|
-
app.use(logger());
|
|
1141
|
-
app.use(prettyJSON());
|
|
1142
|
-
app.use(createDbMiddleware()); // injects db from DATABASE_URL
|
|
1143
|
-
app.use(createAuthMiddleware()); // injects auth (default: Bearer JWT)
|
|
1144
|
-
app.onError(handleError);
|
|
1145
|
-
|
|
1146
|
-
app.route('/users', usersRouter);
|
|
1147
|
-
// ... all generated routers
|
|
1148
|
-
app.route('/health', healthRouter);
|
|
1149
|
-
// ... all custom routers from src/routes/
|
|
1150
|
-
```
|
|
1151
|
-
|
|
1152
|
-
### Runtime architecture
|
|
1153
|
-
|
|
1154
|
-
Generated routes and schemas are thin wrappers. The HTTP runtime ships inside the `schematic-pg` package (`schematic-pg/api/*`). In this repository, the source lives under `src/api/`:
|
|
1155
|
-
|
|
1156
|
-
```
|
|
1157
|
-
schematic-pg/dist/api/ # Published runtime (import as schematic-pg/api/*)
|
|
1158
|
-
├── types.ts # Hono AppEnv (db + auth in context)
|
|
1159
|
-
├── auth/
|
|
1160
|
-
│ ├── jwt-resolver.ts # Default Bearer JWT resolver (HS256)
|
|
1161
|
-
│ ├── middleware.ts # createAuthMiddleware(resolver?)
|
|
1162
|
-
│ ├── policy.ts # assertPolicy, resolvePolicyWhere, mergeWhere
|
|
1163
|
-
│ └── ...
|
|
1164
|
-
├── middleware/
|
|
1165
|
-
│ ├── db.ts # Pool + createDbClient + context middleware
|
|
1166
|
-
│ ├── validate.ts # Zod validation wrappers
|
|
1167
|
-
│ └── errors.ts # HTTP error mapping (401, 403, 409, …)
|
|
1168
|
-
└── utils/
|
|
1169
|
-
└── route-naming.ts # Model → kebab-case plural paths
|
|
1170
|
-
|
|
1171
|
-
your-project/src/routes/ # Hand-written custom Hono routers (auto-imported)
|
|
1172
|
-
└── health.ts # Example: GET /health
|
|
1173
|
-
```
|
|
1174
|
-
|
|
1175
|
-
The generators live in this repo under `src/api-generator/` and are invoked by the CLI at build time.
|
|
1176
|
-
|
|
1177
|
-
---
|
|
1178
|
-
|
|
1179
|
-
## Access Control (`@policy`)
|
|
1180
|
-
|
|
1181
|
-
Define who can do what — and which rows they can touch — directly on your models. Policies are parsed from the schema, emitted to `generated/policies.ts`, and enforced in generated route handlers at runtime.
|
|
1182
|
-
|
|
1183
|
-
### Defining policies
|
|
1184
|
-
|
|
1185
|
-
Attach one or more `@policy` attributes to a model:
|
|
1186
|
-
|
|
1187
|
-
```ts
|
|
1188
|
-
model User {
|
|
1189
|
-
id: UUID @id @default(gen_random_uuid())
|
|
1190
|
-
role: UserRole @default(USER)
|
|
1191
|
-
// ...
|
|
1192
|
-
|
|
1193
|
-
@policy(role: USER, allow: [select, insert, update], where: "id = {{auth.user.id}}")
|
|
1194
|
-
@policy(role: ADMIN, allow: all)
|
|
1195
|
-
}
|
|
1196
|
-
```
|
|
1197
|
-
|
|
1198
|
-
| Argument | Type | Description |
|
|
1199
|
-
|----------|------|-------------|
|
|
1200
|
-
| `role` | enum identifier | Role this policy applies to (must match a value in your schema enums, e.g. `UserRole`) |
|
|
1201
|
-
| `allow` | `all` or `[select, insert, update, delete]` | Operations permitted for this role |
|
|
1202
|
-
| `where` | string (optional) | Row-level filter applied on read/update/delete; supports `{{auth.*}}` templates |
|
|
1203
|
-
|
|
1204
|
-
**Operations map to HTTP methods:**
|
|
1205
|
-
|
|
1206
|
-
| HTTP | Policy operation |
|
|
1207
|
-
|------|------------------|
|
|
1208
|
-
| `GET` | `select` |
|
|
1209
|
-
| `POST` | `insert` |
|
|
1210
|
-
| `PUT` | `update` |
|
|
1211
|
-
| `DELETE` | `delete` |
|
|
1212
|
-
|
|
1213
|
-
Models **without** `@policy` attributes are open — generated routes skip ACL checks entirely (e.g. `Log` in the sample schema).
|
|
1214
|
-
|
|
1215
|
-
### How enforcement works
|
|
1216
|
-
|
|
1217
|
-
For each model that has policies, generated routes call the policy guard before every DB operation:
|
|
1218
|
-
|
|
1219
|
-
```typescript
|
|
1220
|
-
const auth = c.get('auth');
|
|
1221
|
-
const policy = assertPolicy('User', auth.role, 'select');
|
|
1222
|
-
const policyWhere = resolvePolicyWhere(policy, auth);
|
|
1223
|
-
const rows = await db.user.findMany({ where: policyWhere });
|
|
1224
|
-
```
|
|
1225
|
-
|
|
1226
|
-
1. **`assertPolicy(model, role, operation)`** — Looks up the policy for the caller's role in `generated/policies.ts`. Throws `403 Forbidden` if the role has no policy or the operation is not in `allow`. Returns the matched policy.
|
|
1227
|
-
2. **`resolvePolicyWhere(policy, auth)`** — Interpolates `{{auth.user.id}}` (and other `{{auth.*}}` paths) from the request auth context, then parses the result into a `WhereInput` object.
|
|
1228
|
-
3. **`mergeWhere(routeWhere, policyWhere)`** — Combines route params (e.g. `:id`) with the policy filter via `AND` on read/update/delete.
|
|
1229
|
-
|
|
1230
|
-
`POST` (insert) checks operation permission only — no `where` injection.
|
|
1231
|
-
|
|
1232
|
-
### Auth context
|
|
1233
|
-
|
|
1234
|
-
Every request gets an `auth` object on Hono context:
|
|
1235
|
-
|
|
1236
|
-
```typescript
|
|
1237
|
-
type AuthContext = {
|
|
1238
|
-
role: string;
|
|
1239
|
-
user?: { id: string; [key: string]: unknown };
|
|
1240
|
-
};
|
|
1241
|
-
```
|
|
1242
|
-
|
|
1243
|
-
**Unauthenticated requests** (no `Authorization` header) default to `{ role: 'PUBLIC' }`. Missing token is not a `401` — only a malformed or invalid token when a Bearer header is present.
|
|
1244
|
-
|
|
1245
|
-
If the caller's role has no matching `@policy`, the runtime falls back to a `PUBLIC` role policy when one exists.
|
|
1246
|
-
|
|
1247
|
-
### Default JWT authentication
|
|
1248
|
-
|
|
1249
|
-
The generated app uses `createAuthMiddleware()` with a built-in Bearer JWT resolver (`src/api/auth/jwt-resolver.ts`):
|
|
1250
|
-
|
|
1251
|
-
```bash
|
|
1252
|
-
curl http://localhost:3000/users \
|
|
1253
|
-
-H 'Authorization: Bearer <jwt>'
|
|
1254
|
-
```
|
|
1255
|
-
|
|
1256
|
-
The resolver expects HS256 tokens and reads:
|
|
1257
|
-
|
|
1258
|
-
- `auth.role` ← claim named by `JWT_ROLE_CLAIM` (default: `role`)
|
|
1259
|
-
- `auth.user.id` ← claim named by `JWT_USER_ID_CLAIM` (default: `sub`)
|
|
1260
|
-
|
|
1261
|
-
Set `JWT_SECRET` in `.env` when using the default resolver.
|
|
1262
|
-
|
|
1263
|
-
### Pluggable auth
|
|
1264
|
-
|
|
1265
|
-
Different systems resolve identity differently. Pass a custom `AuthResolver` to the middleware:
|
|
1266
|
-
|
|
1267
|
-
```typescript
|
|
1268
|
-
import { createAuthMiddleware } from 'schematic-pg/api/auth/middleware';
|
|
1269
|
-
|
|
1270
|
-
app.use(createAuthMiddleware(async (c) => {
|
|
1271
|
-
const role = c.req.header('X-Role');
|
|
1272
|
-
const userId = c.req.header('X-User-Id');
|
|
1273
|
-
|
|
1274
|
-
if (!role || !userId) {
|
|
1275
|
-
return null; // → defaults to { role: 'PUBLIC' }
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
return {
|
|
1279
|
-
role,
|
|
1280
|
-
user: { id: userId },
|
|
1281
|
-
};
|
|
1282
|
-
}));
|
|
1283
|
-
```
|
|
1284
|
-
|
|
1285
|
-
`AuthResolver` signature: `(c: Context<AppEnv>) => Promise<AuthContext | null>`.
|
|
1286
|
-
|
|
1287
|
-
Return `null` for anonymous callers; throw `UnauthorizedError` for invalid credentials.
|
|
1288
|
-
|
|
1289
|
-
### Where templates
|
|
1290
|
-
|
|
1291
|
-
Policy `where` clauses support `{{auth.*}}` placeholders resolved against the auth context:
|
|
1292
|
-
|
|
1293
|
-
```ts
|
|
1294
|
-
where: "id = {{auth.user.id}}"
|
|
1295
|
-
```
|
|
1296
|
-
|
|
1297
|
-
After interpolation, simple `field op value` forms are parsed into `WhereInput`:
|
|
1298
|
-
|
|
1299
|
-
| Form | Example |
|
|
1300
|
-
|------|---------|
|
|
1301
|
-
| Equality | `id = {{auth.user.id}}` → `{ id: '…' }` |
|
|
1302
|
-
| Comparison | `balance >= 100` → `{ balance: { gte: 100 } }` |
|
|
1303
|
-
| Inequality | `role != ADMIN` → `{ NOT: { role: 'ADMIN' } }` |
|
|
1304
|
-
|
|
1305
|
-
Complex multi-clause SQL in `where` is not supported yet — keep policies to a single condition for now.
|
|
1306
|
-
|
|
1307
|
-
### Generated policy metadata
|
|
1308
|
-
|
|
1309
|
-
`schematic-pg generate:api` emits `generated/policies.ts`:
|
|
1310
|
-
|
|
1311
|
-
```typescript
|
|
1312
|
-
export const POLICIES: Record<string, NormalizedPolicy[]> = {
|
|
1313
|
-
User: [
|
|
1314
|
-
{ role: 'USER', operations: ['select', 'insert', 'update'], where: "id = {{auth.user.id}}" },
|
|
1315
|
-
{ role: 'ADMIN', operations: 'all' },
|
|
1316
|
-
],
|
|
1317
|
-
};
|
|
1318
|
-
```
|
|
1319
|
-
|
|
1320
|
-
This file is consumed by `assertPolicy` at runtime — do not edit manually.
|
|
1321
|
-
|
|
1322
|
-
### Example: scoped user access
|
|
1323
|
-
|
|
1324
|
-
With the sample `User` policies above:
|
|
1325
|
-
|
|
1326
|
-
| Caller | `GET /users` | `GET /users/:id` | `DELETE /users/:id` |
|
|
1327
|
-
|--------|--------------|------------------|---------------------|
|
|
1328
|
-
| No token (`PUBLIC`) | `403` | `403` | `403` |
|
|
1329
|
-
| JWT `role: USER`, `sub: <own-id>` | Returns own row only | Own row if `:id` matches | `403` (delete not in `allow`) |
|
|
1330
|
-
| JWT `role: ADMIN` | Returns all rows | Any row | Allowed |
|
|
1331
|
-
|
|
1332
|
-
These scenarios are covered by `npm run test:integration` — see [`src/api/__tests__/acl.integration.test.ts`](src/api/__tests__/acl.integration.test.ts).
|
|
1333
|
-
|
|
1334
|
-
---
|
|
1335
|
-
|
|
1336
|
-
## Project Structure
|
|
1337
|
-
|
|
1338
|
-
After `schematic-pg init` and `schematic-pg generate`, a typical application looks like this:
|
|
1339
|
-
|
|
1340
|
-
```
|
|
1341
|
-
my-app/
|
|
1342
|
-
├── app.schema # Your single source of truth
|
|
1343
|
-
├── schema.sql # Generated PostgreSQL DDL
|
|
1344
|
-
├── .env # DATABASE_URL, JWT_* settings
|
|
1345
|
-
├── docker-compose.yml # Local PostgreSQL (optional)
|
|
1346
|
-
├── tsconfig.json
|
|
1347
|
-
├── package.json # schematic-pg + hono + pg + zod
|
|
1348
|
-
├── generated/
|
|
1349
|
-
│ ├── db.ts # createDbClient(pool) factory
|
|
1350
|
-
│ ├── db-types.ts # Generated model + input interfaces
|
|
1351
|
-
│ ├── db-model-meta.ts # Runtime column metadata
|
|
1352
|
-
│ ├── app.ts # Hono entry point (starts server on :3000)
|
|
1353
|
-
│ ├── policies.ts # Generated ACL metadata from @policy
|
|
1354
|
-
│ ├── hooks.ts # Registry of src/hooks/* (wired at startup)
|
|
1355
|
-
│ ├── routes/
|
|
1356
|
-
│ │ ├── users.ts
|
|
1357
|
-
│ │ ├── profiles.ts
|
|
1358
|
-
│ │ └── ...
|
|
1359
|
-
│ └── schemas/
|
|
1360
|
-
│ └── validation.ts # Generated Zod schemas
|
|
1361
|
-
└── src/
|
|
1362
|
-
├── routes/
|
|
1363
|
-
│ └── health.ts # Custom route → GET /health
|
|
1364
|
-
└── hooks/
|
|
1365
|
-
└── User.ts # Lifecycle hooks → POST/PUT/DELETE /users
|
|
1366
|
-
```
|
|
1367
|
-
|
|
1368
|
-
Framework runtime (query builder, auth middleware, validation, hook registry) is **not** copied into your project — it is imported from `node_modules/schematic-pg` at runtime. Only `generated/`, `src/routes/`, and `src/hooks/` contain project-specific code.
|
|
1369
|
-
|
|
1370
|
-
### This repository (framework source)
|
|
1371
|
-
|
|
1372
|
-
```
|
|
1373
|
-
postgrest.js/
|
|
1374
|
-
├── src/
|
|
1375
|
-
│ ├── schema-dsl/ # Lexer, parser, AST
|
|
1376
|
-
│ ├── sql-generator/ # DDL + migration planner
|
|
1377
|
-
│ ├── db/ # Query builder + client runtime + include eager-loading
|
|
1378
|
-
│ ├── api/ # Hono runtime (published as schematic-pg/api/*)
|
|
1379
|
-
│ ├── api-generator/ # AST → routes, Zod, policies, app
|
|
1380
|
-
│ ├── cli/ # init templates + command helpers
|
|
1381
|
-
│ └── cli.ts # schematic-pg CLI entry point
|
|
1382
|
-
├── dist/ # Compiled output (npm publish target)
|
|
1383
|
-
├── generated/ # Sample output from app.schema (this repo)
|
|
1384
|
-
├── app.schema # Sample schema
|
|
1385
|
-
└── editors/ # VS Code extension + language server
|
|
1386
|
-
```
|
|
1387
|
-
|
|
1388
|
-
---
|
|
1389
|
-
|
|
1390
|
-
## Why schematic-pg?
|
|
1391
|
-
|
|
1392
|
-
| Concern | ORM Approach | schematic-pg Approach |
|
|
1393
|
-
|---------|-----------|----------------------|
|
|
1394
|
-
| Schema truth | Migrations + models + Zod + routes | One `.schema` file |
|
|
1395
|
-
| Query visibility | Hidden behind ORM methods | Raw, parameterized SQL |
|
|
1396
|
-
| Client ergonomics | ORM model API | Generated Prisma-like client, no ORM runtime |
|
|
1397
|
-
| Performance | N+1, lazy loading pitfalls | Explicit `include`; batched split queries by default |
|
|
1398
|
-
| ACL | External service or manual checks | Inline `@policy` directives |
|
|
1399
|
-
| Validation | Separate Zod schemas | Derived from `@regex` / `@range` |
|
|
1400
|
-
| Dependencies | Heavy (Prisma, Drizzle, etc.) | Hono + pg + Zod + hand-written parser |
|
|
1401
|
-
|
|
1402
|
-
---
|
|
1403
|
-
|
|
1404
|
-
## Roadmap
|
|
1405
|
-
|
|
1406
|
-
- [x] npm package + CLI (`schematic-pg init`, `generate`, `dev`, `start`, `db:*`)
|
|
1407
|
-
- [x] Hand-written lexer & recursive-descent parser
|
|
1408
|
-
- [x] SQL DDL generator (full regeneration)
|
|
1409
|
-
- [x] Type-safe database client generator (`createDbClient`, parameterized query builder)
|
|
1410
|
-
- [x] Diff-based migration planner
|
|
1411
|
-
- [x] Hono route generator with Zod validation
|
|
1412
|
-
- [x] Static ACL middleware generation (`@policy` → `assertPolicy` in routes)
|
|
1413
|
-
- [x] Row-level policy injection (`WHERE` clause from `where:` templates)
|
|
1414
|
-
- [x] JWT authentication (default Bearer resolver, pluggable `AuthResolver`)
|
|
1415
|
-
- [x] Custom routes (`src/routes/` auto-imported into generated app)
|
|
1416
|
-
- [x] Lifecycle hooks (`src/hooks/` before/after create-update-delete, `hooks:add` CLI)
|
|
1417
|
-
- [x] Relation `include` in DB client (nested eager-loading, split + json_agg strategies)
|
|
1418
|
-
- [ ] Type generation for frontend consumption
|
|
1419
|
-
- [ ] Tree-sitter grammar for editor support
|
|
1420
|
-
- [x] VS Code extension with syntax highlighting and language server
|
|
1421
|
-
- [ ] URL query-string filters for `findMany` (e.g. `?role=ADMIN`)
|
|
1422
|
-
|
|
1423
|
-
---
|
|
1424
|
-
|
|
1425
477
|
## License
|
|
1426
478
|
|
|
1427
479
|
MIT
|