schematic-pg 0.1.7 → 0.1.10

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.
Files changed (78) hide show
  1. package/README.md +237 -970
  2. package/dist/api/auth/jwt-crypto.d.ts +10 -0
  3. package/dist/api/auth/jwt-crypto.js +61 -0
  4. package/dist/api/auth/jwt-resolver.js +2 -27
  5. package/dist/api/auth/password/config.d.ts +32 -0
  6. package/dist/api/auth/password/config.js +35 -0
  7. package/dist/api/auth/password/errors.d.ts +8 -0
  8. package/dist/api/auth/password/errors.js +14 -0
  9. package/dist/api/auth/password/index.d.ts +3 -0
  10. package/dist/api/auth/password/index.js +3 -0
  11. package/dist/api/auth/password/password.d.ts +14 -0
  12. package/dist/api/auth/password/password.js +46 -0
  13. package/dist/api/auth/routes.d.ts +23 -0
  14. package/dist/api/auth/routes.js +118 -0
  15. package/dist/api/auth/token/config.d.ts +10 -0
  16. package/dist/api/auth/token/config.js +43 -0
  17. package/dist/api/auth/token/errors.d.ts +6 -0
  18. package/dist/api/auth/token/errors.js +12 -0
  19. package/dist/api/auth/token/index.d.ts +3 -0
  20. package/dist/api/auth/token/index.js +3 -0
  21. package/dist/api/auth/token/token.d.ts +11 -0
  22. package/dist/api/auth/token/token.js +31 -0
  23. package/dist/api/hooks/define.d.ts +10 -0
  24. package/dist/api/hooks/define.js +3 -0
  25. package/dist/api/hooks/index.d.ts +4 -0
  26. package/dist/api/hooks/index.js +2 -0
  27. package/dist/api/hooks/registry.d.ts +8 -0
  28. package/dist/api/hooks/registry.js +94 -0
  29. package/dist/api/hooks/types.d.ts +48 -0
  30. package/dist/api/hooks/types.js +1 -0
  31. package/dist/api/middleware/errors.js +11 -0
  32. package/dist/api-generator/app-generator.js +3 -0
  33. package/dist/api-generator/hook-scanner.d.ts +11 -0
  34. package/dist/api-generator/hook-scanner.js +36 -0
  35. package/dist/api-generator/hooks-generator.d.ts +2 -0
  36. package/dist/api-generator/hooks-generator.js +25 -0
  37. package/dist/api-generator/index.d.ts +2 -0
  38. package/dist/api-generator/index.js +7 -1
  39. package/dist/api-generator/route-generator.d.ts +3 -2
  40. package/dist/api-generator/route-generator.js +85 -64
  41. package/dist/cli/dev.js +5 -36
  42. package/dist/cli/generate.js +1 -0
  43. package/dist/cli/hooks.d.ts +6 -0
  44. package/dist/cli/hooks.js +85 -0
  45. package/dist/cli/init.js +9 -2
  46. package/dist/cli/paths.d.ts +1 -0
  47. package/dist/cli/paths.js +1 -0
  48. package/dist/cli/server.d.ts +5 -0
  49. package/dist/cli/server.js +60 -0
  50. package/dist/cli/start.d.ts +7 -0
  51. package/dist/cli/start.js +35 -0
  52. package/dist/cli/templates/agents.md +290 -0
  53. package/dist/cli/templates.d.ts +6 -3
  54. package/dist/cli/templates.js +58 -6
  55. package/dist/cli/wait-for-database.js +1 -1
  56. package/dist/cli.js +10 -0
  57. package/dist/db/db-client-generator.js +20 -2
  58. package/dist/db/include/executor.d.ts +2 -2
  59. package/dist/db/include/executor.js +7 -7
  60. package/dist/db/include/json-agg.d.ts +2 -2
  61. package/dist/db/include/json-agg.js +4 -4
  62. package/dist/db/include/load.d.ts +3 -3
  63. package/dist/db/include/load.js +8 -8
  64. package/dist/db/index.d.ts +4 -0
  65. package/dist/db/index.js +2 -0
  66. package/dist/db/model-client.d.ts +2 -2
  67. package/dist/db/model-client.js +3 -3
  68. package/dist/db/queryable.d.ts +5 -0
  69. package/dist/db/queryable.js +1 -0
  70. package/dist/db/raw.d.ts +22 -0
  71. package/dist/db/raw.js +35 -0
  72. package/dist/db/transaction.d.ts +2 -0
  73. package/dist/db/transaction.js +24 -0
  74. package/dist/routes/auth.d.ts +3 -0
  75. package/dist/routes/auth.js +5 -0
  76. package/dist/types/generated-db.stub.d.ts +5 -1
  77. package/dist/types/generated-db.stub.js +8 -1
  78. package/package.json +11 -4
package/README.md CHANGED
@@ -7,27 +7,159 @@
7
7
 
8
8
  ---
9
9
 
10
- ## Philosophy
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
+ - [Project structure](docs/project-structure.md)
18
+ - [Contributing (this repo)](docs/contributing.md)
19
+ - [Why schematic-pg?](docs/why.md)
20
+ - [Roadmap](docs/roadmap.md)
11
21
 
12
- Most backend frameworks force you to scatter your truth across migrations, ORM models, Zod schemas, route handlers, and access control lists. schematic-pg inverts that: **your schema definition is the source of truth** for everything — the database, the REST API, and the runtime validations.
22
+ ---
23
+
24
+ ## Quick Start
25
+
26
+ Install the CLI and scaffold a new project:
27
+
28
+ ```bash
29
+ npx schematic-pg init my-app
30
+ cd my-app
31
+ ```
32
+
33
+ Edit `app.schema`, then start the full dev loop:
34
+
35
+ ```bash
36
+ make dev
37
+ # → starts PostgreSQL, generates code, bootstraps the DB, runs the dev server,
38
+ # and watches app.schema for changes (regenerate + bootstrap + restart)
39
+ # → http://localhost:3000
40
+ ```
41
+
42
+ Or run each step individually:
43
+
44
+ ```bash
45
+ # Start PostgreSQL ( matches .env defaults)
46
+ docker compose up -d --wait
47
+
48
+ # Generate, bootstrap, start server, and watch app.schema (default)
49
+ npx schematic-pg dev
50
+ # → http://localhost:3000
13
51
 
14
- - **One file.** Schema, relations, triggers, indexes, and ACL in a single `.schema` file.
15
- - **Zero ORM.** We generate raw PostgreSQL and parameterized queries. No hidden query builders — relation loading is explicit via `include`, with batched queries that avoid N+1 and cartesian explosion.
16
- - **Hand-written parser.** A small, fast recursive-descent lexer/parser with zero parser-generator dependencies.
17
- - **Hono-based runtime.** Lightweight HTTP handlers generated from your schema, with Zod validation on every write.
52
+ # One-shot dev server without schema watching:
53
+ npx schematic-pg dev --no-watch
54
+ ```
55
+
56
+ Manual split when you need finer control:
57
+
58
+ ```bash
59
+ npx schematic-pg generate
60
+ npx schematic-pg db:bootstrap
61
+ npx schematic-pg dev --no-watch
62
+ ```
63
+
64
+ The `init` command creates everything you need to get running:
65
+
66
+ | File / directory | Purpose |
67
+ |------------------|---------|
68
+ | `AGENTS.md` | Agent-oriented guide for working with schematic-pg in this project |
69
+ | `app.schema` | Starter schema (one `User` model) — edit this |
70
+ | `.env` | `DATABASE_URL`, JWT settings |
71
+ | `docker-compose.yml` | Local PostgreSQL on `:5432` |
72
+ | `Makefile` | `make dev` — docker compose (with health wait) + `schematic-pg dev` |
73
+ | `tsconfig.json` | TypeScript config for `generated/` and `src/routes/` |
74
+ | `package.json` | `schematic-pg` + runtime deps (`hono`, `pg`, `zod`, …) |
75
+ | `src/routes/health.ts` | Example custom route mounted at `/health` |
76
+
77
+ After `generate`, your project also contains:
78
+
79
+ | Output | Purpose |
80
+ |--------|---------|
81
+ | `schema.sql` | Idempotent PostgreSQL DDL |
82
+ | `generated/db*.ts` | Type-safe DB client |
83
+ | `generated/app.ts` | Hono server entry point |
84
+ | `generated/routes/*.ts` | CRUD routers per model |
85
+ | `generated/policies.ts` | ACL metadata from `@policy` |
86
+ | `generated/schemas/validation.ts` | Zod request validators |
87
+
88
+ 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.
89
+
90
+ ### Environment variables
91
+
92
+ | Variable | Default | Purpose |
93
+ |----------|---------|---------|
94
+ | `DATABASE_URL` | — (required) | PostgreSQL connection string |
95
+ | `PORT` | `3000` | HTTP listen port |
96
+ | `JWT_SECRET` | — | HMAC secret for JWT sign + verify (required for auth) |
97
+ | `AUTH_PEPPER` | — | App-side pepper appended before Argon2 hash/verify (required for register/login) |
98
+ | `AUTH_ACCESS_TOKEN_TTL` | `1h` | Access token lifetime (`15m`, `1h`, or seconds) |
99
+ | `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
100
+ | `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
101
+
102
+ Set these in `.env` before running `dev`, `start`, or `db:bootstrap`.
18
103
 
