schematic-pg 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1019 -0
  3. package/dist/api/auth/errors.d.ts +6 -0
  4. package/dist/api/auth/errors.js +12 -0
  5. package/dist/api/auth/jwt-resolver.d.ts +7 -0
  6. package/dist/api/auth/jwt-resolver.js +59 -0
  7. package/dist/api/auth/middleware.d.ts +4 -0
  8. package/dist/api/auth/middleware.js +10 -0
  9. package/dist/api/auth/policy.d.ts +7 -0
  10. package/dist/api/auth/policy.js +95 -0
  11. package/dist/api/auth/template.d.ts +2 -0
  12. package/dist/api/auth/template.js +24 -0
  13. package/dist/api/auth/types.d.ts +12 -0
  14. package/dist/api/auth/types.js +1 -0
  15. package/dist/api/middleware/db.d.ts +8 -0
  16. package/dist/api/middleware/db.js +12 -0
  17. package/dist/api/middleware/errors.d.ts +5 -0
  18. package/dist/api/middleware/errors.js +27 -0
  19. package/dist/api/middleware/validate.d.ts +23 -0
  20. package/dist/api/middleware/validate.js +13 -0
  21. package/dist/api/types.d.ts +8 -0
  22. package/dist/api/types.js +1 -0
  23. package/dist/api/utils/route-naming.d.ts +3 -0
  24. package/dist/api/utils/route-naming.js +25 -0
  25. package/dist/api-generator/app-generator.d.ts +11 -0
  26. package/dist/api-generator/app-generator.js +79 -0
  27. package/dist/api-generator/custom-route-scanner.d.ts +6 -0
  28. package/dist/api-generator/custom-route-scanner.js +42 -0
  29. package/dist/api-generator/generate-api-cli.d.ts +1 -0
  30. package/dist/api-generator/generate-api-cli.js +28 -0
  31. package/dist/api-generator/index.d.ts +11 -0
  32. package/dist/api-generator/index.js +15 -0
  33. package/dist/api-generator/policy-generator.d.ts +9 -0
  34. package/dist/api-generator/policy-generator.js +33 -0
  35. package/dist/api-generator/route-generator.d.ts +20 -0
  36. package/dist/api-generator/route-generator.js +198 -0
  37. package/dist/api-generator/utils/policy.d.ts +18 -0
  38. package/dist/api-generator/utils/policy.js +72 -0
  39. package/dist/api-generator/zod-schema-generator.d.ts +16 -0
  40. package/dist/api-generator/zod-schema-generator.js +145 -0
  41. package/dist/cli/db.d.ts +4 -0
  42. package/dist/cli/db.js +144 -0
  43. package/dist/cli/dev.d.ts +1 -0
  44. package/dist/cli/dev.js +10 -0
  45. package/dist/cli/generate.d.ts +4 -0
  46. package/dist/cli/generate.js +50 -0
  47. package/dist/cli/init.d.ts +1 -0
  48. package/dist/cli/init.js +57 -0
  49. package/dist/cli/paths.d.ts +5 -0
  50. package/dist/cli/paths.js +10 -0
  51. package/dist/cli/templates.d.ts +6 -0
  52. package/dist/cli/templates.js +85 -0
  53. package/dist/cli.d.ts +2 -0
  54. package/dist/cli.js +81 -0
  55. package/dist/constants.d.ts +1 -0
  56. package/dist/constants.js +1 -0
  57. package/dist/db/bootstrap-cli.d.ts +1 -0
  58. package/dist/db/bootstrap-cli.js +17 -0
  59. package/dist/db/bootstrap.d.ts +3 -0
  60. package/dist/db/bootstrap.js +16 -0
  61. package/dist/db/cli.d.ts +1 -0
  62. package/dist/db/cli.js +20 -0
  63. package/dist/db/client.d.ts +8 -0
  64. package/dist/db/client.js +23 -0
  65. package/dist/db/config.d.ts +1 -0
  66. package/dist/db/config.js +10 -0
  67. package/dist/db/db-client-generator.d.ts +13 -0
  68. package/dist/db/db-client-generator.js +70 -0
  69. package/dist/db/diff-cli.d.ts +1 -0
  70. package/dist/db/diff-cli.js +46 -0
  71. package/dist/db/diff.d.ts +9 -0
  72. package/dist/db/diff.js +30 -0
  73. package/dist/db/errors.d.ts +34 -0
  74. package/dist/db/errors.js +88 -0
  75. package/dist/db/generate-client-cli.d.ts +1 -0
  76. package/dist/db/generate-client-cli.js +21 -0
  77. package/dist/db/index.d.ts +19 -0
  78. package/dist/db/index.js +17 -0
  79. package/dist/db/load-env.d.ts +3 -0
  80. package/dist/db/load-env.js +19 -0
  81. package/dist/db/migrate-cli.d.ts +1 -0
  82. package/dist/db/migrate-cli.js +88 -0
  83. package/dist/db/migrate.d.ts +3 -0
  84. package/dist/db/migrate.js +32 -0
  85. package/dist/db/migrations.d.ts +17 -0
  86. package/dist/db/migrations.js +81 -0
  87. package/dist/db/model-client.d.ts +36 -0
  88. package/dist/db/model-client.js +83 -0
  89. package/dist/db/model-meta.d.ts +36 -0
  90. package/dist/db/model-meta.js +57 -0
  91. package/dist/db/query-builder.d.ts +33 -0
  92. package/dist/db/query-builder.js +97 -0
  93. package/dist/db/reset-database.d.ts +4 -0
  94. package/dist/db/reset-database.js +9 -0
  95. package/dist/db/row-mapper.d.ts +3 -0
  96. package/dist/db/row-mapper.js +41 -0
  97. package/dist/db/schema-state.d.ts +7 -0
  98. package/dist/db/schema-state.js +32 -0
  99. package/dist/db/type-generator.d.ts +19 -0
  100. package/dist/db/type-generator.js +136 -0
  101. package/dist/db/utils/naming.d.ts +6 -0
  102. package/dist/db/utils/naming.js +23 -0
  103. package/dist/db/where-translator.d.ts +20 -0
  104. package/dist/db/where-translator.js +141 -0
  105. package/dist/index.d.ts +1 -0
  106. package/dist/index.js +1 -0
  107. package/dist/routes/health.d.ts +4 -0
  108. package/dist/routes/health.js +4 -0
  109. package/dist/schema-dsl/ast.d.ts +108 -0
  110. package/dist/schema-dsl/ast.js +1 -0
  111. package/dist/schema-dsl/cli.d.ts +1 -0
  112. package/dist/schema-dsl/cli.js +25 -0
  113. package/dist/schema-dsl/index.d.ts +8 -0
  114. package/dist/schema-dsl/index.js +16 -0
  115. package/dist/schema-dsl/inspect.d.ts +1 -0
  116. package/dist/schema-dsl/inspect.js +9 -0
  117. package/dist/schema-dsl/lexer.d.ts +31 -0
  118. package/dist/schema-dsl/lexer.js +216 -0
  119. package/dist/schema-dsl/parser.d.ts +49 -0
  120. package/dist/schema-dsl/parser.js +372 -0
  121. package/dist/schema-dsl/tokens.d.ts +30 -0
  122. package/dist/schema-dsl/tokens.js +35 -0
  123. package/dist/sql-generator/cli.d.ts +1 -0
  124. package/dist/sql-generator/cli.js +7 -0
  125. package/dist/sql-generator/generators/drop-tables.d.ts +2 -0
  126. package/dist/sql-generator/generators/drop-tables.js +8 -0
  127. package/dist/sql-generator/generators/enums.d.ts +4 -0
  128. package/dist/sql-generator/generators/enums.js +16 -0
  129. package/dist/sql-generator/generators/extensions.d.ts +4 -0
  130. package/dist/sql-generator/generators/extensions.js +11 -0
  131. package/dist/sql-generator/generators/foreign-keys.d.ts +4 -0
  132. package/dist/sql-generator/generators/foreign-keys.js +23 -0
  133. package/dist/sql-generator/generators/indexes.d.ts +13 -0
  134. package/dist/sql-generator/generators/indexes.js +39 -0
  135. package/dist/sql-generator/generators/tables.d.ts +4 -0
  136. package/dist/sql-generator/generators/tables.js +65 -0
  137. package/dist/sql-generator/generators/triggers.d.ts +6 -0
  138. package/dist/sql-generator/generators/triggers.js +47 -0
  139. package/dist/sql-generator/index.d.ts +5 -0
  140. package/dist/sql-generator/index.js +3 -0
  141. package/dist/sql-generator/migration-planner.d.ts +15 -0
  142. package/dist/sql-generator/migration-planner.js +207 -0
  143. package/dist/sql-generator/migration-sql-generator.d.ts +9 -0
  144. package/dist/sql-generator/migration-sql-generator.js +181 -0
  145. package/dist/sql-generator/migration-types.d.ts +86 -0
  146. package/dist/sql-generator/migration-types.js +1 -0
  147. package/dist/sql-generator/sql-generator.d.ts +6 -0
  148. package/dist/sql-generator/sql-generator.js +26 -0
  149. package/dist/sql-generator/utils/ast-helpers.d.ts +58 -0
  150. package/dist/sql-generator/utils/ast-helpers.js +252 -0
  151. package/dist/sql-generator/utils/format.d.ts +2 -0
  152. package/dist/sql-generator/utils/format.js +21 -0
  153. package/dist/sql-generator/utils/snake-case.d.ts +3 -0
  154. package/dist/sql-generator/utils/snake-case.js +96 -0
  155. package/dist/sql-generator/utils/type-mapper.d.ts +2 -0
  156. package/dist/sql-generator/utils/type-mapper.js +39 -0
  157. package/dist/sql-generator/utils/value-formatter.d.ts +4 -0
  158. package/dist/sql-generator/utils/value-formatter.js +41 -0
  159. package/dist/types/generated-db.stub.d.ts +2 -0
  160. package/dist/types/generated-db.stub.js +3 -0
  161. package/dist/types/generated-policies.stub.d.ts +7 -0
  162. package/dist/types/generated-policies.stub.js +1 -0
  163. package/package.json +86 -0
