schematic-pg 0.1.14 → 0.1.15

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 (2) hide show
  1. package/README.md +383 -194
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -7,18 +7,374 @@
7
7
 
8
8
  ---
9
9
 
10
- ## Documentation
10
+ ## Schema DSL
11
11
 
12
- - [Philosophy & features](docs/philosophy.md)
13
- - [How it works](docs/how-it-works.md)
14
- - [Database client](docs/database-client.md)
15
- - [REST API](docs/rest-api.md)
16
- - [Access control](docs/access-control.md)
17
- - [Migrations tutorial](docs/migrations.md) — schema diffs, `db:migrate`, and GitHub Actions for staging/production
18
- - [Project structure](docs/project-structure.md)
19
- - [Contributing (this repo)](docs/contributing.md)
20
- - [Why schematic-pg?](docs/why.md)
21
- - [Roadmap](docs/roadmap.md)
12
+ `app.schema` is the source of truth. From it, schematic-pg generates PostgreSQL DDL, a type-safe DB client, REST routes, Zod validators, and ACL policies.
13
+
14
+ A schema file always has three sections, in this order: `extensions`, `enums`, `models`.
15
+
16
+ ```ts
17
+ extensions {
18
+ pgcrypto
19
+ }
20
+
21
+ enums {
22
+ UserRole { ADMIN, USER }
23
+ }
24
+
25
+ models {
26
+ model User {
27
+ id: UUID @id @default(gen_random_uuid())
28
+ email: VARCHAR(255) @unique
29
+ role: UserRole @default(USER)
30
+ }
31
+ }
32
+ ```
33
+
34
+ Each feature below is shown in isolation. Identifiers in SQL become `snake_case` automatically (`createdAt` → `created_at`, `User` → `"user"`). API field names stay camelCase.
35
+
36
+ ### Extensions
37
+
38
+ Declare PostgreSQL extensions to enable. Options are optional.
39
+
40
+ ```ts
41
+ extensions {
42
+ pgcrypto { version: "1.3" }
43
+ uuid-ossp
44
+ }
45
+ ```
46
+
47
+ ### Enums
48
+
49
+ Enums become PostgreSQL enum types. Use them as field types and as `@policy` roles.
50
+
51
+ ```ts
52
+ enums {
53
+ UserRole { ADMIN, USER, PUBLIC }
54
+ OrderStatus { PENDING, SHIPPED, DELIVERED }
55
+ }
56
+ ```
57
+
58
+ ### Models
59
+
60
+ A model is a table plus its relations, policies, indexes, and triggers.
61
+
62
+ ```ts
63
+ models {
64
+ model Log {
65
+ id: UUID @id @default(gen_random_uuid())
66
+ message: TEXT
67
+ }
68
+ }
69
+ ```
70
+
71
+ ### Field types
72
+
73
+ Stored columns use PostgreSQL types. Append `?` for nullable, `[]` for arrays. Parametric types take arguments.
74
+
75
+ ```ts
76
+ model Product {
77
+ id: UUID
78
+ name: VARCHAR(255)
79
+ price: DECIMAL(10, 2)
80
+ stock: INTEGER
81
+ tags: TEXT[]
82
+ description: TEXT?
83
+ metadata: JSONB
84
+ createdAt: TIMESTAMP
85
+ }
86
+ ```
87
+
88
+ Common types: `UUID`, `VARCHAR`, `TEXT`, `BOOLEAN`, `TIMESTAMP`, `DECIMAL`, `NUMERIC`, `INTEGER`, `SMALLINT`, `BIGINT`, `SERIAL`, `JSONB`, `POINT`, `BYTEA`, `DATE`, `TIME`, `INTERVAL`, `REAL`, `DOUBLE`.
89
+
90
+ Relation fields use another model as the type (`Profile?`, `Order[]`). They are not stored columns — see [Relations](#relations-relation).
91
+
92
+ ### Primary keys (`@id`, `@@id`)
93
+
94
+ Mark a single column with `@id`. Use `@@id` for a composite key.
95
+
96
+ ```ts
97
+ model User {
98
+ id: UUID @id @default(gen_random_uuid())
99
+ }
100
+ ```
101
+
102
+ ```ts
103
+ model ProductOrder {
104
+ orderId: UUID
105
+ productId: UUID
106
+
107
+ @@id(fields: [orderId, productId])
108
+ }
109
+ ```
110
+
111
+ Composite keys expose one path segment per field (`/product-orders/:orderId/:productId`).
112
+
113
+ ### Defaults (`@default`)
114
+
115
+ Literals, enum values, or call expressions. Fields with `@default` are optional on create.
116
+
117
+ ```ts
118
+ model User {
119
+ id: UUID @id @default(gen_random_uuid())
120
+ role: UserRole @default(USER)
121
+ isActive: BOOLEAN @default(true)
122
+ createdAt: TIMESTAMP @default(now())
123
+ }
124
+ ```
125
+
126
+ Built-in functions: `gen_random_uuid()`, `now()`.
127
+
128
+ ### Unique constraints (`@unique`)
129
+
130
+ ```ts
131
+ model User {
132
+ email: VARCHAR(255) @unique
133
+ }
134
+ ```
135
+
136
+ For a partial unique index, use `@@index` with `unique: true` instead — see [Indexes](#indexes-index).
137
+
138
+ ### Validation (`@regex`, `@range`)
139
+
140
+ Constraints flow into generated Zod request validators. The `message` is returned as the API error.
141
+
142
+ ```ts
143
+ model User {
144
+ email: VARCHAR(255) @regex(pattern: "^[\\w.-]+@[\\w.-]+\\.\\w+$", message: "Invalid email address")
145
+ age: SMALLINT? @range(min: 1, max: 120, message: "Age must be between 1 and 120")
146
+ }
147
+ ```
148
+
149
+ Failed validation responds with `{ "error": "Invalid email address" }`.
150
+
151
+ ### Response shaping (`@omit`)
152
+
153
+ Exclude a stored field from generated API JSON. The DB client still returns the full row.
154
+
155
+ ```ts
156
+ model User {
157
+ passwordHash: VARCHAR(255)? @omit
158
+ }
159
+ ```
160
+
161
+ `@omit` fields are never URL-filterable. On read endpoints with `include`, they are stripped recursively on nested objects as well.
162
+
163
+ ### Query filters (`@unfilterable`)
164
+
165
+ Scalar fields are URL-filterable by default (`?role=ADMIN`, `?balance_gte=100`). Opt out per field:
166
+
167
+ ```ts
168
+ model User {
169
+ id: UUID @id @unfilterable
170
+ updatedAt: TIMESTAMP? @unfilterable
171
+ }
172
+ ```
173
+
174
+ ### Relation includes (`@unincludeable`)
175
+
176
+ Relation fields can be loaded via `?include=profile,orders`. Block that on a field:
177
+
178
+ ```ts
179
+ model User {
180
+ orders: Order[] @unincludeable
181
+ }
182
+ ```
183
+
184
+ ### Relations (`@relation`)
185
+
186
+ Relation fields point at another model. The side that owns the foreign-key column declares `@relation` with `fields` and `references`. The inverse side is inferred — no `@relation` needed.
187
+
188
+ **One-to-many**
189
+
190
+ ```ts
191
+ model User {
192
+ orders: Order[]
193
+ }
194
+
195
+ model Order {
196
+ userId: UUID
197
+ user: User @relation(fields: [userId], references: [id])
198
+ }
199
+ ```
200
+
201
+ **One-to-one** — unique FK on the owning side, optional inverse:
202
+
203
+ ```ts
204
+ model User {
205
+ profile: Profile?
206
+ }
207
+
208
+ model Profile {
209
+ userId: UUID @unique
210
+ user: User @relation(fields: [userId], references: [id])
211
+ }
212
+ ```
213
+
214
+ **Referential actions** (`onDelete`, `onUpdate`) are optional: `CASCADE`, `SET_NULL`, `RESTRICT`, `NO_ACTION`.
215
+
216
+ ```ts
217
+ model Profile {
218
+ userId: UUID @unique
219
+ user: User @relation(
220
+ fields: [userId],
221
+ references: [id],
222
+ onDelete: CASCADE,
223
+ onUpdate: SET_NULL
224
+ )
225
+ }
226
+ ```
227
+
228
+ **Named relations** — only when two models relate more than once. Both sides must use the same `name`:
229
+
230
+ ```ts
231
+ model User {
232
+ writtenPosts: Post[] @relation(name: "PostAuthor")
233
+ editedPosts: Post[] @relation(name: "PostEditor")
234
+ }
235
+
236
+ model Post {
237
+ authorId: UUID
238
+ editorId: UUID?
239
+ author: User @relation(name: "PostAuthor", fields: [authorId], references: [id])
240
+ editor: User? @relation(name: "PostEditor", fields: [editorId], references: [id])
241
+ }
242
+ ```
243
+
244
+ `include` and API paths use the **field name** (`profile`, `orders`, `author`) — not the optional `name` argument. Foreign keys are named from table and column names.
245
+
246
+ | Argument | Required | Purpose |
247
+ |----------|----------|---------|
248
+ | `fields` | Yes (FK side) | Local column(s) on this model |
249
+ | `references` | Yes (FK side) | Target column(s) on the related model |
250
+ | `onDelete` | No | PostgreSQL `ON DELETE` action |
251
+ | `onUpdate` | No | PostgreSQL `ON UPDATE` action |
252
+ | `name` | No | Disambiguates multiple relations between the same two models |
253
+
254
+ **Many-to-many** is an explicit join model with two `@relation`s (and usually `@@id`):
255
+
256
+ ```ts
257
+ model ProductOrder {
258
+ orderId: UUID
259
+ productId: UUID
260
+ quantity: INTEGER
261
+
262
+ order: Order @relation(fields: [orderId], references: [id])
263
+ product: Product @relation(fields: [productId], references: [id])
264
+
265
+ @@id(fields: [orderId, productId])
266
+ }
267
+ ```
268
+
269
+ ### REST surface (`@rest`)
270
+
271
+ By default every model gets full CRUD. `@rest` chooses which HTTP handlers are generated. Disabled methods return `404` and are omitted from OpenAPI. The DB client is unaffected.
272
+
273
+ ```ts
274
+ model User {
275
+ id: UUID @id
276
+ @rest(except: [create, update, delete]) // keep list + get
277
+ }
278
+ ```
279
+
280
+ ```ts
281
+ model Report {
282
+ id: UUID @id
283
+ @rest(only: [list, get])
284
+ }
285
+ ```
286
+
287
+ ```ts
288
+ model Internal {
289
+ id: UUID @id
290
+ @rest(false) // no HTTP for this model (`@rest` alone is the same)
291
+ }
292
+ ```
293
+
294
+ | DSL operation | HTTP | Path |
295
+ |---------------|------|------|
296
+ | `list` | `GET` | `/` |
297
+ | `get` | `GET` | `/{pk}` |
298
+ | `create` | `POST` | `/` |
299
+ | `update` | `PUT` | `/{pk}` |
300
+ | `delete` | `DELETE` | `/{pk}` |
301
+
302
+ Do not mix `only` and `except`. For custom handlers on the same path, see [REST API](docs/rest-api.md).
303
+
304
+ ### Access control (`@policy`)
305
+
306
+ Attach one or more policies to a model. Models without `@policy` are open. `@policy` only gates **generated** handlers — use `@rest` when an operation should not exist as HTTP at all.
307
+
308
+ ```ts
309
+ model User {
310
+ id: UUID @id
311
+
312
+ @policy(role: USER, allow: [select], where: "id = {{auth.user.id}}")
313
+ @policy(role: ADMIN, allow: all)
314
+ }
315
+ ```
316
+
317
+ | Argument | Description |
318
+ |----------|-------------|
319
+ | `role` | Enum identifier (typically a `UserRole` value) |
320
+ | `allow` | `all` or `[select, insert, update, delete]` |
321
+ | `where` | Optional row-level filter; supports `{{auth.user.id}}` |
322
+
323
+ `GET` → `select`, `POST` → `insert`, `PUT` → `update`, `DELETE` → `delete`. Unauthenticated requests default to `{ role: 'PUBLIC' }`.
324
+
325
+ `where` is a single condition today (`id = {{auth.user.id}}`, `balance >= 100`). See [Access control](docs/access-control.md) for enforcement, JWT claims, and pluggable auth.
326
+
327
+ ### Indexes (`@@index`)
328
+
329
+ ```ts
330
+ model User {
331
+ role: UserRole
332
+ isActive: BOOLEAN
333
+ name: VARCHAR(150)
334
+ email: VARCHAR(255)
335
+
336
+ @@index(fields: [role, isActive])
337
+ @@index(fields: [name], where: "isActive = true", name: "active_users_name_idx", type: BTREE)
338
+ @@index(fields: [email], unique: true, where: "role = 'PUBLIC'")
339
+ }
340
+ ```
341
+
342
+ | Argument | Required | Purpose |
343
+ |----------|----------|---------|
344
+ | `fields` | Yes | Indexed columns |
345
+ | `where` | No | Partial index predicate |
346
+ | `name` | No | Explicit index name |
347
+ | `type` | No | `BTREE`, `GIN`, `GIST`, `HASH`, `BRIN` |
348
+ | `unique` | No | Unique index |
349
+
350
+ ### Triggers (`@@trigger`)
351
+
352
+ `execute` is the PL/pgSQL function body (wrapped in `BEGIN` / `END` for you). A model may have multiple triggers.
353
+
354
+ ```ts
355
+ model User {
356
+ balance: INTEGER
357
+
358
+ @@trigger {
359
+ timing: BEFORE,
360
+ event: UPDATE,
361
+ level: ROW,
362
+ execute: """
363
+ IF (OLD.balance <> NEW.balance) THEN
364
+ RAISE EXCEPTION 'Balance cannot be updated directly';
365
+ END IF;
366
+ RETURN NEW;
367
+ """
368
+ }
369
+ }
370
+ ```
371
+
372
+ | Argument | Values | Default |
373
+ |----------|--------|---------|
374
+ | `timing` | `BEFORE`, `AFTER` | — |
375
+ | `event` | `INSERT`, `UPDATE`, `DELETE` | — |
376
+ | `level` | `ROW`, `STATEMENT` | `ROW` |
377
+ | `execute` | Triple-quoted PL/pgSQL | — |
22
378
 
23
379
  ---
24
380
 
@@ -83,7 +439,7 @@ After `generate`, your project also contains:
83
439
  | `schema.sql` | Idempotent PostgreSQL DDL |
84
440
  | `generated/db*.ts` | Type-safe DB client |
85
441
  | `generated/app.ts` | Hono server entry point |
86
- | `generated/routes/*.ts` | CRUD routers per model |
442
+ | `generated/routes/*.ts` | CRUD routers per model (`@rest` may omit methods) |
87
443
  | `generated/policies.ts` | ACL metadata from `@policy` |
88
444
  | `generated/schemas/validation.ts` | Zod request validators |
89
445
 
@@ -165,188 +521,6 @@ Password reset, MFA, session/refresh-token management, and login rate limiting a
165
521
 
166
522
  ---
167
523
 
168
- ## The DSL
169
-
170
- ```ts
171
- extensions {
172
- pgcrypto { version: "1.3" }
173
- uuid-ossp
174
- }
175
-
176
- enums {
177
- UserRole { ADMIN, USER, PUBLIC }
178
- OrderStatus { PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED }
179
- }
180
-
181
- models {
182
-
183
- model User {
184
- id: UUID @id @default(gen_random_uuid())
185
- email: VARCHAR(255) @unique @regex(pattern: "^[\\w.-]+@[\\w.-]+\\.\\w+$", message: "Invalid email address")
186
- name: VARCHAR(150)
187
- role: UserRole @default(USER)
188
- age: SMALLINT?
189
- balance: INTEGER
190
- isActive: BOOLEAN @default(true)
191
- createdAt: TIMESTAMP @default(now())
192
- updatedAt: TIMESTAMP?
193
-
194
- profile: Profile?
195
- orders: Order[]
196
-
197
- @policy(role: USER, allow: [select, insert, update], where: "id = {{auth.user.id}}")
198
- @policy(role: ADMIN, allow: all)
199
-
200
- @@index(fields: [role, isActive])
201
- @@index(fields: [name], where: "isActive = true", name: "active_users_name_idx", type: BTREE)
202
-
203
- @@trigger {
204
- timing: BEFORE,
205
- event: UPDATE,
206
- level: ROW,
207
- execute: """
208
- IF (OLD.balance <> NEW.balance) THEN
209
- RAISE EXCEPTION 'Balance cannot be updated directly';
210
- END IF;
211
- RETURN NEW;
212
- """
213
- }
214
- }
215
-
216
- model Profile {
217
- id: UUID @id @default(gen_random_uuid())
218
- userId: UUID @unique
219
- bio: TEXT
220
- avatar: VARCHAR(255)
221
- location: POINT
222
-
223
- user: User @relation(
224
- fields: [userId],
225
- references: [id],
226
- onDelete: CASCADE,
227
- onUpdate: SET_NULL
228
- )
229
- }
230
-
231
- model Order {
232
- id: UUID @id @default(gen_random_uuid())
233
- userId: UUID
234
- status: OrderStatus @default(PENDING)
235
- totalAmount: DECIMAL(10, 2)
236
- items: JSONB
237
- createdAt: TIMESTAMP @default(now())
238
- updatedAt: TIMESTAMP?
239
-
240
- user: User @relation(fields: [userId], references: [id])
241
- products: ProductOrder[]
242
-
243
- @@index(fields: [userId])
244
- @@index(fields: [status, createdAt], name: "order_status_created_idx")
245
- }
246
-
247
- model Product {
248
- id: UUID @id @default(gen_random_uuid())
249
- name: VARCHAR(255)
250
- description: TEXT
251
- price: DECIMAL(10, 2) @range(min: 0.01, max: 999999.99)
252
- stock: INTEGER @range(min: 0)
253
- category: VARCHAR(100)
254
- tags: TEXT[]
255
- metadata: JSONB
256
- createdAt: TIMESTAMP @default(now())
257
- updatedAt: TIMESTAMP?
258
-
259
- orders: ProductOrder[]
260
-
261
- @@trigger {
262
- timing: AFTER,
263
- event: UPDATE,
264
- level: ROW,
265
- execute: """
266
- IF (OLD.stock <> NEW.stock) THEN
267
- INSERT INTO log (message) VALUES ('Product stock changed');
268
- END IF;
269
- RETURN NEW;
270
- """
271
- }
272
- }
273
-
274
- model ProductOrder {
275
- id: SERIAL
276
- orderId: UUID
277
- productId: UUID
278
- quantity: INTEGER
279
- price: DECIMAL(10, 2)
280
-
281
- order: Order @relation(fields: [orderId], references: [id])
282
- product: Product @relation(fields: [productId], references: [id])
283
-
284
- @@id(fields: [orderId, productId])
285
- }
286
-
287
- }
288
- ```
289
-
290
- ### Relations (`@relation`)
291
-
292
- Relation fields point at another model (`Profile?`, `Order[]`). The side that owns the foreign-key column must declare `@relation` with `fields` and `references`:
293
-
294
- ```ts
295
- model User {
296
- profile: Profile? // inverse — no @relation needed
297
- orders: Order[]
298
- }
299
-
300
- model Profile {
301
- userId: UUID @unique
302
- user: User @relation(
303
- fields: [userId],
304
- references: [id],
305
- onDelete: CASCADE, // optional
306
- onUpdate: SET_NULL // optional
307
- )
308
- }
309
-
310
- model Order {
311
- userId: UUID
312
- user: User @relation(fields: [userId], references: [id])
313
- }
314
- ```
315
-
316
- | Argument | Required | Side | Purpose |
317
- |----------|----------|------|---------|
318
- | `fields` | Yes (FK side) | FK owner | Local column(s) on this model |
319
- | `references` | Yes (FK side) | FK owner | Target column(s) on the related model |
320
- | `onDelete` | No | FK side | PostgreSQL `ON DELETE` action (`CASCADE`, `SET NULL`, …) |
321
- | `onUpdate` | No | FK side | PostgreSQL `ON UPDATE` action |
322
- | `name` | No | Both (must match) | Disambiguates multiple relations between the same two models |
323
-
324
- **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.
325
-
326
- **`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:
327
-
328
- ```ts
329
- model User {
330
- writtenPosts: Post[] @relation(name: "PostAuthor")
331
- editedPosts: Post[] @relation(name: "PostEditor")
332
- }
333
-
334
- model Post {
335
- authorId: UUID
336
- editorId: UUID?
337
- author: User @relation(name: "PostAuthor", fields: [authorId], references: [id])
338
- editor: User? @relation(name: "PostEditor", fields: [editorId], references: [id])
339
- }
340
- ```
341
-
342
- 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).
343
-
344
- **Runtime keys.** `include` and API relation paths use the **field name** (`profile`, `orders`, `user`) — not the optional `name` argument. `name` is never used for SQL constraint names; foreign keys are named from table and column names.
345
-
346
- For `@policy` enforcement, JWT auth, and row-level filters, see [Access control](docs/access-control.md).
347
-
348
- ---
349
-
350
524
  ## CLI Reference
351
525
 
352
526
  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).
@@ -474,6 +648,21 @@ schematic-pg --help
474
648
 
475
649
  ---
476
650
 
651
+ ## Documentation
652
+
653
+ - [Philosophy & features](docs/philosophy.md)
654
+ - [How it works](docs/how-it-works.md)
655
+ - [Database client](docs/database-client.md)
656
+ - [REST API](docs/rest-api.md)
657
+ - [Access control](docs/access-control.md)
658
+ - [Migrations tutorial](docs/migrations.md) — schema diffs, `db:migrate`, and GitHub Actions for staging/production
659
+ - [Project structure](docs/project-structure.md)
660
+ - [Contributing (this repo)](docs/contributing.md)
661
+ - [Why schematic-pg?](docs/why.md)
662
+ - [Roadmap](docs/roadmap.md)
663
+
664
+ ---
665
+
477
666
  ## License
478
667
 
479
668
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Single-file backend framework for PostgreSQL and Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",