19
104
  ---
20
105
 
21
- ## Features
106
+ ## Authentication
107
+
108
+ 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`.
109
+
110
+ ### Enable routes
111
+
112
+ `init` scaffolds `src/routes/auth.ts`:
113
+
114
+ ```ts
115
+ import { createAuthRouter } from 'schematic-pg/api/auth/routes';
116
+
117
+ export default createAuthRouter();
118
+ ```
119
+
120
+ After `generate:api`, the custom-route scanner mounts it at `/auth`. Options let you map your user model/fields (`userModel`, `emailField`, `passwordHashField`, `roleField`, `defaultCreateFields`, …).
121
+
122
+ | Method | Path | Purpose |
123
+ |--------|------|---------|
124
+ | `POST` | `/auth/register` | Create user (hashes password, issues access token). Bypasses model `@policy` — do not weaken insert policies for signup. |
125
+ | `POST` | `/auth/login` | Verify password, optional rehash, issue access token |
126
+ | `GET` | `/auth/me` | Current `auth` context from the JWT middleware |
127
+
128
+ Register/login responses: `{ token, user }` with `passwordHash` omitted (`@omit` / `omitFields`).
129
+
130
+ ### Password hashing
22
131
 
23
- - **Declarative Schema DSL** — PostgreSQL-native types, enums, extensions, indexes, and triggers
24
- - **Automatic SQL Generation** — idempotent DDL with snake_case naming conventions
25
- - **Type-safe Database Client** — Prisma-like query API over parameterized raw SQL (`pg` Pool, no ORM), with nested `include` eager-loading
26
- - **Type-safe REST API** Hono routes with generated Zod validation
27
- - **Custom routes** Hand-written Hono routers in `src/routes/` auto-imported into the generated app
28
- - **Inline ACL** — Row-level and role-based access control via `@policy` directives, enforced at runtime in generated routes
29
- - **Validation Rules** `@regex` and `@range` constraints that flow into generated Zod request validators (with custom error messages from the schema)
30
- - **Migration Ready** Full regeneration today, diff-based migrations tomorrow
132
+ Use Argon2id via `schematic-pg/api/auth/password`:
133
+
134
+ ```ts
135
+ import { passwordService } from 'schematic-pg/api/auth/password';
136
+ import { UnauthorizedError } from 'schematic-pg/api/auth/errors';
137
+
138
+ const hash = await passwordService.hashPassword(password);
139
+ const valid = await passwordService.verifyPassword(password, user.passwordHash);
140
+ if (!valid) throw new UnauthorizedError();
141
+ if (passwordService.needsRehash(user.passwordHash)) {
142
+ const newHash = await passwordService.hashPassword(password);
143
+ await db.user.update({ where: { id: user.id }, data: { passwordHash: newHash } });
144
+ }
145
+ ```
146
+
147
+ ### Tokens
148
+
149
+ `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.
150
+
151
+ ### Security notes
152
+
153
+ - **Argon2id** with automatic salt; encoded `$argon2id$…` digest stores algo, version, params, salt, and hash.
154
+ - **Pepper** (`AUTH_PEPPER`) is applied before hash/verify and never stored in the DB.
155
+ - **Verify** uses Argon2’s constant-time check — never compare hash strings manually.
156
+ - **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).
157
+ - **Expiry enforcement** on JWT verify; issued tokens always carry `exp`.
158
+ - Never log passwords, hashes, pepper, or `JWT_SECRET`. Keep `passwordHash` `@omit` so it never appears in API JSON.
159
+
160
+ ### Future extensions
161
+
162
+ Password reset, MFA, session/refresh-token management, and login rate limiting are intentionally out of scope for this release.
31
163
 
32
164
  ---
33
165
 
@@ -36,7 +168,6 @@ Most backend frameworks force you to scatter your truth across migrations, ORM m
36
168
  ```ts
37
169
  extensions {
38
170
  pgcrypto { version: "1.3" }
39
- postgis
40
171
  uuid-ossp
41
172
  }
42
173
 
@@ -58,7 +189,7 @@ models {
58
189
  createdAt: TIMESTAMP @default(now())
59
190
  updatedAt: TIMESTAMP?
60
191
 
61
- profile: Profile? @relation(name: "UserProfile")
192
+ profile: Profile?
62
193
  orders: Order[]
63
194
 
64
195
  @policy(role: USER, allow: [select, insert, update], where: "id = {{auth.user.id}}")
@@ -88,7 +219,6 @@ models {
88
219
  location: POINT
89
220
 
90
221
  user: User @relation(
91
- name: "UserProfile",
92
222
  fields: [userId],
93
223
  references: [id],
94
224
  onDelete: CASCADE,
@@ -155,119 +285,63 @@ models {
155
285
  }
156
286
  ```
157
287
 
158
- ---
288
+ ### Relations (`@relation`)
159
289
 
160
- ## How It Works
161
-
162
- ```
163
- ┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐
164
- │ schema.dsl │────▶│ Lexer + │────▶│ AST │
165
- │ (your source) │ │ Parser │ │ (typed nodes) │
166
- └─────────────────┘ └──────────────┘ └────────┬─────────┘
167
-
168
- ┌──────────────────────────────────────────┼──────────┐
169
- │ │ │
170
- ▼ ▼ ▼
171
- ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
172
- │ SQL DDL │ │ DB Client │ │ Hono Routes │
173
- │ Generator │ │ Generator │ │ Generator │
174
- └─────────────┘ └──────────────┘ └─────────────┘
175
- │ │ │
176
- ▼ ▼ ▼
177
- ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
178
- │ schema.sql │ │ generated/ │ │ Hono Routes │
179
- │ (PostgreSQL)│ │ db.ts, types │ │ + policies │
180
- └─────────────┘ └──────────────┘ └─────────────┘
181
- ```
290
+ Relation fields point at another model (`Profile?`, `Order[]`). The side that owns the foreign-key column must declare `@relation` with `fields` and `references`:
182
291
 