package/README.md ADDED
@@ -0,0 +1,1019 @@
1
+ # schematic-pg
2
+
3
+ > A single-file backend framework for PostgreSQL and Node.js. Define your database schema, ACL policies, and validations in one declarative DSL — then generate the SQL, the API, and the types.
4
+
5
+ ---
6
+
7
+ ## Philosophy
8
+
9
+ 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.
10
+
11
+ - **One file.** Schema, relations, triggers, indexes, and ACL in a single `.schema` file.
12
+ - **Zero ORM.** We generate raw PostgreSQL and parameterized queries. No hidden query builders, no N+1 surprises.
13
+ - **Hand-written parser.** A small, fast recursive-descent lexer/parser with zero parser-generator dependencies.
14
+ - **Hono-based runtime.** Lightweight HTTP handlers generated from your schema, with Zod validation on every write.
15
+
16
+ ---
17
+
18
+ ## Features
19
+
20
+ - **Declarative Schema DSL** — PostgreSQL-native types, enums, extensions, indexes, and triggers
21
+ - **Automatic SQL Generation** — idempotent DDL with snake_case naming conventions
22
+ - **Type-safe Database Client** — Prisma-like query API over parameterized raw SQL (`pg` Pool, no ORM)
23
+ - **Type-safe REST API** — Hono routes with generated Zod validation
24
+ - **Custom routes** — Hand-written Hono routers in `src/routes/` auto-imported into the generated app
25
+ - **Inline ACL** — Row-level and role-based access control via `@policy` directives, enforced at runtime in generated routes
26
+ - **Validation Rules** — `@regex` and `@range` constraints that flow into generated Zod request validators (with custom error messages from the schema)
27
+ - **Migration Ready** — Full regeneration today, diff-based migrations tomorrow
28
+
29
+ ---
30
+
31
+ ## The DSL
32
+
33
+ ```ts
34
+ extensions {
35
+ pgcrypto { version: "1.3" }
36
+ postgis
37
+ uuid-ossp
38
+ }
39
+
40
+ enums {
41
+ UserRole { ADMIN, USER, PUBLIC }
42
+ OrderStatus { PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED }
43
+ }
44
+
45
+ models {
46
+
47
+ model User {
48
+ id: UUID @id @default(gen_random_uuid())
49
+ email: VARCHAR(255) @unique
50
+ name: VARCHAR(150)
51
+ role: UserRole @default(USER)
52
+ age: SMALLINT?
53
+ balance: INTEGER
54
+ isActive: BOOLEAN @default(true)
55
+ createdAt: TIMESTAMP @default(now())
56
+ updatedAt: TIMESTAMP?
57
+
58
+ profile: Profile? @relation(name: "UserProfile")
59
+ orders: Order[]
60
+
61
+ @policy(role: USER, allow: [select, insert, update], where: "id = {{auth.user.id}}")
62
+ @policy(role: ADMIN, allow: all)
63
+
64
+ @@index(fields: [role, isActive])
65
+ @@index(fields: [name], where: "isActive = true", name: "active_users_name_idx", type: BTREE)
66
+
67
+ @@trigger {
68
+ timing: BEFORE,
69
+ event: UPDATE,
70
+ level: ROW,
71
+ execute: """
72
+ IF (OLD.balance <> NEW.balance) THEN
73
+ RAISE EXCEPTION 'Balance cannot be updated directly';
74
+ END IF;
75
+ RETURN NEW;
76
+ """
77
+ }
78
+ }
79
+
80
+ model Profile {
81
+ id: UUID @id @default(gen_random_uuid())
82
+ userId: UUID @unique
83
+ bio: TEXT
84
+ avatar: VARCHAR(255)
85
+ location: POINT
86
+
87
+ user: User @relation(
88
+ name: "UserProfile",
89
+ fields: [userId],
90
+ references: [id],
91
+ onDelete: CASCADE,
92
+ onUpdate: SET_NULL
93
+ )
94
+ }
95
+
96
+ model Order {
97
+ id: UUID @id @default(gen_random_uuid())
98
+ userId: UUID
99
+ status: OrderStatus @default(PENDING)
100
+ totalAmount: DECIMAL(10, 2)
101
+ items: JSONB
102
+ createdAt: TIMESTAMP @default(now())
103
+ updatedAt: TIMESTAMP?
104
+
105
+ user: User @relation(fields: [userId], references: [id])
106
+ products: ProductOrder[]
107
+
108
+ @@index(fields: [userId])
109
+ @@index(fields: [status, createdAt], name: "order_status_created_idx")
110
+ }
111
+
112
+ model Product {
113
+ id: UUID @id @default(gen_random_uuid())
114
+ name: VARCHAR(255)
115
+ description: TEXT
116
+ price: DECIMAL(10, 2) @range(min: 0.01, max: 999999.99)
117
+ stock: INTEGER @range(min: 0)
118
+ category: VARCHAR(100)
119
+ tags: TEXT[]
120
+ metadata: JSONB
121
+ createdAt: TIMESTAMP @default(now())
122
+ updatedAt: TIMESTAMP?
123
+
124
+ orders: ProductOrder[]
125
+
126
+ @@trigger {
127
+ timing: AFTER,
128
+ event: UPDATE,
129
+ level: ROW,
130
+ execute: """
131
+ IF (OLD.stock <> NEW.stock) THEN
132
+ INSERT INTO log (message) VALUES ('Product stock changed');
133
+ END IF;
134
+ RETURN NEW;
135
+ """
136
+ }
137
+ }
138
+
139
+ model ProductOrder {
140
+ id: SERIAL
141
+ orderId: UUID
142
+ productId: UUID
143
+ quantity: INTEGER
144
+ price: DECIMAL(10, 2)
145
+
146
+ order: Order @relation(fields: [orderId], references: [id])
147
+ product: Product @relation(fields: [productId], references: [id])
148
+
149
+ @@id(fields: [orderId, productId])
150
+ }
151
+
152
+ }
153
+ ```
154
+
155
+ ---
156
+
157
+ ## How It Works
158
+
159
+ ```
160
+ ┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐
161
+ │ schema.dsl │────▶│ Lexer + │────▶│ AST │
162
+ │ (your source) │ │ Parser │ │ (typed nodes) │
163
+ └─────────────────┘ └──────────────┘ └────────┬─────────┘
164
+
165
+ ┌──────────────────────────────────────────┼──────────┐
166
+ │ │ │
167
+ ▼ ▼ ▼
168
+ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
169
+ │ SQL DDL │ │ DB Client │ │ Hono Routes │
170
+ │ Generator │ │ Generator │ │ Generator │
171
+ └─────────────┘ └──────────────┘ └─────────────┘
172
+ │ │ │
173
+ ▼ ▼ ▼
174
+ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
175
+ │ schema.sql │ │ generated/ │ │ Hono Routes │
176
+ │ (PostgreSQL)│ │ db.ts, types │ │ + policies │
177
+ └─────────────┘ └──────────────┘ └─────────────┘
178
+ ```
179
+
180
+ 1. **Parse** — The hand-written lexer and recursive-descent parser turn your `.schema` file into a typed AST.
181
+ 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`.
182
+ 3. **Generate DB client** — The client generator emits TypeScript interfaces and a `createDbClient(pool)` factory with per-model CRUD methods backed by a runtime query builder. All SQL uses `$1`, `$2`, … placeholders — user input is never interpolated.
183
+ 4. **Generate API** — The route generator emits Hono routers with:
184
+ - Zod-validated request bodies and path params (driven by `@regex` and `@range`)
185
+ - Full CRUD handlers backed by the generated DB client
186
+ - Role-based ACL enforcement (driven by `@policy`) with row-level `WHERE` injection
187
+ - Pluggable authentication middleware (default: Bearer JWT)
188
+ 5. **Run** — `generated/app.ts` mounts all routers and starts a Node.js server. You get a validated REST API in seconds.
189
+
190
+ ---
191
+
192
+ ## Quick Start
193
+
194
+ Install the CLI and scaffold a new project:
195
+
196
+ ```bash
197
+ npx schematic-pg init my-app
198
+ cd my-app
199
+ npm install
200
+ ```
201
+
202
+ Edit `app.schema`, then generate code and start the API:
203
+
204
+ ```bash
205
+ # Start PostgreSQL (PostGIS-enabled, matches .env defaults)
206
+ docker compose up -d
207
+
208
+ # Generate schema.sql + generated/ (db client, routes, policies, Zod schemas)
209
+ npx schematic-pg generate
210
+
211
+ # Apply DDL to the database and snapshot schema state
212
+ npx schematic-pg db:bootstrap
213
+
214
+ # Regenerate client + API and start the server
215
+ npx schematic-pg dev
216
+ # → http://localhost:3000
217
+ ```
218
+
219
+ The `init` command creates everything you need to get running:
220
+
221
+ | File / directory | Purpose |
222
+ |------------------|---------|
223
+ | `app.schema` | Starter schema (one `User` model) — edit this |
224
+ | `.env` | `DATABASE_URL`, JWT settings |
225
+ | `docker-compose.yml` | Local PostGIS PostgreSQL on `:5432` |
226
+ | `tsconfig.json` | TypeScript config for `generated/` and `src/routes/` |
227
+ | `package.json` | `schematic-pg` + runtime deps (`hono`, `pg`, `zod`, …) |
228
+ | `src/routes/health.ts` | Example custom route mounted at `/health` |
229
+
230
+ After `generate`, your project also contains:
231
+
232
+ | Output | Purpose |
233
+ |--------|---------|
234
+ | `schema.sql` | Idempotent PostgreSQL DDL |
235
+ | `generated/db*.ts` | Type-safe DB client |
236
+ | `generated/app.ts` | Hono server entry point |
237
+ | `generated/routes/*.ts` | CRUD routers per model |
238
+ | `generated/policies.ts` | ACL metadata from `@policy` |
239
+ | `generated/schemas/validation.ts` | Zod request validators |
240
+
241
+ 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.
242
+
243
+ ### Environment variables
244
+
245
+ | Variable | Default | Purpose |
246
+ |----------|---------|---------|
247
+ | `DATABASE_URL` | — (required) | PostgreSQL connection string |
248
+ | `PORT` | `3000` | HTTP listen port |
249
+ | `JWT_SECRET` | — | HMAC secret for the default Bearer JWT resolver |
250
+ | `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
251
+ | `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
252
+
253
+ Set these in `.env` before running `db:bootstrap` or `dev`.
254
+
255
+ ---
256
+
257
+ ## CLI Reference
258
+
259
+ The `schematic-pg` binary is the primary interface. Each command accepts an optional path to a schema file (defaults to `app.schema` in the current directory).
260
+
261
+ ### Project setup
262
+
263
+ ```bash
264
+ schematic-pg init [dir] # Scaffold a new project (default: current directory)
265
+ ```
266
+
267
+ ### Code generation
268
+
269
+ ```bash
270
+ schematic-pg generate [schema] # schema.sql + db client + API (all three)
271
+ schematic-pg generate:sql [schema] # SQL DDL to stdout
272
+ schematic-pg generate:client [schema] # generated/db*.ts only
273
+ schematic-pg generate:api [schema] # generated/app.ts, routes/, policies, schemas
274
+ ```
275
+
276
+ Run `generate:client` before `generate:api` when using the split commands — routes depend on `generated/db.ts`.
277
+
278
+ ### Development server
279
+
280
+ ```bash
281
+ schematic-pg dev [schema] # generate:client + generate:api, then start generated/app.ts
282
+ ```
283
+
284
+ Equivalent npm scripts in a project created by `init`:
285
+
286
+ ```bash
287
+ npm run dev # schematic-pg dev
288
+ npm run generate # schematic-pg generate
289
+ ```
290
+
291
+ ### Database commands
292
+
293
+ ```bash
294
+ schematic-pg db:ping [schema] # Test DATABASE_URL connection (SELECT 1)
295
+ schematic-pg db:bootstrap [schema] # Apply DDL from schema + write .schema-state snapshot
296
+ schematic-pg db:diff [schema] # Print pending schema changes (snapshot vs app.schema)
297
+ schematic-pg db:diff --name add_users # Write a migration file under migrations/
298
+ schematic-pg db:migrate [schema] # Apply pending migration files
299
+ schematic-pg db:migrate:status [schema] # Show snapshot + migration file status
300
+ ```
301
+
302
+ `db:bootstrap` is the recommended first-time setup. Use `db:diff` / `db:migrate` when evolving an existing database.
303
+
304
+ Alternatively, apply SQL manually:
305
+
306
+ ```bash
307
+ psql $DATABASE_URL -f schema.sql
308
+ ```
309
+
310
+ ### Help
311
+
312
+ ```bash
313
+ schematic-pg --help
314
+ ```
315
+
316
+ ---
317
+
318
+ ## Local development (this repo)
319
+
320
+ Contributors working on the framework itself clone the repo and use npm scripts (which delegate to the same CLI via `tsx`):
321
+
322
+ ```bash
323
+ cp .env.example .env # configure DATABASE_URL
324
+ npm run build # compile src/ → dist/ (required for schematic-pg/* imports)
325
+ npm run docker:up # PostGIS-enabled PostgreSQL on :5432
326
+ npm run generate # write schema.sql from app.schema
327
+ npm run generate:client # write generated/db*.ts
328
+ npm run generate:api # write generated/app.ts, routes/, schemas/
329
+ npm run db:bootstrap # apply DDL + snapshot schema state
330
+ npm run dev:api # regenerate client + API and start server on :3000
331
+ npm test # unit tests
332
+ npm run test:integration # Docker + generate + DB client + ACL integration tests
333
+ ```
334
+
335
+ ---
336
+
337
+ ## Database Client
338
+
339
+ 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.
340
+
341
+ ### Generate
342
+
343
+ ```bash
344
+ npx schematic-pg generate:client
345
+ # or: npm run generate:client (inside a scaffolded project)
346
+ ```
347
+
348
+ Outputs:
349
+
350
+ | File | Purpose |
351
+ |------|---------|
352
+ | `generated/db-types.ts` | Per-model interfaces: `User`, `UserCreateInput`, `UserUpdateInput`, `UserWhereInput`, `UserOrderByInput`, enum unions |
353
+ | `generated/db-model-meta.ts` | Serialized field/column metadata consumed at runtime |
354
+ | `generated/db.ts` | `createDbClient(pool)` factory wiring all models |
355
+
356
+ ### Usage
357
+
358
+ ```typescript
359
+ import { Pool } from 'pg';
360
+ import { createDbClient } from './generated/db.js';
361
+
362
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
363
+ const db = createDbClient(pool);
364
+
365
+ // Create
366
+ const user = await db.user.create({
367
+ email: 'a@b.com',
368
+ name: 'Alice',
369
+ balance: 0,
370
+ });
371
+
372
+ // Read
373
+ const one = await db.user.findUnique({ id: user.id });
374
+ const first = await db.user.findFirst({
375
+ where: { role: 'ADMIN' },
376
+ orderBy: { createdAt: 'desc' },
377
+ });
378
+ const many = await db.user.findMany({
379
+ where: { role: { in: ['ADMIN', 'USER'] }, isActive: true },
380
+ orderBy: [{ role: 'asc' }, { createdAt: 'desc' }],
381
+ take: 10,
382
+ skip: 0,
383
+ });
384
+ const total = await db.user.count({ where: { role: 'ADMIN' } });
385
+
386
+ // Update
387
+ const updated = await db.user.update({
388
+ where: { id: user.id },
389
+ data: { name: 'Bob' },
390
+ });
391
+ const { count } = await db.user.updateMany({
392
+ where: { isActive: false },
393
+ data: { name: 'Inactive' },
394
+ });
395
+
396
+ // Delete
397
+ const deleted = await db.user.delete({ id: user.id });
398
+ await db.user.deleteMany({ where: { role: 'PUBLIC' } });
399
+ ```
400
+
401
+ ### Per-model API
402
+
403
+ Each model in `app.schema` becomes a camelCase property on the client (`User` → `db.user`, `ProductOrder` → `db.productOrder`) with these methods:
404
+
405
+ | Method | SQL shape |
406
+ |--------|-----------|
407
+ | `create(data)` | `INSERT INTO … VALUES ($1, …) RETURNING *` |
408
+ | `findUnique(where)` | `SELECT * … WHERE … LIMIT 1` |
409
+ | `findFirst({ where, orderBy })` | `SELECT * … ORDER BY … LIMIT 1` |
410
+ | `findMany({ where, orderBy, take, skip })` | `SELECT * … ORDER BY … LIMIT … OFFSET …` |
411
+ | `count({ where })` | `SELECT COUNT(*) …` |
412
+ | `update({ where, data })` | `UPDATE … SET … WHERE … RETURNING *` |
413
+ | `updateMany({ where, data })` | `UPDATE … SET … WHERE … RETURNING *` |
414
+ | `delete(where)` | `DELETE … WHERE … RETURNING *` |
415
+ | `deleteMany({ where })` | `DELETE … WHERE … RETURNING *` |
416
+
417
+ Mutations return the full row (`RETURNING *`). Rows are mapped from `snake_case` columns to `camelCase` TypeScript fields.
418
+
419
+ ### Where filters
420
+
421
+ Direct values are treated as equality. Structured operators are supported per field type:
422
+
423
+ ```typescript
424
+ // Equality shorthand
425
+ { email: 'a@b.com' }
426
+
427
+ // Explicit operators
428
+ { email: { equals: 'a@b.com' } }
429
+ { email: { contains: '@' } } // LIKE %@%
430
+ { email: { startsWith: 'a' } } // LIKE a%
431
+ { email: { endsWith: '.com' } } // LIKE %.com
432
+ { balance: { gt: 100 } }
433
+ { balance: { lte: 500 } }
434
+ { role: { in: ['ADMIN', 'USER'] } }
435
+
436
+ // Logical groups
437
+ {
438
+ AND: [{ role: 'USER' }, { isActive: true }],
439
+ OR: [{ role: 'ADMIN' }, { role: 'PUBLIC' }],
440
+ NOT: { isActive: false },
441
+ }
442
+ ```
443
+
444
+ ### Naming and types
445
+
446
+ - **API**: camelCase field names (`createdAt`, `userId`)
447
+ - **SQL**: snake_case columns (`created_at`, `user_id`); reserved table names like `user` and `order` are quoted
448
+ - **Runtime mapping**:
449
+
450
+ | Schema type | TypeScript |
451
+ |-------------|------------|
452
+ | `UUID`, `VARCHAR`, `TEXT` | `string` |
453
+ | `INTEGER`, `SERIAL`, `SMALLINT` | `number` |
454
+ | `BOOLEAN` | `boolean` |
455
+ | `TIMESTAMP` | `Date` |
456
+ | `DECIMAL` | `string` (avoids float precision loss) |
457
+ | `JSONB` | `Record<string, unknown>` |
458
+ | `TEXT[]` | `string[]` |
459
+ | Enums | string literal union |
460
+ | Optional (`?`) | `T \| null` |
461
+
462
+ Fields with `@default` are optional on `CreateInput`. `@id` fields are omitted from create input when the database generates them.
463
+
464
+ ### Error handling
465
+
466
+ PostgreSQL errors are mapped to typed exceptions:
467
+
468
+ | Class | PG code | When |
469
+ |-------|---------|------|
470
+ | `UniqueConstraintError` | `23505` | Duplicate unique column (includes `fields: string[]`) |
471
+ | `ForeignKeyConstraintError` | `23503` | Invalid relation reference |
472
+ | `NotFoundError` | — | Optional helper for missing records |
473
+ | `DatabaseError` | other | Generic wrapper with `code`, `detail`, `constraint` |
474
+
475
+ ```typescript
476
+ import { UniqueConstraintError } from 'schematic-pg/db/errors';
477
+
478
+ try {
479
+ await db.user.create({ email: 'taken@b.com', name: 'X', balance: 0 });
480
+ } catch (error) {
481
+ if (error instanceof UniqueConstraintError) {
482
+ console.log(error.fields); // ['email']
483
+ }
484
+ }
485
+ ```
486
+
487
+ ### Runtime architecture
488
+
489
+ 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/`:
490
+
491
+ ```
492
+ schematic-pg/dist/db/ # Published runtime (import as schematic-pg/db/*)
493
+ ├── query-builder.ts # INSERT / SELECT / UPDATE / DELETE / COUNT
494
+ ├── where-translator.ts # WhereInput → SQL + params
495
+ ├── model-client.ts # createModelClient factory
496
+ ├── row-mapper.ts # snake_case rows → camelCase + coercion
497
+ └── errors.ts # UniqueConstraintError, ForeignKeyConstraintError, …
498
+ ```
499
+
500
+ Relation `include` (e.g. `findMany({ include: { profile: true } })`) is planned for a future release.
501
+
502
+ ### Integration tests
503
+
504
+ One command starts Docker Postgres, generates the client and API, and runs all integration tests (DB client + ACL):
505
+
506
+ ```bash
507
+ npm run test:integration
508
+ ```
509
+
510
+ This resets the `public` schema, bootstraps from `app.schema`, seeds test data, and exercises:
511
+
512
+ - **DB client** — CRUD, filters, and error handling ([`src/db/__tests__/db-client.integration.test.ts`](src/db/__tests__/db-client.integration.test.ts))
513
+ - **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))
514
+
515
+ Tests run in-process via Hono `app.request()` against the exported `createApp()` factory from `generated/app.ts`.
516
+
517
+ ---
518
+
519
+ ## REST API
520
+
521
+ 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.
522
+
523
+ ### Generate
524
+
525
+ ```bash
526
+ npx schematic-pg generate:api
527
+ # or: npm run generate:api
528
+ ```
529
+
530
+ Requires `generate:client` first (routes call `createDbClient` from `generated/db.ts`). Use `npx schematic-pg generate` to run both.
531
+
532
+ Outputs:
533
+
534
+ | File | Purpose |
535
+ |------|---------|
536
+ | `generated/app.ts` | Hono app entry point — mounts routers, auth + DB middleware, starts the server |
537
+ | `generated/policies.ts` | Per-model ACL metadata derived from `@policy` attributes |
538
+ | `generated/schemas/validation.ts` | Per-model Zod schemas: `{Model}CreateSchema`, `{Model}UpdateSchema`, `{Model}ParamSchema` |
539
+ | `generated/routes/*.ts` | One Hono router per model with GET / POST / PUT / DELETE handlers |
540
+ | `src/routes/*.ts` | *(hand-written)* Custom Hono routers auto-imported into `generated/app.ts` on each `generate:api` run |
541
+
542
+ ### Start the server
543
+
544
+ ```bash
545
+ npx schematic-pg dev
546
+ # or: npm run dev
547
+ # → regenerates client + API, then starts http://localhost:3000
548
+ ```
549
+
550
+ Or run the generated entry point directly after generation:
551
+
552
+ ```bash
553
+ npx tsx generated/app.ts
554
+ ```
555
+
556
+ Environment variables (also see [Quick Start](#quick-start)):
557
+
558
+ | Variable | Default | Purpose |
559
+ |----------|---------|---------|
560
+ | `DATABASE_URL` | — (required) | PostgreSQL connection string (loaded from `.env`) |
561
+ | `PORT` | `3000` | HTTP listen port |
562
+ | `JWT_SECRET` | — | HMAC secret for the default Bearer JWT resolver |
563
+ | `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
564
+ | `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
565
+
566
+ 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')`).
567
+
568
+ ### Routes
569
+
570
+ 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.
571
+
572
+ | Model | Base path | Primary key route |
573
+ |-------|-----------|-------------------|
574
+ | `User` | `/users` | `/users/:id` |
575
+ | `Profile` | `/profiles` | `/profiles/:id` |
576
+ | `Order` | `/orders` | `/orders/:id` |
577
+ | `Log` | `/logs` | `/logs/:id` |
578
+ | `Product` | `/products` | `/products/:id` |
579
+ | `ProductOrder` | `/product-orders` | `/product-orders/:orderId/:productId` |
580
+
581
+ Models with composite primary keys (`@@id(fields: [...])`) expose one path segment per key field.
582
+
583
+ ### Custom routes
584
+
585
+ 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.
586
+
587
+ **Convention**
588
+
589
+ | Rule | Example |
590
+ |------|---------|
591
+ | Location | `src/routes/**/*.ts` |
592
+ | Export | `export default router` where `router` is `Hono<AppEnv>` |
593
+ | Mount path | File path relative to `src/routes/`, without extension |
594
+ | Regenerate | `schematic-pg generate:api` or `schematic-pg dev` after adding or renaming files |
595
+
596
+ **Path mapping**
597
+
598
+ | File | Mounted at |
599
+ |------|------------|
600
+ | `src/routes/health.ts` | `/health` |
601
+ | `src/routes/webhooks/stripe.ts` | `/webhooks/stripe` |
602
+
603
+ **Example** — `src/routes/health.ts`:
604
+
605
+ ```typescript
606
+ import { Hono } from 'hono';
607
+ import type { AppEnv } from 'schematic-pg/api/types';
608
+
609
+ const router = new Hono<AppEnv>();
610
+
611
+ router.get('/', (c) => c.json({ ok: true }));
612
+
613
+ export default router;
614
+ ```
615
+
616
+ After `schematic-pg generate:api`, `generated/app.ts` includes:
617
+
618
+ ```typescript
619
+ import healthRouter from '../src/routes/health.js';
620
+ // ...
621
+ app.route('/health', healthRouter);
622
+ ```
623
+
624
+ Custom routes are mounted **after** all schema-generated routers. Handlers can use the same request context as generated routes:
625
+
626
+ ```typescript
627
+ router.get('/me', async (c) => {
628
+ const db = c.get('db');
629
+ const auth = c.get('auth');
630
+ // ...
631
+ });
632
+ ```
633
+
634
+ **Skipped files** — The scanner ignores `*.test.ts`, `*.d.ts`, and any file or directory whose name starts with `_`.
635
+
636
+ **Do not edit** `generated/app.ts` manually for custom routes — add files under `src/routes/` and regenerate.
637
+
638
+ ### Endpoints
639
+
640
+ 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.
641
+
642
+ | Method | Path | Handler | Validation |
643
+ |--------|------|---------|------------|
644
+ | `GET` | `/` | `findMany({ where: policyWhere })` | — |
645
+ | `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere))` | Path params |
646
+ | `POST` | `/` | `create(body)` — policy check only | JSON body |
647
+ | `PUT` | `/{pk}` | `update({ where: mergeWhere(pk, policyWhere), data })` | Path params + JSON body |
648
+ | `DELETE` | `/{pk}` | `delete(mergeWhere(pk, policyWhere))` | Path params |
649
+
650
+ `POST` returns `201 Created`. Missing records on `GET` return `404`.
651
+
652
+ ### Validation
653
+
654
+ Zod schemas are generated from stored fields (relation fields are excluded). Rules from the DSL:
655
+
656
+ ```ts
657
+ email: VARCHAR(255) @regex(pattern: "^[\\w.-]+@[\\w.-]+\\.\\w+$", message: "Invalid email address")
658
+ age: SMALLINT? @range(min: 1, max: 120, message: "Age must be between 1 and 120")
659
+ ```
660
+
661
+ Become generated validators with the same messages:
662
+
663
+ ```typescript
664
+ email: z.string().regex(/^[\w.-]+@[\w.-]+\.\w+$/, { message: 'Invalid email address' }),
665
+ 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(),
666
+ ```
667
+
668
+ Validation runs through middleware in `src/api/middleware/validate.ts`. On failure the API responds with:
669
+
670
+ ```json
671
+ { "error": "Invalid email address" }
672
+ ```
673
+
674
+ Fields with `@default` or optional (`?`) types are optional on create. Update schemas make all non-PK fields optional (partial updates).
675
+
676
+ ### Example requests
677
+
678
+ ```bash
679
+ # Health check (custom route from src/routes/health.ts)
680
+ curl http://localhost:3000/health
681
+
682
+ # List users
683
+ curl http://localhost:3000/users
684
+
685
+ # Get one user
686
+ curl http://localhost:3000/users/{uuid}
687
+
688
+ # Create a user
689
+ curl -X POST http://localhost:3000/users \
690
+ -H 'Content-Type: application/json' \
691
+ -d '{"email":"alice@example.com","name":"Alice","balance":0}'
692
+
693
+ # Validation failure (schema message returned)
694
+ curl -X POST http://localhost:3000/users \
695
+ -H 'Content-Type: application/json' \
696
+ -d '{"email":"not-an-email","name":"Alice","balance":0}'
697
+ # → {"error":"Invalid email address"}
698
+
699
+ # Update a user
700
+ curl -X PUT http://localhost:3000/users/{uuid} \
701
+ -H 'Content-Type: application/json' \
702
+ -d '{"name":"Alice Updated"}'
703
+
704
+ # Delete a user
705
+ curl -X DELETE http://localhost:3000/users/{uuid}
706
+
707
+ # Composite primary key
708
+ curl http://localhost:3000/product-orders/{orderId}/{productId}
709
+ ```
710
+
711
+ ### Error responses
712
+
713
+ | Status | When |
714
+ |--------|------|
715
+ | `400` | Zod validation failure or foreign key violation |
716
+ | `401` | Malformed or invalid JWT (when `Authorization: Bearer` is present) |
717
+ | `403` | Role not allowed for the requested operation (`@policy` denial) |
718
+ | `404` | Record not found on `GET`, or delete/update returned no rows |
719
+ | `409` | Unique constraint violation |
720
+ | `500` | Other database errors |
721
+
722
+ Global error handling lives in `src/api/middleware/errors.ts` and maps the same typed exceptions as the DB client layer.
723
+
724
+ ### App configuration
725
+
726
+ The generated `app.ts` sets up:
727
+
728
+ ```typescript
729
+ import { Hono } from 'hono';
730
+ import { logger } from 'hono/logger';
731
+ import { prettyJSON } from 'hono/pretty-json';
732
+
733
+ const app = new Hono<AppEnv>();
734
+ app.use(logger());
735
+ app.use(prettyJSON());
736
+ app.use(createDbMiddleware()); // injects db from DATABASE_URL
737
+ app.use(createAuthMiddleware()); // injects auth (default: Bearer JWT)
738
+ app.onError(handleError);
739
+
740
+ app.route('/users', usersRouter);
741
+ // ... all generated routers
742
+ app.route('/health', healthRouter);
743
+ // ... all custom routers from src/routes/
744
+ ```
745
+
746
+ ### Runtime architecture
747
+
748
+ 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/`:
749
+
750
+ ```
751
+ schematic-pg/dist/api/ # Published runtime (import as schematic-pg/api/*)
752
+ ├── types.ts # Hono AppEnv (db + auth in context)
753
+ ├── auth/
754
+ │ ├── jwt-resolver.ts # Default Bearer JWT resolver (HS256)
755
+ │ ├── middleware.ts # createAuthMiddleware(resolver?)
756
+ │ ├── policy.ts # assertPolicy, resolvePolicyWhere, mergeWhere
757
+ │ └── ...
758
+ ├── middleware/
759
+ │ ├── db.ts # Pool + createDbClient + context middleware
760
+ │ ├── validate.ts # Zod validation wrappers
761
+ │ └── errors.ts # HTTP error mapping (401, 403, 409, …)
762
+ └── utils/
763
+ └── route-naming.ts # Model → kebab-case plural paths
764
+
765
+ your-project/src/routes/ # Hand-written custom Hono routers (auto-imported)
766
+ └── health.ts # Example: GET /health
767
+ ```
768
+
769
+ The generators live in this repo under `src/api-generator/` and are invoked by the CLI at build time.
770
+
771
+ URL query-string filters for `findMany` (e.g. `?role=ADMIN`) are planned for a future release.
772
+
773
+ ---
774
+
775
+ ## Access Control (`@policy`)
776
+
777
+ 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.
778
+
779
+ ### Defining policies
780
+
781
+ Attach one or more `@policy` attributes to a model:
782
+
783
+ ```ts
784
+ model User {
785
+ id: UUID @id @default(gen_random_uuid())
786
+ role: UserRole @default(USER)
787
+ // ...
788
+
789
+ @policy(role: USER, allow: [select, insert, update], where: "id = {{auth.user.id}}")
790
+ @policy(role: ADMIN, allow: all)
791
+ }
792
+ ```
793
+
794
+ | Argument | Type | Description |
795
+ |----------|------|-------------|
796
+ | `role` | enum identifier | Role this policy applies to (must match a value in your schema enums, e.g. `UserRole`) |
797
+ | `allow` | `all` or `[select, insert, update, delete]` | Operations permitted for this role |
798
+ | `where` | string (optional) | Row-level filter applied on read/update/delete; supports `{{auth.*}}` templates |
799
+
800
+ **Operations map to HTTP methods:**
801
+
802
+ | HTTP | Policy operation |
803
+ |------|------------------|
804
+ | `GET` | `select` |
805
+ | `POST` | `insert` |
806
+ | `PUT` | `update` |
807
+ | `DELETE` | `delete` |
808
+
809
+ Models **without** `@policy` attributes are open — generated routes skip ACL checks entirely (e.g. `Log` in the sample schema).
810
+
811
+ ### How enforcement works
812
+
813
+ For each model that has policies, generated routes call the policy guard before every DB operation:
814
+
815
+ ```typescript
816
+ const auth = c.get('auth');
817
+ const policy = assertPolicy('User', auth.role, 'select');
818
+ const policyWhere = resolvePolicyWhere(policy, auth);
819
+ const rows = await db.user.findMany({ where: policyWhere });
820
+ ```
821
+
822
+ 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.
823
+ 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.
824
+ 3. **`mergeWhere(routeWhere, policyWhere)`** — Combines route params (e.g. `:id`) with the policy filter via `AND` on read/update/delete.
825
+
826
+ `POST` (insert) checks operation permission only — no `where` injection.
827
+
828
+ ### Auth context
829
+
830
+ Every request gets an `auth` object on Hono context:
831
+
832
+ ```typescript
833
+ type AuthContext = {
834
+ role: string;
835
+ user?: { id: string; [key: string]: unknown };
836
+ };
837
+ ```
838
+
839
+ **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.
840
+
841
+ If the caller's role has no matching `@policy`, the runtime falls back to a `PUBLIC` role policy when one exists.
842
+
843
+ ### Default JWT authentication
844
+
845
+ The generated app uses `createAuthMiddleware()` with a built-in Bearer JWT resolver (`src/api/auth/jwt-resolver.ts`):
846
+
847
+ ```bash
848
+ curl http://localhost:3000/users \
849
+ -H 'Authorization: Bearer <jwt>'
850
+ ```
851
+
852
+ The resolver expects HS256 tokens and reads:
853
+
854
+ - `auth.role` ← claim named by `JWT_ROLE_CLAIM` (default: `role`)
855
+ - `auth.user.id` ← claim named by `JWT_USER_ID_CLAIM` (default: `sub`)
856
+
857
+ Set `JWT_SECRET` in `.env` when using the default resolver.
858
+
859
+ ### Pluggable auth
860
+
861
+ Different systems resolve identity differently. Pass a custom `AuthResolver` to the middleware:
862
+
863
+ ```typescript
864
+ import { createAuthMiddleware } from 'schematic-pg/api/auth/middleware';
865
+
866
+ app.use(createAuthMiddleware(async (c) => {
867
+ const role = c.req.header('X-Role');
868
+ const userId = c.req.header('X-User-Id');
869
+
870
+ if (!role || !userId) {
871
+ return null; // → defaults to { role: 'PUBLIC' }
872
+ }
873
+
874
+ return {
875
+ role,
876
+ user: { id: userId },
877
+ };
878
+ }));
879
+ ```
880
+
881
+ `AuthResolver` signature: `(c: Context<AppEnv>) => Promise<AuthContext | null>`.
882
+
883
+ Return `null` for anonymous callers; throw `UnauthorizedError` for invalid credentials.
884
+
885
+ ### Where templates
886
+
887
+ Policy `where` clauses support `{{auth.*}}` placeholders resolved against the auth context:
888
+
889
+ ```ts
890
+ where: "id = {{auth.user.id}}"
891
+ ```
892
+
893
+ After interpolation, simple `field op value` forms are parsed into `WhereInput`:
894
+
895
+ | Form | Example |
896
+ |------|---------|
897
+ | Equality | `id = {{auth.user.id}}` → `{ id: '…' }` |
898
+ | Comparison | `balance >= 100` → `{ balance: { gte: 100 } }` |
899
+ | Inequality | `role != ADMIN` → `{ NOT: { role: 'ADMIN' } }` |
900
+
901
+ Complex multi-clause SQL in `where` is not supported yet — keep policies to a single condition for now.
902
+
903
+ ### Generated policy metadata
904
+
905
+ `schematic-pg generate:api` emits `generated/policies.ts`:
906
+
907
+ ```typescript
908
+ export const POLICIES: Record<string, NormalizedPolicy[]> = {
909
+ User: [
910
+ { role: 'USER', operations: ['select', 'insert', 'update'], where: "id = {{auth.user.id}}" },
911
+ { role: 'ADMIN', operations: 'all' },
912
+ ],
913
+ };
914
+ ```
915
+
916
+ This file is consumed by `assertPolicy` at runtime — do not edit manually.
917
+
918
+ ### Example: scoped user access
919
+
920
+ With the sample `User` policies above:
921
+
922
+ | Caller | `GET /users` | `GET /users/:id` | `DELETE /users/:id` |
923
+ |--------|--------------|------------------|---------------------|
924
+ | No token (`PUBLIC`) | `403` | `403` | `403` |
925
+ | JWT `role: USER`, `sub: <own-id>` | Returns own row only | Own row if `:id` matches | `403` (delete not in `allow`) |
926
+ | JWT `role: ADMIN` | Returns all rows | Any row | Allowed |
927
+
928
+ These scenarios are covered by `npm run test:integration` — see [`src/api/__tests__/acl.integration.test.ts`](src/api/__tests__/acl.integration.test.ts).
929
+
930
+ ---
931
+
932
+ ## Project Structure
933
+
934
+ After `schematic-pg init` and `schematic-pg generate`, a typical application looks like this:
935
+
936
+ ```
937
+ my-app/
938
+ ├── app.schema # Your single source of truth
939
+ ├── schema.sql # Generated PostgreSQL DDL
940
+ ├── .env # DATABASE_URL, JWT_* settings
941
+ ├── docker-compose.yml # Local PostgreSQL (optional)
942
+ ├── tsconfig.json
943
+ ├── package.json # schematic-pg + hono + pg + zod
944
+ ├── generated/
945
+ │ ├── db.ts # createDbClient(pool) factory
946
+ │ ├── db-types.ts # Generated model + input interfaces
947
+ │ ├── db-model-meta.ts # Runtime column metadata
948
+ │ ├── app.ts # Hono entry point (starts server on :3000)
949
+ │ ├── policies.ts # Generated ACL metadata from @policy
950
+ │ ├── routes/
951
+ │ │ ├── users.ts
952
+ │ │ ├── profiles.ts
953
+ │ │ └── ...
954
+ │ └── schemas/
955
+ │ └── validation.ts # Generated Zod schemas
956
+ └── src/
957
+ └── routes/
958
+ └── health.ts # Custom route → GET /health
959
+ ```
960
+
961
+ 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.
962
+
963
+ ### This repository (framework source)
964
+
965
+ ```
966
+ postgrest.js/
967
+ ├── src/
968
+ │ ├── schema-dsl/ # Lexer, parser, AST
969
+ │ ├── sql-generator/ # DDL + migration planner
970
+ │ ├── db/ # Query builder + client runtime
971
+ │ ├── api/ # Hono runtime (published as schematic-pg/api/*)
972
+ │ ├── api-generator/ # AST → routes, Zod, policies, app
973
+ │ ├── cli/ # init templates + command helpers
974
+ │ └── cli.ts # schematic-pg CLI entry point
975
+ ├── dist/ # Compiled output (npm publish target)
976
+ ├── generated/ # Sample output from app.schema (this repo)
977
+ ├── app.schema # Sample schema
978
+ └── editors/ # VS Code extension + language server
979
+ ```
980
+
981
+ ---
982
+
983
+ ## Why schematic-pg?
984
+
985
+ | Concern | ORM Approach | schematic-pg Approach |
986
+ |---------|-----------|----------------------|
987
+ | Schema truth | Migrations + models + Zod + routes | One `.schema` file |
988
+ | Query visibility | Hidden behind ORM methods | Raw, parameterized SQL |
989
+ | Client ergonomics | ORM model API | Generated Prisma-like client, no ORM runtime |
990
+ | Performance | N+1, lazy loading pitfalls | Explicit joins, no magic |
991
+ | ACL | External service or manual checks | Inline `@policy` directives |
992
+ | Validation | Separate Zod schemas | Derived from `@regex` / `@range` |
993
+ | Dependencies | Heavy (Prisma, Drizzle, etc.) | Hono + pg + Zod + hand-written parser |
994
+
995
+ ---
996
+
997
+ ## Roadmap
998
+
999
+ - [x] npm package + CLI (`schematic-pg init`, `generate`, `dev`, `db:*`)
1000
+ - [x] Hand-written lexer & recursive-descent parser
1001
+ - [x] SQL DDL generator (full regeneration)
1002
+ - [x] Type-safe database client generator (`createDbClient`, parameterized query builder)
1003
+ - [x] Diff-based migration planner
1004
+ - [x] Hono route generator with Zod validation
1005
+ - [x] Static ACL middleware generation (`@policy` → `assertPolicy` in routes)
1006
+ - [x] Row-level policy injection (`WHERE` clause from `where:` templates)
1007
+ - [x] JWT authentication (default Bearer resolver, pluggable `AuthResolver`)
1008
+ - [x] Custom routes (`src/routes/` auto-imported into generated app)
1009
+ - [ ] Relation `include` in DB client
1010
+ - [ ] Type generation for frontend consumption
1011
+ - [ ] Tree-sitter grammar for editor support
1012
+ - [x] VS Code extension with syntax highlighting and language server
1013
+ - [ ] URL query-string filters for `findMany` (e.g. `?role=ADMIN`)
1014
+
1015
+ ---
1016
+
1017
+ ## License
1018
+
1019
+ MIT