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.
- package/README.md +383 -194
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,18 +7,374 @@
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Schema DSL
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|