183
- 1. **Parse** — The hand-written lexer and recursive-descent parser turn your `.schema` file into a typed AST.
184
- 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`.
185
- 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.
186
- 4. **Generate API** — The route generator emits Hono routers with:
187
- - Zod-validated request bodies and path params (driven by `@regex` and `@range`)
188
- - Full CRUD handlers backed by the generated DB client
189
- - Role-based ACL enforcement (driven by `@policy`) with row-level `WHERE` injection
190
- - Pluggable authentication middleware (default: Bearer JWT)
191
- 5. **Run** — `generated/app.ts` mounts all routers and starts a Node.js server. You get a validated REST API in seconds.
192
-
193
- ---
194
-
195
- ## Quick Start
196
-
197
- Install the CLI and scaffold a new project:
198
-
199
- ```bash
200
- npx schematic-pg init my-app
201
- cd my-app
202
- ```
292
+ ```ts
293
+ model User {
294
+ profile: Profile? // inverseno @relation needed
295
+ orders: Order[]
296
+ }
203
297
 
204
- Edit `app.schema`, then start the full dev loop:
298
+ model Profile {
299
+ userId: UUID @unique
300
+ user: User @relation(
301
+ fields: [userId],
302
+ references: [id],
303
+ onDelete: CASCADE, // optional
304
+ onUpdate: SET_NULL // optional
305
+ )
306
+ }
205
307
 
206
- ```bash
207
- make dev
208
- # starts PostgreSQL, generates code, bootstraps the DB, runs the dev server,
209
- # and watches app.schema for changes (regenerate + bootstrap + restart)
210
- # → http://localhost:3000
308
+ model Order {
309
+ userId: UUID
310
+ user: User @relation(fields: [userId], references: [id])
311
+ }
211
312
  ```
212
313
 
213
- Or run each step individually:
314
+ | Argument | Required | Side | Purpose |
315
+ |----------|----------|------|---------|
316
+ | `fields` | Yes (FK side) | FK owner | Local column(s) on this model |
317
+ | `references` | Yes (FK side) | FK owner | Target column(s) on the related model |
318
+ | `onDelete` | No | FK side | PostgreSQL `ON DELETE` action (`CASCADE`, `SET NULL`, …) |
319
+ | `onUpdate` | No | FK side | PostgreSQL `ON UPDATE` action |
320
+ | `name` | No | Both (must match) | Disambiguates multiple relations between the same two models |
214
321
 
215
- ```bash
216
- # Start PostgreSQL (PostGIS-enabled, matches .env defaults)
217
- docker compose up -d --wait
218
-
219
- # Generate, bootstrap, start server, and watch app.schema (default)
220
- npx schematic-pg dev
221
- # → http://localhost:3000
322
+ **FK owner vs inverse.** Put `fields` and `references` on the model that stores the foreign key (`Profile.userId` → `user` on `Profile`). The other side (`User.profile`) is inferred automatically — list fields become `hasMany`, optional scalars become `hasOne` / `belongsTo` on the FK side.
222
323
 
223
- # One-shot dev server without schema watching:
224
- npx schematic-pg dev --no-watch
225
- ```
324
+ **`name` is only for disambiguation.** When a single link exists between two models (like `User` ↔ `Profile`), you do not need `name` on either side. Use matching `name` values only when two models relate more than once:
226
325
 
227
- Manual split when you need finer control:
326
+ ```ts
327
+ model User {
328
+ writtenPosts: Post[] @relation(name: "PostAuthor")
329
+ editedPosts: Post[] @relation(name: "PostEditor")
330
+ }
228
331
 
229
- ```bash
230
- npx schematic-pg generate
231
- npx schematic-pg db:bootstrap
232
- npx schematic-pg dev --no-watch
332
+ model Post {
333
+ authorId: UUID
334
+ editorId: UUID?
335
+ author: User @relation(name: "PostAuthor", fields: [authorId], references: [id])
336
+ editor: User? @relation(name: "PostEditor", fields: [editorId], references: [id])
337
+ }
233
338
  ```
234
339
 
235
- The `init` command creates everything you need to get running:
236
-
237
- | File / directory | Purpose |
238
- |------------------|---------|
239
- | `app.schema` | Starter schema (one `User` model) — edit this |
240
- | `.env` | `DATABASE_URL`, JWT settings |
241
- | `docker-compose.yml` | Local PostGIS PostgreSQL on `:5432` |
242
- | `Makefile` | `make dev` — docker compose (with health wait) + `schematic-pg dev` |
243
- | `tsconfig.json` | TypeScript config for `generated/` and `src/routes/` |
244
- | `package.json` | `schematic-pg` + runtime deps (`hono`, `pg`, `zod`, …) |
245
- | `src/routes/health.ts` | Example custom route mounted at `/health` |
340
+ If either side declares `name`, the other side must use the same `name` (or omit `@relation` entirely on the inverse when no `name` is used anywhere).
246
341
 
247
- After `generate`, your project also contains:
248
-
249
- | Output | Purpose |
250
- |--------|---------|
251
- | `schema.sql` | Idempotent PostgreSQL DDL |
252
- | `generated/db*.ts` | Type-safe DB client |
253
- | `generated/app.ts` | Hono server entry point |
254
- | `generated/routes/*.ts` | CRUD routers per model |
255
- | `generated/policies.ts` | ACL metadata from `@policy` |
256
- | `generated/schemas/validation.ts` | Zod request validators |
257
-
258
- 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.
259
-
260
- ### Environment variables
261
-
262
- | Variable | Default | Purpose |
263
- |----------|---------|---------|
264
- | `DATABASE_URL` | — (required) | PostgreSQL connection string |
265
- | `PORT` | `3000` | HTTP listen port |
266
- | `JWT_SECRET` | — | HMAC secret for the default Bearer JWT resolver |
267
- | `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
268
- | `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
342
+ **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.
269
343
 
270
- Set these in `.env` before running `dev` or `db:bootstrap`.
344
+ For `@policy` enforcement, JWT auth, and row-level filters, see [Access control](docs/access-control.md).
271
345
 
272
346
  ---
273
347
 
@@ -292,6 +366,14 @@ schematic-pg generate:api [schema] # generated/app.ts, routes/, policies, s
292
366
 
293
367
  Run `generate:client` before `generate:api` when using the split commands — routes depend on `generated/db.ts`.
294
368
 
369
+ ### Lifecycle hooks scaffolding
370
+
371
+ ```bash
372
+ schematic-pg hooks:add [schema] [--model ModelName]
373
+ ```
374
+
375
+ Reads `app.schema`, prompts for a model (or accepts `--model`), and writes `src/hooks/{Model}.ts` with all six lifecycle hooks pre-filled. Delete any hooks you do not need, then run `generate:api` to wire them into POST/PUT/DELETE routes. Existing hook files are never overwritten.
376
+
295
377
  ### Development server
296
378
 
297
379
  ```bash
@@ -312,897 +394,82 @@ Equivalent npm scripts in a project created by `init`:
312
394
  ```bash
313
395
  make dev # docker compose up -d --wait + schematic-pg dev
314
396
  npm run dev # schematic-pg dev
397
+ npm run start # schematic-pg start (production)
315
398
  npm run generate # schematic-pg generate
316
399
  ```
317
400
 
318
- ### Database commands
401
+ ### Production server
319
402
 
320
403
  ```bash
321
- schematic-pg db:ping [schema] # Test DATABASE_URL connection (SELECT 1)
322
- schematic-pg db:bootstrap [schema] # Apply DDL from schema + write .schema-state snapshot
323
- schematic-pg db:diff [schema] # Print pending schema changes (snapshot vs app.schema)
324
- schematic-pg db:diff --name add_users # Write a migration file under migrations/
325
- schematic-pg db:migrate [schema] # Apply pending migration files
326
- schematic-pg db:migrate:status [schema] # Show snapshot + migration file status
404
+ schematic-pg start [schema] [--no-migrate]
327
405
  ```
328
406
 
329
- `db:bootstrap` is the recommended first-time setup. Use `db:diff` / `db:migrate` when evolving an existing database.
330
-
331
- Alternatively, apply SQL manually:
332
-
333
- ```bash
334
- psql $DATABASE_URL -f schema.sql
335
- ```
407
+ `start` runs the app in production mode no code generation, no schema watching:
336
408
 
337
- ### Help
409
+ 1. Verifies `generated/app.ts` exists (run `generate` in your build step if missing)
410
+ 2. Waits for PostgreSQL to accept connections
411
+ 3. Applies pending migration files (default; skip with `--no-migrate`)
412
+ 4. Starts `generated/app.ts` with `NODE_ENV=production` until exit
338
413
 
339
- ```bash
340
- schematic-pg --help
341
- ```
414
+ The optional `[schema]` argument is only used for migration snapshot resolution (same as `db:migrate`).
342
415
 
343
- ---
416
+ | Step | `dev` | `start` |
417
+ |------|-------|---------|
418
+ | Generate code | Yes | No |
419
+ | DB bootstrap | Yes | No |
420
+ | Apply pending migrations | No | Yes (default) |
421
+ | Wait for Postgres | Yes (via bootstrap) | Yes |
422
+ | Schema file watch | Yes (default) | No |
423
+ | `NODE_ENV` | unset | `production` |
344
424
 
345
- ## Local development (this repo)
346
-
347
- Contributors working on the framework itself clone the repo and use npm scripts (which delegate to the same CLI via `tsx`):
425
+ Example deploy flow:
348
426
 
349
427
  ```bash
350
- cp .env.example .env # configure DATABASE_URL
351
- npm run build # compile src/ dist/ (required for schematic-pg/* imports)
352
- npm run docker:up # PostGIS-enabled PostgreSQL on :5432
353
- npm run generate # write schema.sql from app.schema
354
- npm run generate:client # write generated/db*.ts
355
- npm run generate:api # write generated/app.ts, routes/, schemas/
356
- npm run db:bootstrap # apply DDL + snapshot schema state
357
- npm run dev:api # regenerate client + API and start server on :3000
358
- npm test # unit tests
359
- npm run test:integration # Docker + generate + DB client + ACL integration tests
428
+ npx schematic-pg generate # build step in CI
429
+ npx schematic-pg start # migrate DB + run server
430
+ # or: npm run start
360
431
  ```
361
432
 
362
- ---
363
-
364
- ## Database Client
365
-
366
- 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.
367
-
368
- ### Generate
433
+ Pass `--no-migrate` when migrations are applied separately (e.g. in a release job):
369
434
 
370
435
  ```bash
371
- npx schematic-pg generate:client
372
- # or: npm run generate:client (inside a scaffolded project)
373
- ```
374
-
375
- Outputs:
376
-
377
- | File | Purpose |
378
- |------|---------|
379
- | `generated/db-types.ts` | Per-model interfaces: `User`, `UserCreateInput`, `UserWhereInput`, `UserInclude`, enum unions |
380
- | `generated/db-model-meta.ts` | Serialized field/column and relation metadata consumed at runtime |
381
- | `generated/db.ts` | `createDbClient(pool)` factory wiring all models |
382
-
383
- ### Usage
384
-
385
- ```typescript
386
- import { Pool } from 'pg';
387
- import { createDbClient } from './generated/db.js';
388
-
389
- const pool = new Pool({ connectionString: process.env.DATABASE_URL });
390
- const db = createDbClient(pool);
391
-
392
- // Create
393
- const user = await db.user.create({
394
- email: 'a@b.com',
395
- name: 'Alice',
396
- balance: 0,
397
- });
398
-
399
- // Read
400
- const one = await db.user.findUnique({ id: user.id });
401
- const first = await db.user.findFirst({
402
- where: { role: 'ADMIN' },
403
- orderBy: { createdAt: 'desc' },
404
- });
405
- const many = await db.user.findMany({
406
- where: { role: { in: ['ADMIN', 'USER'] }, isActive: true },
407
- orderBy: [{ role: 'asc' }, { createdAt: 'desc' }],
408
- take: 10,
409
- skip: 0,
410
- });
411
- const total = await db.user.count({ where: { role: 'ADMIN' } });
412
-
413
- // Eager-load relations (nested)
414
- const usersWithOrders = await db.user.findMany({
415
- where: { role: 'ADMIN' },
416
- include: {
417
- profile: true,
418
- orders: {
419
- where: { status: 'PENDING' },
420
- orderBy: { createdAt: 'desc' },
421
- include: {
422
- products: {
423
- include: { product: true },
424
- },
425
- },
426
- },
427
- },
428
- });
429
-
430
- // include works on all read methods
431
- const oneWithProfile = await db.user.findUnique(
432
- { id: user.id },
433
- { include: { profile: true } },
434
- );
435
-
436
- // Update
437
- const updated = await db.user.update({
438
- where: { id: user.id },
439
- data: { name: 'Bob' },
440
- });
441
- const { count } = await db.user.updateMany({
442
- where: { isActive: false },
443
- data: { name: 'Inactive' },
444
- });
445
-
446
- // Delete
447
- const deleted = await db.user.delete({ id: user.id });
448
- await db.user.deleteMany({ where: { role: 'PUBLIC' } });
436
+ npx schematic-pg db:migrate
437
+ npx schematic-pg start --no-migrate
449
438
  ```
450
439
 
451
- ### Per-model API
452
-
453
- Each model in `app.schema` becomes a camelCase property on the client (`User` → `db.user`, `ProductOrder` → `db.productOrder`) with these methods:
454
-
455
- | Method | SQL shape |
456
- |--------|-----------|
457
- | `create(data)` | `INSERT INTO … VALUES ($1, …) RETURNING *` |
458
- | `findUnique(where, { include, … })` | `SELECT * … WHERE … LIMIT 1` (+ batched relation queries when `include` is set) |
459
- | `findFirst({ where, orderBy, include, … })` | `SELECT * … ORDER BY … LIMIT 1` (+ relation queries when `include` is set) |
460
- | `findMany({ where, orderBy, take, skip, include, … })` | `SELECT * … ORDER BY … LIMIT … OFFSET …` (+ relation queries when `include` is set) |
461
- | `count({ where })` | `SELECT COUNT(*) …` (no `include`) |
462
- | `update({ where, data })` | `UPDATE … SET … WHERE … RETURNING *` |
463
- | `updateMany({ where, data })` | `UPDATE … SET … WHERE … RETURNING *` |
464
- | `delete(where)` | `DELETE … WHERE … RETURNING *` |
465
- | `deleteMany({ where })` | `DELETE … WHERE … RETURNING *` |
466
-
467
- Mutations return the full row (`RETURNING *`). Rows are mapped from `snake_case` columns to `camelCase` TypeScript fields.
468
-
469
- ### Eager loading (`include`)
470
-
471
- 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: …)`).
472
-
473
- **Supported on:** `findMany`, `findFirst`, and `findUnique`. Not on `count`.
474
-
475
- ```typescript
476
- // Shorthand — load all columns for the relation
477
- await db.user.findMany({
478
- include: { profile: true, orders: true },
479
- });
480
-
481
- // Nested — arbitrary depth
482
- await db.user.findMany({
483
- include: {
484
- orders: {
485
- include: {
486
- products: {
487
- include: { product: true },
488
- },
489
- },
490
- },
491
- },
492
- });
493
-
494
- // Filter, sort, and paginate inner relations
495
- await db.user.findMany({
496
- include: {
497
- orders: {
498
- where: { status: 'PENDING' },
499
- orderBy: { createdAt: 'desc' },
500
- take: 5,
501
- include: { products: true },
502
- },
503
- },
504
- });
505
- ```
506
-
507
- Each relation key accepts `true` or a `{ where?, orderBy?, take?, skip?, include? }` object (typed as `{Model}IncludeArgs` in `generated/db-types.ts`).
508
-
509
- **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.
510
-
511
- #### Loading strategies
512
-
513
- 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`).
514
-
515
- ```typescript
516
- // Default — split queries (recommended for large result sets)
517
- await db.user.findMany({
518
- include: { orders: true },
519
- });
520
-
521
- // Optional — single round-trip via PostgreSQL LATERAL + json_agg
522
- await db.user.findMany({
523
- include: { profile: true, orders: true },
524
- relationLoadStrategy: 'join',
525
- });
526
- ```
527
-
528
- | Strategy | Option | Behavior |
529
- |----------|--------|----------|
530
- | Split (default) | omit or `'split'` | One query per relation edge; no row duplication on the wire |
531
- | Join | `'join'` | One SQL statement; nested JSON built in PostgreSQL |
532
-
533
- Use `'join'` when latency dominates (fewer round trips). Use the default split strategy when loading many rows or several sibling collections.
534
-
535
- #### How it works
536
-
537
- ```
538
- findMany({ include }) → buildLoadPlan (relation tree from schema metadata)
539
- → SELECT root rows
540
- → for each included relation: SELECT … WHERE fk = ANY($parentIds)
541
- → stitch (hash-join children onto parents in O(n))
542
- ```
543
-
544
- 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.
545
-
546
- Generated types: `{Model}Include` and `{Model}IncludeArgs` in `generated/db-types.ts`.
547
-
548
- ### Where filters
549
-
550
- Direct values are treated as equality. Structured operators are supported per field type:
551
-
552
- ```typescript
553
- // Equality shorthand
554
- { email: 'a@b.com' }
555
-
556
- // Explicit operators
557
- { email: { equals: 'a@b.com' } }
558
- { email: { contains: '@' } } // LIKE %@%
559
- { email: { startsWith: 'a' } } // LIKE a%
560
- { email: { endsWith: '.com' } } // LIKE %.com
561
- { balance: { gt: 100 } }
562
- { balance: { lte: 500 } }
563
- { role: { in: ['ADMIN', 'USER'] } }
564
-
565
- // Logical groups
566
- {
567
- AND: [{ role: 'USER' }, { isActive: true }],
568
- OR: [{ role: 'ADMIN' }, { role: 'PUBLIC' }],
569
- NOT: { isActive: false },
570
- }
571
- ```
572
-
573
- ### Naming and types
574
-
575
- - **API**: camelCase field names (`createdAt`, `userId`)
576
- - **SQL**: snake_case columns (`created_at`, `user_id`); reserved table names like `user` and `order` are quoted
577
- - **Runtime mapping**:
578
-
579
- | Schema type | TypeScript |
580
- |-------------|------------|
581
- | `UUID`, `VARCHAR`, `TEXT` | `string` |
582
- | `INTEGER`, `SERIAL`, `SMALLINT` | `number` |
583
- | `BOOLEAN` | `boolean` |
584
- | `TIMESTAMP` | `Date` |
585
- | `DECIMAL` | `string` (avoids float precision loss) |
586
- | `JSONB` | `Record<string, unknown>` |
587
- | `TEXT[]` | `string[]` |
588
- | Enums | string literal union |
589
- | Optional (`?`) | `T \| null` |
590
-
591
- Fields with `@default` are optional on `CreateInput`. `@id` fields are omitted from create input when the database generates them.
592
-
593
- ### Error handling
594
-
595
- PostgreSQL errors are mapped to typed exceptions:
596
-
597
- | Class | PG code | When |
598
- |-------|---------|------|
599
- | `UniqueConstraintError` | `23505` | Duplicate unique column (includes `fields: string[]`) |
600
- | `ForeignKeyConstraintError` | `23503` | Invalid relation reference |
601
- | `NotFoundError` | — | Optional helper for missing records |
602
- | `DatabaseError` | other | Generic wrapper with `code`, `detail`, `constraint` |
603
-
604
- ```typescript
605
- import { UniqueConstraintError } from 'schematic-pg/db/errors';
606
-
607
- try {
608
- await db.user.create({ email: 'taken@b.com', name: 'X', balance: 0 });
609
- } catch (error) {
610
- if (error instanceof UniqueConstraintError) {
611
- console.log(error.fields); // ['email']
612
- }
613
- }
614
- ```
615
-
616
- ### Runtime architecture
617
-
618
- 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/`:
619
-
620
- ```
621
- schematic-pg/dist/db/ # Published runtime (import as schematic-pg/db/*)
622
- ├── query-builder.ts # INSERT / SELECT / UPDATE / DELETE / COUNT
623
- ├── where-translator.ts # WhereInput → SQL + params
624
- ├── model-client.ts # createModelClient factory
625
- ├── include/ # Eager-loading planner, executor, hydrator, json_agg
626
- ├── utils/relations.ts # Relation graph from schema AST
627
- ├── row-mapper.ts # snake_case rows → camelCase + coercion
628
- └── errors.ts # UniqueConstraintError, ForeignKeyConstraintError, …
629
- ```
630
-
631
- ### Integration tests
632
-
633
- One command starts Docker Postgres, generates the client and API, and runs all integration tests (DB client + ACL):
634
-
635
- ```bash
636
- npm run test:integration
637
- ```
638
-
639
- This resets the `public` schema, bootstraps from `app.schema`, seeds test data, and exercises:
640
-
641
- - **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))
642
- - **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))
643
-
644
- Tests run in-process via Hono `app.request()` against the exported `createApp()` factory from `generated/app.ts`.
645
-
646
- ---
647
-
648
- ## REST API
649
-
650
- 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.
651
-
652
- ### Generate
653
-
654
- ```bash
655
- npx schematic-pg generate:api
656
- # or: npm run generate:api
657
- ```
658
-
659
- Requires `generate:client` first (routes call `createDbClient` from `generated/db.ts`). Use `npx schematic-pg generate` to run both.
660
-
661
- Outputs:
662
-
663
- | File | Purpose |
664
- |------|---------|
665
- | `generated/app.ts` | Hono app entry point — mounts routers, auth + DB middleware, starts the server |
666
- | `generated/policies.ts` | Per-model ACL metadata derived from `@policy` attributes |
667
- | `generated/schemas/validation.ts` | Per-model Zod schemas: `{Model}CreateSchema`, `{Model}UpdateSchema`, `{Model}ParamSchema` |
668
- | `generated/routes/*.ts` | One Hono router per model with GET / POST / PUT / DELETE handlers |
669
- | `src/routes/*.ts` | *(hand-written)* Custom Hono routers auto-imported into `generated/app.ts` on each `generate:api` run |
670
-
671
- ### Start the server
672
-
673
- ```bash
674
- npx schematic-pg dev
675
- # or: npm run dev
676
- # → regenerates client + API, then starts http://localhost:3000
677
- ```
678
-
679
- Or run the generated entry point directly after generation:
680
-
681
- ```bash
682
- npx tsx generated/app.ts
683
- ```
684
-
685
- Environment variables (also see [Quick Start](#quick-start)):
686
-
687
- | Variable | Default | Purpose |
688
- |----------|---------|---------|
689
- | `DATABASE_URL` | — (required) | PostgreSQL connection string (loaded from `.env`) |
690
- | `PORT` | `3000` | HTTP listen port |
691
- | `JWT_SECRET` | — | HMAC secret for the default Bearer JWT resolver |
692
- | `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
693
- | `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
694
-
695
- 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')`).
696
-
697
- ### Routes
698
-
699
- 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.
700
-
701
- | Model | Base path | Primary key route |
702
- |-------|-----------|-------------------|
703
- | `User` | `/users` | `/users/:id` |
704
- | `Profile` | `/profiles` | `/profiles/:id` |
705
- | `Order` | `/orders` | `/orders/:id` |
706
- | `Log` | `/logs` | `/logs/:id` |
707
- | `Product` | `/products` | `/products/:id` |
708
- | `ProductOrder` | `/product-orders` | `/product-orders/:orderId/:productId` |
709
-
710
- Models with composite primary keys (`@@id(fields: [...])`) expose one path segment per key field.
711
-
712
- ### Custom routes
713
-
714
- 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.
715
-
716
- **Convention**
717
-
718
- | Rule | Example |
719
- |------|---------|
720
- | Location | `src/routes/**/*.ts` |
721
- | Export | `export default router` where `router` is `Hono<AppEnv>` |
722
- | Mount path | File path relative to `src/routes/`, without extension |
723
- | Regenerate | `schematic-pg generate:api` or `schematic-pg dev` after adding or renaming files |
724
-
725
- **Path mapping**
726
-
727
- | File | Mounted at |
728
- |------|------------|
729
- | `src/routes/health.ts` | `/health` |
730
- | `src/routes/webhooks/stripe.ts` | `/webhooks/stripe` |
731
-
732
- **Example** — `src/routes/health.ts`:
733
-
734
- ```typescript
735
- import { Hono } from 'hono';
736
- import type { AppEnv } from 'schematic-pg/api/types';
737
-
738
- const router = new Hono<AppEnv>();
739
-
740
- router.get('/', (c) => c.json({ ok: true }));
741
-
742
- export default router;
743
- ```
744
-
745
- After `schematic-pg generate:api`, `generated/app.ts` includes:
746
-
747
- ```typescript
748
- import healthRouter from '../src/routes/health.js';
749
- // ...
750
- app.route('/health', healthRouter);
751
- ```
752
-
753
- Custom routes are mounted **after** all schema-generated routers. Handlers can use the same request context as generated routes:
754
-
755
- ```typescript
756
- router.get('/me', async (c) => {
757
- const db = c.get('db');
758
- const auth = c.get('auth');
759
- // ...
760
- });
761
- ```
762
-
763
- **Skipped files** — The scanner ignores `*.test.ts`, `*.d.ts`, and any file or directory whose name starts with `_`.
764
-
765
- **Do not edit** `generated/app.ts` manually for custom routes — add files under `src/routes/` and regenerate.
766
-
767
- ### Endpoints
768
-
769
- 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.
770
-
771
- | Method | Path | Handler | Validation |
772
- |--------|------|---------|------------|
773
- | `GET` | `/` | `findMany({ where: mergeWhere(queryWhere, policyWhere), orderBy, take, skip, include })` | Query params |
774
- | `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere), { include })` | Path params + query params |
775
- | `POST` | `/` | `create(body)` — policy check only | JSON body |
776
- | `PUT` | `/{pk}` | `update({ where: mergeWhere(pk, policyWhere), data })` | Path params + JSON body |
777
- | `DELETE` | `/{pk}` | `delete(mergeWhere(pk, policyWhere))` | Path params |
778
-
779
- `POST` returns `201 Created`. Missing records on `GET` return `404`. All handlers strip `@omit` fields from JSON responses before returning.
780
-
781
- ### Query filters (`GET /`)
782
-
783
- All stored scalar fields are URL-filterable by default. Opt out with `@unfilterable` on a field. Fields marked `@omit` are never filterable.
784
-
785
- Query params use **API field names** (camelCase), not SQL column names:
786
-
787
- | Param | Maps to |
788
- |-------|---------|
789
- | `?role=ADMIN` | `{ role: 'ADMIN' }` |
790
- | `?email_contains=@` | `{ email: { contains: '@' } }` |
791
- | `?balance_gte=100` | `{ balance: { gte: 100 } }` |
792
- | `?role_in=ADMIN,USER` | `{ role: { in: ['ADMIN', 'USER'] } }` |
793
- | `?limit=20` | `take: 20` (max 100) |
794
- | `?offset=40` | `skip: 40` |
795
- | `?sort=-createdAt` | `orderBy: { createdAt: 'desc' }` |
796
- | `?include=profile,orders` | `include: { profile: true, orders: true }` |
797
- | `?include=orders.products.product` | nested boolean includes |
798
-
799
- 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.
440
+ Equivalent npm scripts in a project created by `init`:
800
441
 
801
442
  ```bash
802
- curl "http://localhost:3000/products?category=books&limit=10"
803
- curl "http://localhost:3000/users?role=USER&isActive=true" -H "Authorization: Bearer $TOKEN"
804
- curl "http://localhost:3000/users/USER_ID?include=profile,orders" -H "Authorization: Bearer $TOKEN"
443
+ npm run start # schematic-pg start
805
444
  ```
806
445
 
807
- ### Relation includes (`GET /`, `GET /{pk}`)
808
-
809
- Load related models via the `include` query param. Paths are comma-separated; use dots for nesting:
446
+ ### Database commands
810
447
 
811
448
  ```bash
812
- curl "http://localhost:3000/users?include=profile,orders"
813
- curl "http://localhost:3000/users/USER_ID?include=orders.products.product"
814
- ```
815
-
816
- 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).
817
-
818
- Opt out of HTTP includes on a relation field with `@unincludeable`:
819
-
820
- ```ts
821
- orders: Order[] @unincludeable
822
- ```
823
-
824
- **v1 limits:**
825
-
826
- - Boolean includes only — no nested `where`, `take`, or `skip` via URL (use the DB client or a custom route for that).
827
- - `@policy` row filters apply to the **root** model only; included relations are not policy-filtered separately.
828
- - `@omit` fields are stripped recursively on nested included objects in read responses.
829
-
830
- ### Response shaping (`@omit`)
831
-
832
- 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.
833
-
834
- ```ts
835
- passwordHash: VARCHAR(255) @omit @unfilterable @default("")
836
- ```
837
-
838
- Generated types include `{Model}Response` (for example `UserResponse = Omit<User, 'passwordHash'>`) in `generated/schemas/validation.ts`.
839
-
840
- ### Validation
841
-
842
- Zod schemas are generated from stored fields (relation fields are excluded). Rules from the DSL:
843
-
844
- ```ts
845
- email: VARCHAR(255) @regex(pattern: "^[\\w.-]+@[\\w.-]+\\.\\w+$", message: "Invalid email address")
846
- age: SMALLINT? @range(min: 1, max: 120, message: "Age must be between 1 and 120")
847
- ```
848
-
849
- Become generated validators with the same messages:
850
-
851
- ```typescript
852
- email: z.string().regex(/^[\w.-]+@[\w.-]+\.\w+$/, { message: 'Invalid email address' }),
853
- 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(),
854
- ```
855
-
856
- Validation runs through middleware in `src/api/middleware/validate.ts`. On failure the API responds with:
857
-
858
- ```json
859
- { "error": "Invalid email address" }
449
+ schematic-pg db:ping [schema] # Test DATABASE_URL connection (SELECT 1)
450
+ schematic-pg db:bootstrap [schema] # Apply DDL from schema + write .schema-state snapshot
451
+ schematic-pg db:diff [schema] # Print pending schema changes (snapshot vs app.schema)
452
+ schematic-pg db:diff --name add_users # Write a migration file under migrations/
453
+ schematic-pg db:migrate [schema] # Apply pending migration files
454
+ schematic-pg db:migrate:status [schema] # Show snapshot + migration file status
860
455
  ```
861
456
 
862
- Fields with `@default` or optional (`?`) types are optional on create. Update schemas make all non-PK fields optional (partial updates).
457
+ `db:bootstrap` is the recommended first-time setup. Use `db:diff` / `db:migrate` when evolving an existing database.
863
458
 
864
- ### Example requests
459
+ Alternatively, apply SQL manually:
865
460
 
866
461
  ```bash
867
- # Health check (custom route from src/routes/health.ts)
868
- curl http://localhost:3000/health
869
-
870
- # List users with filters
871
- curl "http://localhost:3000/users?role=USER&limit=10"
872
-
873
- # List users
874
- curl http://localhost:3000/users
875
-
876
- # Get one user
877
- curl http://localhost:3000/users/{uuid}
878
-
879
- # Create a user
880
- curl -X POST http://localhost:3000/users \
881
- -H 'Content-Type: application/json' \
882
- -d '{"email":"alice@example.com","name":"Alice","balance":0}'
883
-
884
- # Validation failure (schema message returned)
885
- curl -X POST http://localhost:3000/users \
886
- -H 'Content-Type: application/json' \
887
- -d '{"email":"not-an-email","name":"Alice","balance":0}'
888
- # → {"error":"Invalid email address"}
889
-
890
- # Update a user
891
- curl -X PUT http://localhost:3000/users/{uuid} \
892
- -H 'Content-Type: application/json' \
893
- -d '{"name":"Alice Updated"}'
894
-
895
- # Delete a user
896
- curl -X DELETE http://localhost:3000/users/{uuid}
897
-
898
- # Composite primary key
899
- curl http://localhost:3000/product-orders/{orderId}/{productId}
900
- ```
901
-
902
- ### Error responses
903
-
904
- | Status | When |
905
- |--------|------|
906
- | `400` | Zod validation failure or foreign key violation |
907
- | `401` | Malformed or invalid JWT (when `Authorization: Bearer` is present) |
908
- | `403` | Role not allowed for the requested operation (`@policy` denial) |
909
- | `404` | Record not found on `GET`, or delete/update returned no rows |
910
- | `409` | Unique constraint violation |
911
- | `500` | Other database errors |
912
-
913
- Global error handling lives in `src/api/middleware/errors.ts` and maps the same typed exceptions as the DB client layer.
914
-
915
- ### App configuration
916
-
917
- The generated `app.ts` sets up:
918
-
919
- ```typescript
920
- import { Hono } from 'hono';
921
- import { logger } from 'hono/logger';
922
- import { prettyJSON } from 'hono/pretty-json';
923
-
924
- const app = new Hono<AppEnv>();
925
- app.use(logger());
926
- app.use(prettyJSON());
927
- app.use(createDbMiddleware()); // injects db from DATABASE_URL
928
- app.use(createAuthMiddleware()); // injects auth (default: Bearer JWT)
929
- app.onError(handleError);
930
-
931
- app.route('/users', usersRouter);
932
- // ... all generated routers
933
- app.route('/health', healthRouter);
934
- // ... all custom routers from src/routes/
935
- ```
936
-
937
- ### Runtime architecture
938
-
939
- 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/`:
940
-
941
- ```
942
- schematic-pg/dist/api/ # Published runtime (import as schematic-pg/api/*)
943
- ├── types.ts # Hono AppEnv (db + auth in context)
944
- ├── auth/
945
- │ ├── jwt-resolver.ts # Default Bearer JWT resolver (HS256)
946
- │ ├── middleware.ts # createAuthMiddleware(resolver?)
947
- │ ├── policy.ts # assertPolicy, resolvePolicyWhere, mergeWhere
948
- │ └── ...
949
- ├── middleware/
950
- │ ├── db.ts # Pool + createDbClient + context middleware
951
- │ ├── validate.ts # Zod validation wrappers
952
- │ └── errors.ts # HTTP error mapping (401, 403, 409, …)
953
- └── utils/
954
- └── route-naming.ts # Model → kebab-case plural paths
955
-
956
- your-project/src/routes/ # Hand-written custom Hono routers (auto-imported)
957
- └── health.ts # Example: GET /health
958
- ```
959
-
960
- The generators live in this repo under `src/api-generator/` and are invoked by the CLI at build time.
961
-
962
- ---
963
-
964
- ## Access Control (`@policy`)
965
-
966
- 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.
967
-
968
- ### Defining policies
969
-
970
- Attach one or more `@policy` attributes to a model:
971
-
972
- ```ts
973
- model User {
974
- id: UUID @id @default(gen_random_uuid())
975
- role: UserRole @default(USER)
976
- // ...
977
-
978
- @policy(role: USER, allow: [select, insert, update], where: "id = {{auth.user.id}}")
979
- @policy(role: ADMIN, allow: all)
980
- }
981
- ```
982
-
983
- | Argument | Type | Description |
984
- |----------|------|-------------|
985
- | `role` | enum identifier | Role this policy applies to (must match a value in your schema enums, e.g. `UserRole`) |
986
- | `allow` | `all` or `[select, insert, update, delete]` | Operations permitted for this role |
987
- | `where` | string (optional) | Row-level filter applied on read/update/delete; supports `{{auth.*}}` templates |
988
-
989
- **Operations map to HTTP methods:**
990
-
991
- | HTTP | Policy operation |
992
- |------|------------------|
993
- | `GET` | `select` |
994
- | `POST` | `insert` |
995
- | `PUT` | `update` |
996
- | `DELETE` | `delete` |
997
-
998
- Models **without** `@policy` attributes are open — generated routes skip ACL checks entirely (e.g. `Log` in the sample schema).
999
-
1000
- ### How enforcement works
1001
-
1002
- For each model that has policies, generated routes call the policy guard before every DB operation:
1003
-
1004
- ```typescript
1005
- const auth = c.get('auth');
1006
- const policy = assertPolicy('User', auth.role, 'select');
1007
- const policyWhere = resolvePolicyWhere(policy, auth);
1008
- const rows = await db.user.findMany({ where: policyWhere });
1009
- ```
1010
-
1011
- 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.
1012
- 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.
1013
- 3. **`mergeWhere(routeWhere, policyWhere)`** — Combines route params (e.g. `:id`) with the policy filter via `AND` on read/update/delete.
1014
-
1015
- `POST` (insert) checks operation permission only — no `where` injection.
1016
-
1017
- ### Auth context
1018
-
1019
- Every request gets an `auth` object on Hono context:
1020
-
1021
- ```typescript
1022
- type AuthContext = {
1023
- role: string;
1024
- user?: { id: string; [key: string]: unknown };
1025
- };
462
+ psql $DATABASE_URL -f schema.sql
1026
463
  ```
1027
464
 
1028
- **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.
1029
-
1030
- If the caller's role has no matching `@policy`, the runtime falls back to a `PUBLIC` role policy when one exists.
1031
-
1032
- ### Default JWT authentication
1033
-
1034
- The generated app uses `createAuthMiddleware()` with a built-in Bearer JWT resolver (`src/api/auth/jwt-resolver.ts`):
465
+ ### Help
1035
466
 
1036
467
  ```bash
1037
- curl http://localhost:3000/users \
1038
- -H 'Authorization: Bearer <jwt>'
1039
- ```
1040
-
1041
- The resolver expects HS256 tokens and reads:
1042
-
1043
- - `auth.role` ← claim named by `JWT_ROLE_CLAIM` (default: `role`)
1044
- - `auth.user.id` ← claim named by `JWT_USER_ID_CLAIM` (default: `sub`)
1045
-
1046
- Set `JWT_SECRET` in `.env` when using the default resolver.
1047
-
1048
- ### Pluggable auth
1049
-
1050
- Different systems resolve identity differently. Pass a custom `AuthResolver` to the middleware:
1051
-
1052
- ```typescript
1053
- import { createAuthMiddleware } from 'schematic-pg/api/auth/middleware';
1054
-
1055
- app.use(createAuthMiddleware(async (c) => {
1056
- const role = c.req.header('X-Role');
1057
- const userId = c.req.header('X-User-Id');
1058
-
1059
- if (!role || !userId) {
1060
- return null; // → defaults to { role: 'PUBLIC' }
1061
- }
1062
-
1063
- return {
1064
- role,
1065
- user: { id: userId },
1066
- };
1067
- }));
1068
- ```
1069
-
1070
- `AuthResolver` signature: `(c: Context<AppEnv>) => Promise<AuthContext | null>`.
1071
-
1072
- Return `null` for anonymous callers; throw `UnauthorizedError` for invalid credentials.
1073
-
1074
- ### Where templates
1075
-
1076
- Policy `where` clauses support `{{auth.*}}` placeholders resolved against the auth context:
1077
-
1078
- ```ts
1079
- where: "id = {{auth.user.id}}"
1080
- ```
1081
-
1082
- After interpolation, simple `field op value` forms are parsed into `WhereInput`:
1083
-
1084
- | Form | Example |
1085
- |------|---------|
1086
- | Equality | `id = {{auth.user.id}}` → `{ id: '…' }` |
1087
- | Comparison | `balance >= 100` → `{ balance: { gte: 100 } }` |
1088
- | Inequality | `role != ADMIN` → `{ NOT: { role: 'ADMIN' } }` |
1089
-
1090
- Complex multi-clause SQL in `where` is not supported yet — keep policies to a single condition for now.
1091
-
1092
- ### Generated policy metadata
1093
-
1094
- `schematic-pg generate:api` emits `generated/policies.ts`:
1095
-
1096
- ```typescript
1097
- export const POLICIES: Record<string, NormalizedPolicy[]> = {
1098
- User: [
1099
- { role: 'USER', operations: ['select', 'insert', 'update'], where: "id = {{auth.user.id}}" },
1100
- { role: 'ADMIN', operations: 'all' },
1101
- ],
1102
- };
1103
- ```
1104
-
1105
- This file is consumed by `assertPolicy` at runtime — do not edit manually.
1106
-
1107
- ### Example: scoped user access
1108
-
1109
- With the sample `User` policies above:
1110
-
1111
- | Caller | `GET /users` | `GET /users/:id` | `DELETE /users/:id` |
1112
- |--------|--------------|------------------|---------------------|
1113
- | No token (`PUBLIC`) | `403` | `403` | `403` |
1114
- | JWT `role: USER`, `sub: <own-id>` | Returns own row only | Own row if `:id` matches | `403` (delete not in `allow`) |
1115
- | JWT `role: ADMIN` | Returns all rows | Any row | Allowed |
1116
-
1117
- These scenarios are covered by `npm run test:integration` — see [`src/api/__tests__/acl.integration.test.ts`](src/api/__tests__/acl.integration.test.ts).
1118
-
1119
- ---
1120
-
1121
- ## Project Structure
1122
-
1123
- After `schematic-pg init` and `schematic-pg generate`, a typical application looks like this:
1124
-
1125
- ```
1126
- my-app/
1127
- ├── app.schema # Your single source of truth
1128
- ├── schema.sql # Generated PostgreSQL DDL
1129
- ├── .env # DATABASE_URL, JWT_* settings
1130
- ├── docker-compose.yml # Local PostgreSQL (optional)
1131
- ├── tsconfig.json
1132
- ├── package.json # schematic-pg + hono + pg + zod
1133
- ├── generated/
1134
- │ ├── db.ts # createDbClient(pool) factory
1135
- │ ├── db-types.ts # Generated model + input interfaces
1136
- │ ├── db-model-meta.ts # Runtime column metadata
1137
- │ ├── app.ts # Hono entry point (starts server on :3000)
1138
- │ ├── policies.ts # Generated ACL metadata from @policy
1139
- │ ├── routes/
1140
- │ │ ├── users.ts
1141
- │ │ ├── profiles.ts
1142
- │ │ └── ...
1143
- │ └── schemas/
1144
- │ └── validation.ts # Generated Zod schemas
1145
- └── src/
1146
- └── routes/
1147
- └── health.ts # Custom route → GET /health
1148
- ```
1149
-
1150
- Framework runtime (query builder, auth middleware, validation) is **not** copied into your project — it is imported from `node_modules/schematic-pg` at runtime. Only `generated/` and `src/routes/` contain project-specific code.
1151
-
1152
- ### This repository (framework source)
1153
-
1154
- ```
1155
- postgrest.js/
1156
- ├── src/
1157
- │ ├── schema-dsl/ # Lexer, parser, AST
1158
- │ ├── sql-generator/ # DDL + migration planner
1159
- │ ├── db/ # Query builder + client runtime + include eager-loading
1160
- │ ├── api/ # Hono runtime (published as schematic-pg/api/*)
1161
- │ ├── api-generator/ # AST → routes, Zod, policies, app
1162
- │ ├── cli/ # init templates + command helpers
1163
- │ └── cli.ts # schematic-pg CLI entry point
1164
- ├── dist/ # Compiled output (npm publish target)
1165
- ├── generated/ # Sample output from app.schema (this repo)
1166
- ├── app.schema # Sample schema
1167
- └── editors/ # VS Code extension + language server
468
+ schematic-pg --help
1168
469
  ```
1169
470
 
1170
471
  ---
1171
472
 
1172
- ## Why schematic-pg?
1173
-
1174
- | Concern | ORM Approach | schematic-pg Approach |
1175
- |---------|-----------|----------------------|
1176
- | Schema truth | Migrations + models + Zod + routes | One `.schema` file |
1177
- | Query visibility | Hidden behind ORM methods | Raw, parameterized SQL |
1178
- | Client ergonomics | ORM model API | Generated Prisma-like client, no ORM runtime |
1179
- | Performance | N+1, lazy loading pitfalls | Explicit `include`; batched split queries by default |
1180
- | ACL | External service or manual checks | Inline `@policy` directives |
1181
- | Validation | Separate Zod schemas | Derived from `@regex` / `@range` |
1182
- | Dependencies | Heavy (Prisma, Drizzle, etc.) | Hono + pg + Zod + hand-written parser |
1183
-
1184
- ---
1185
-
1186
- ## Roadmap
1187
-
1188
- - [x] npm package + CLI (`schematic-pg init`, `generate`, `dev`, `db:*`)
1189
- - [x] Hand-written lexer & recursive-descent parser
1190
- - [x] SQL DDL generator (full regeneration)
1191
- - [x] Type-safe database client generator (`createDbClient`, parameterized query builder)
1192
- - [x] Diff-based migration planner
1193
- - [x] Hono route generator with Zod validation
1194
- - [x] Static ACL middleware generation (`@policy` → `assertPolicy` in routes)
1195
- - [x] Row-level policy injection (`WHERE` clause from `where:` templates)
1196
- - [x] JWT authentication (default Bearer resolver, pluggable `AuthResolver`)
1197
- - [x] Custom routes (`src/routes/` auto-imported into generated app)
1198
- - [x] Relation `include` in DB client (nested eager-loading, split + json_agg strategies)
1199
- - [ ] Type generation for frontend consumption
1200
- - [ ] Tree-sitter grammar for editor support
1201
- - [x] VS Code extension with syntax highlighting and language server
1202
- - [ ] URL query-string filters for `findMany` (e.g. `?role=ADMIN`)
1203
-
1204
- ---
1205
-
1206
473
  ## License
1207
474
 
1208
475
  MIT