schematic-pg 0.1.7 → 0.1.8
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 +227 -8
- package/dist/api/hooks/define.d.ts +10 -0
- package/dist/api/hooks/define.js +3 -0
- package/dist/api/hooks/index.d.ts +4 -0
- package/dist/api/hooks/index.js +2 -0
- package/dist/api/hooks/registry.d.ts +8 -0
- package/dist/api/hooks/registry.js +94 -0
- package/dist/api/hooks/types.d.ts +48 -0
- package/dist/api/hooks/types.js +1 -0
- package/dist/api-generator/app-generator.js +3 -0
- package/dist/api-generator/hook-scanner.d.ts +11 -0
- package/dist/api-generator/hook-scanner.js +36 -0
- package/dist/api-generator/hooks-generator.d.ts +2 -0
- package/dist/api-generator/hooks-generator.js +25 -0
- package/dist/api-generator/index.d.ts +2 -0
- package/dist/api-generator/index.js +7 -1
- package/dist/api-generator/route-generator.d.ts +3 -2
- package/dist/api-generator/route-generator.js +85 -64
- package/dist/cli/dev.js +5 -36
- package/dist/cli/generate.js +1 -0
- package/dist/cli/hooks.d.ts +6 -0
- package/dist/cli/hooks.js +85 -0
- package/dist/cli/init.js +6 -1
- package/dist/cli/paths.d.ts +1 -0
- package/dist/cli/paths.js +1 -0
- package/dist/cli/server.d.ts +5 -0
- package/dist/cli/server.js +60 -0
- package/dist/cli/start.d.ts +7 -0
- package/dist/cli/start.js +35 -0
- package/dist/cli/templates.d.ts +1 -0
- package/dist/cli/templates.js +36 -0
- package/dist/cli.js +10 -0
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ Most backend frameworks force you to scatter your truth across migrations, ORM m
|
|
|
25
25
|
- **Type-safe Database Client** — Prisma-like query API over parameterized raw SQL (`pg` Pool, no ORM), with nested `include` eager-loading
|
|
26
26
|
- **Type-safe REST API** — Hono routes with generated Zod validation
|
|
27
27
|
- **Custom routes** — Hand-written Hono routers in `src/routes/` auto-imported into the generated app
|
|
28
|
+
- **Lifecycle hooks** — Before/after create, update, and delete with Express-style `next()` cancel semantics; scaffold via `hooks:add`
|
|
28
29
|
- **Inline ACL** — Row-level and role-based access control via `@policy` directives, enforced at runtime in generated routes
|
|
29
30
|
- **Validation Rules** — `@regex` and `@range` constraints that flow into generated Zod request validators (with custom error messages from the schema)
|
|
30
31
|
- **Migration Ready** — Full regeneration today, diff-based migrations tomorrow
|
|
@@ -58,7 +59,7 @@ models {
|
|
|
58
59
|
createdAt: TIMESTAMP @default(now())
|
|
59
60
|
updatedAt: TIMESTAMP?
|
|
60
61
|
|
|
61
|
-
profile: Profile?
|
|
62
|
+
profile: Profile?
|
|
62
63
|
orders: Order[]
|
|
63
64
|
|
|
64
65
|
@policy(role: USER, allow: [select, insert, update], where: "id = {{auth.user.id}}")
|
|
@@ -88,7 +89,6 @@ models {
|
|
|
88
89
|
location: POINT
|
|
89
90
|
|
|
90
91
|
user: User @relation(
|
|
91
|
-
name: "UserProfile",
|
|
92
92
|
fields: [userId],
|
|
93
93
|
references: [id],
|
|
94
94
|
onDelete: CASCADE,
|
|
@@ -155,6 +155,62 @@ models {
|
|
|
155
155
|
}
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
### Relations (`@relation`)
|
|
159
|
+
|
|
160
|
+
Relation fields point at another model (`Profile?`, `Order[]`). The side that owns the foreign-key column must declare `@relation` with `fields` and `references`:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
model User {
|
|
164
|
+
profile: Profile? // inverse — no @relation needed
|
|
165
|
+
orders: Order[]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
model Profile {
|
|
169
|
+
userId: UUID @unique
|
|
170
|
+
user: User @relation(
|
|
171
|
+
fields: [userId],
|
|
172
|
+
references: [id],
|
|
173
|
+
onDelete: CASCADE, // optional
|
|
174
|
+
onUpdate: SET_NULL // optional
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
model Order {
|
|
179
|
+
userId: UUID
|
|
180
|
+
user: User @relation(fields: [userId], references: [id])
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
| Argument | Required | Side | Purpose |
|
|
185
|
+
|----------|----------|------|---------|
|
|
186
|
+
| `fields` | Yes (FK side) | FK owner | Local column(s) on this model |
|
|
187
|
+
| `references` | Yes (FK side) | FK owner | Target column(s) on the related model |
|
|
188
|
+
| `onDelete` | No | FK side | PostgreSQL `ON DELETE` action (`CASCADE`, `SET NULL`, …) |
|
|
189
|
+
| `onUpdate` | No | FK side | PostgreSQL `ON UPDATE` action |
|
|
190
|
+
| `name` | No | Both (must match) | Disambiguates multiple relations between the same two models |
|
|
191
|
+
|
|
192
|
+
**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.
|
|
193
|
+
|
|
194
|
+
**`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:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
model User {
|
|
198
|
+
writtenPosts: Post[] @relation(name: "PostAuthor")
|
|
199
|
+
editedPosts: Post[] @relation(name: "PostEditor")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
model Post {
|
|
203
|
+
authorId: UUID
|
|
204
|
+
editorId: UUID?
|
|
205
|
+
author: User @relation(name: "PostAuthor", fields: [authorId], references: [id])
|
|
206
|
+
editor: User? @relation(name: "PostEditor", fields: [editorId], references: [id])
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
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).
|
|
211
|
+
|
|
212
|
+
**Runtime keys.** `include` and API relation paths use the **field name** (`profile`, `orders`, `user`) — not the optional `name` argument. `name` is never used for SQL constraint names; foreign keys are named from table and column names.
|
|
213
|
+
|
|
158
214
|
---
|
|
159
215
|
|
|
160
216
|
## How It Works
|
|
@@ -267,7 +323,7 @@ Generated code imports the runtime from the `schematic-pg` package (`schematic-p
|
|
|
267
323
|
| `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
|
|
268
324
|
| `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
|
|
269
325
|
|
|
270
|
-
Set these in `.env` before running `dev` or `db:bootstrap`.
|
|
326
|
+
Set these in `.env` before running `dev`, `start`, or `db:bootstrap`.
|
|
271
327
|
|
|
272
328
|
---
|
|
273
329
|
|
|
@@ -292,6 +348,14 @@ schematic-pg generate:api [schema] # generated/app.ts, routes/, policies, s
|
|
|
292
348
|
|
|
293
349
|
Run `generate:client` before `generate:api` when using the split commands — routes depend on `generated/db.ts`.
|
|
294
350
|
|
|
351
|
+
### Lifecycle hooks scaffolding
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
schematic-pg hooks:add [schema] [--model ModelName]
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
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.
|
|
358
|
+
|
|
295
359
|
### Development server
|
|
296
360
|
|
|
297
361
|
```bash
|
|
@@ -312,9 +376,55 @@ Equivalent npm scripts in a project created by `init`:
|
|
|
312
376
|
```bash
|
|
313
377
|
make dev # docker compose up -d --wait + schematic-pg dev
|
|
314
378
|
npm run dev # schematic-pg dev
|
|
379
|
+
npm run start # schematic-pg start (production)
|
|
315
380
|
npm run generate # schematic-pg generate
|
|
316
381
|
```
|
|
317
382
|
|
|
383
|
+
### Production server
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
schematic-pg start [schema] [--no-migrate]
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`start` runs the app in production mode — no code generation, no schema watching:
|
|
390
|
+
|
|
391
|
+
1. Verifies `generated/app.ts` exists (run `generate` in your build step if missing)
|
|
392
|
+
2. Waits for PostgreSQL to accept connections
|
|
393
|
+
3. Applies pending migration files (default; skip with `--no-migrate`)
|
|
394
|
+
4. Starts `generated/app.ts` with `NODE_ENV=production` until exit
|
|
395
|
+
|
|
396
|
+
The optional `[schema]` argument is only used for migration snapshot resolution (same as `db:migrate`).
|
|
397
|
+
|
|
398
|
+
| Step | `dev` | `start` |
|
|
399
|
+
|------|-------|---------|
|
|
400
|
+
| Generate code | Yes | No |
|
|
401
|
+
| DB bootstrap | Yes | No |
|
|
402
|
+
| Apply pending migrations | No | Yes (default) |
|
|
403
|
+
| Wait for Postgres | Yes (via bootstrap) | Yes |
|
|
404
|
+
| Schema file watch | Yes (default) | No |
|
|
405
|
+
| `NODE_ENV` | unset | `production` |
|
|
406
|
+
|
|
407
|
+
Example deploy flow:
|
|
408
|
+
|
|
409
|
+
```bash
|
|
410
|
+
npx schematic-pg generate # build step in CI
|
|
411
|
+
npx schematic-pg start # migrate DB + run server
|
|
412
|
+
# or: npm run start
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Pass `--no-migrate` when migrations are applied separately (e.g. in a release job):
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
npx schematic-pg db:migrate
|
|
419
|
+
npx schematic-pg start --no-migrate
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Equivalent npm scripts in a project created by `init`:
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
npm run start # schematic-pg start
|
|
426
|
+
```
|
|
427
|
+
|
|
318
428
|
### Database commands
|
|
319
429
|
|
|
320
430
|
```bash
|
|
@@ -355,6 +465,7 @@ npm run generate:client # write generated/db*.ts
|
|
|
355
465
|
npm run generate:api # write generated/app.ts, routes/, schemas/
|
|
356
466
|
npm run db:bootstrap # apply DDL + snapshot schema state
|
|
357
467
|
npm run dev:api # regenerate client + API and start server on :3000
|
|
468
|
+
npm run start # production server (migrate + run generated/app.ts)
|
|
358
469
|
npm test # unit tests
|
|
359
470
|
npm run test:integration # Docker + generate + DB client + ACL integration tests
|
|
360
471
|
```
|
|
@@ -676,7 +787,15 @@ npx schematic-pg dev
|
|
|
676
787
|
# → regenerates client + API, then starts http://localhost:3000
|
|
677
788
|
```
|
|
678
789
|
|
|
679
|
-
|
|
790
|
+
For production (no regenerate, no schema watch):
|
|
791
|
+
|
|
792
|
+
```bash
|
|
793
|
+
npx schematic-pg start
|
|
794
|
+
# or: npm run start
|
|
795
|
+
# → waits for DB, applies pending migrations, starts http://localhost:3000
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
Or run the generated entry point directly after generation (skips migration wait):
|
|
680
799
|
|
|
681
800
|
```bash
|
|
682
801
|
npx tsx generated/app.ts
|
|
@@ -764,6 +883,102 @@ router.get('/me', async (c) => {
|
|
|
764
883
|
|
|
765
884
|
**Do not edit** `generated/app.ts` manually for custom routes — add files under `src/routes/` and regenerate.
|
|
766
885
|
|
|
886
|
+
### Lifecycle hooks
|
|
887
|
+
|
|
888
|
+
Run business logic before or after write operations (POST, PUT, DELETE) without editing generated route files. Hooks live under `src/hooks/` and are discovered when you run `schematic-pg generate:api` (or `schematic-pg dev`).
|
|
889
|
+
|
|
890
|
+
**Convention**
|
|
891
|
+
|
|
892
|
+
| Rule | Example |
|
|
893
|
+
|------|---------|
|
|
894
|
+
| Location | `src/hooks/{Model}.ts` (PascalCase model name) |
|
|
895
|
+
| Export | `export default defineHooks(...)` |
|
|
896
|
+
| Scaffold | `schematic-pg hooks:add` (interactive model picker) |
|
|
897
|
+
| Regenerate | `schematic-pg generate:api` or `schematic-pg dev` after adding or editing hooks |
|
|
898
|
+
|
|
899
|
+
**Request flow**
|
|
900
|
+
|
|
901
|
+
```
|
|
902
|
+
validate → assertPolicy → beforeHooks (can cancel) → db.create/update/delete → afterHooks → response
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
Before-hooks run after policy checks — unauthorized requests never execute hook logic. After-hooks see the DB result and can mutate `ctx.result` before the response is sent.
|
|
906
|
+
|
|
907
|
+
**`next()` contract**
|
|
908
|
+
|
|
909
|
+
| Action | Effect |
|
|
910
|
+
|--------|--------|
|
|
911
|
+
| `await next()` | Proceed to the next hook, or to the DB operation when all hooks call `next()` |
|
|
912
|
+
| `return ctx.abort(status, message)` | Cancel; return that JSON error response; DB untouched |
|
|
913
|
+
| `return ctx.json(body, status)` | Cancel with a custom response body |
|
|
914
|
+
| Return without calling `next()` | Cancel with default `409` |
|
|
915
|
+
| `throw` | Cancel; mapped by global error handler |
|
|
916
|
+
|
|
917
|
+
**Scaffold a hook file**
|
|
918
|
+
|
|
919
|
+
```bash
|
|
920
|
+
schematic-pg hooks:add # interactive — pick a model
|
|
921
|
+
schematic-pg hooks:add --model User
|
|
922
|
+
# edit src/hooks/User.ts — delete unused hooks
|
|
923
|
+
schematic-pg generate:api
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
**Example** — `src/hooks/User.ts`:
|
|
927
|
+
|
|
928
|
+
```typescript
|
|
929
|
+
import { defineHooks } from 'schematic-pg/api/hooks';
|
|
930
|
+
import type { User, UserCreateInput, UserUpdateInput } from '../../generated/db-types.js';
|
|
931
|
+
|
|
932
|
+
export default defineHooks<User, UserCreateInput, UserUpdateInput>({
|
|
933
|
+
async beforeCreate(ctx, next) {
|
|
934
|
+
ctx.data.email = ctx.data.email.toLowerCase();
|
|
935
|
+
if (ctx.data.balance < 0) {
|
|
936
|
+
return ctx.abort(422, 'balance must be >= 0');
|
|
937
|
+
}
|
|
938
|
+
await next();
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
async afterCreate(ctx) {
|
|
942
|
+
await ctx.db.log.create({
|
|
943
|
+
level: 'info',
|
|
944
|
+
message: `User created: ${ctx.result.id}`,
|
|
945
|
+
});
|
|
946
|
+
},
|
|
947
|
+
|
|
948
|
+
async beforeDelete(ctx, next) {
|
|
949
|
+
if (ctx.auth.role !== 'ADMIN') {
|
|
950
|
+
return ctx.abort(403, 'Only admins can delete users');
|
|
951
|
+
}
|
|
952
|
+
await next();
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
After `schematic-pg generate:api`, `generated/routes/users.ts` wraps POST/PUT/DELETE with hook calls, and `generated/app.ts` loads the registry:
|
|
958
|
+
|
|
959
|
+
```typescript
|
|
960
|
+
import { HOOKS } from './hooks.js';
|
|
961
|
+
import { configureHooks } from 'schematic-pg/api/hooks';
|
|
962
|
+
// ...
|
|
963
|
+
configureHooks(HOOKS);
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
**Hook context**
|
|
967
|
+
|
|
968
|
+
| Field | beforeCreate / beforeUpdate | beforeDelete | after* |
|
|
969
|
+
|-------|----------------------------|--------------|--------|
|
|
970
|
+
| `ctx.data` | Create/update payload (mutable) | — | — |
|
|
971
|
+
| `ctx.params` | Route params (`:id`, composite PK fields) | Route params | Route params |
|
|
972
|
+
| `ctx.result` | — | — | DB row (mutable) |
|
|
973
|
+
| `ctx.auth` | JWT auth context | JWT auth context | JWT auth context |
|
|
974
|
+
| `ctx.db` | Generated DB client | Generated DB client | Generated DB client |
|
|
975
|
+
| `ctx.abort(status, msg)` | Cancel with JSON error | Cancel with JSON error | — |
|
|
976
|
+
| `ctx.json(body, status)` | Cancel with custom body | Cancel with custom body | — |
|
|
977
|
+
|
|
978
|
+
**Skipped files** — The scanner ignores `*.test.ts`, `*.d.ts`, and any file whose name starts with `_`.
|
|
979
|
+
|
|
980
|
+
**Do not edit** generated route hook wiring manually — add or edit files under `src/hooks/` and regenerate.
|
|
981
|
+
|
|
767
982
|
### Endpoints
|
|
768
983
|
|
|
769
984
|
Every router exposes the same CRUD shape. Models with `@policy` attributes enforce role checks and row-level filters on every handler; models without policies behave as open endpoints.
|
|
@@ -1136,6 +1351,7 @@ my-app/
|
|
|
1136
1351
|
│ ├── db-model-meta.ts # Runtime column metadata
|
|
1137
1352
|
│ ├── app.ts # Hono entry point (starts server on :3000)
|
|
1138
1353
|
│ ├── policies.ts # Generated ACL metadata from @policy
|
|
1354
|
+
│ ├── hooks.ts # Registry of src/hooks/* (wired at startup)
|
|
1139
1355
|
│ ├── routes/
|
|
1140
1356
|
│ │ ├── users.ts
|
|
1141
1357
|
│ │ ├── profiles.ts
|
|
@@ -1143,11 +1359,13 @@ my-app/
|
|
|
1143
1359
|
│ └── schemas/
|
|
1144
1360
|
│ └── validation.ts # Generated Zod schemas
|
|
1145
1361
|
└── src/
|
|
1146
|
-
|
|
1147
|
-
|
|
1362
|
+
├── routes/
|
|
1363
|
+
│ └── health.ts # Custom route → GET /health
|
|
1364
|
+
└── hooks/
|
|
1365
|
+
└── User.ts # Lifecycle hooks → POST/PUT/DELETE /users
|
|
1148
1366
|
```
|
|
1149
1367
|
|
|
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
|
|
1368
|
+
Framework runtime (query builder, auth middleware, validation, hook registry) is **not** copied into your project — it is imported from `node_modules/schematic-pg` at runtime. Only `generated/`, `src/routes/`, and `src/hooks/` contain project-specific code.
|
|
1151
1369
|
|
|
1152
1370
|
### This repository (framework source)
|
|
1153
1371
|
|
|
@@ -1185,7 +1403,7 @@ postgrest.js/
|
|
|
1185
1403
|
|
|
1186
1404
|
## Roadmap
|
|
1187
1405
|
|
|
1188
|
-
- [x] npm package + CLI (`schematic-pg init`, `generate`, `dev`, `db:*`)
|
|
1406
|
+
- [x] npm package + CLI (`schematic-pg init`, `generate`, `dev`, `start`, `db:*`)
|
|
1189
1407
|
- [x] Hand-written lexer & recursive-descent parser
|
|
1190
1408
|
- [x] SQL DDL generator (full regeneration)
|
|
1191
1409
|
- [x] Type-safe database client generator (`createDbClient`, parameterized query builder)
|
|
@@ -1195,6 +1413,7 @@ postgrest.js/
|
|
|
1195
1413
|
- [x] Row-level policy injection (`WHERE` clause from `where:` templates)
|
|
1196
1414
|
- [x] JWT authentication (default Bearer resolver, pluggable `AuthResolver`)
|
|
1197
1415
|
- [x] Custom routes (`src/routes/` auto-imported into generated app)
|
|
1416
|
+
- [x] Lifecycle hooks (`src/hooks/` before/after create-update-delete, `hooks:add` CLI)
|
|
1198
1417
|
- [x] Relation `include` in DB client (nested eager-loading, split + json_agg strategies)
|
|
1199
1418
|
- [ ] Type generation for frontend consumption
|
|
1200
1419
|
- [ ] Tree-sitter grammar for editor support
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AfterHook, BeforeHook, ModelHooks } from './types.js';
|
|
2
|
+
export interface TypedModelHooks<TRow, TCreate, TUpdate> {
|
|
3
|
+
beforeCreate?: BeforeHook | BeforeHook[];
|
|
4
|
+
afterCreate?: AfterHook | AfterHook[];
|
|
5
|
+
beforeUpdate?: BeforeHook | BeforeHook[];
|
|
6
|
+
afterUpdate?: AfterHook | AfterHook[];
|
|
7
|
+
beforeDelete?: BeforeHook | BeforeHook[];
|
|
8
|
+
afterDelete?: AfterHook | AfterHook[];
|
|
9
|
+
}
|
|
10
|
+
export declare function defineHooks<TRow, TCreate, TUpdate>(hooks: TypedModelHooks<TRow, TCreate, TUpdate>): ModelHooks;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { defineHooks } from './define.js';
|
|
2
|
+
export type { TypedModelHooks } from './define.js';
|
|
3
|
+
export { cancelledResponse, configureHooks, createHookContext, runAfterHooks, runBeforeHooks, } from './registry.js';
|
|
4
|
+
export type { AfterHook, AfterHookContext, BeforeHook, BeforeHookContext, BeforeHookNext, BeforeHookResult, CreateHookContextInput, HookOperation, HookRegistry, ModelHooks, } from './types.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { AppEnv } from '../types.js';
|
|
3
|
+
import type { AfterHookContext, BeforeHookContext, BeforeHookResult, CreateHookContextInput, HookOperation, HookRegistry } from './types.js';
|
|
4
|
+
export declare function configureHooks(next: HookRegistry): void;
|
|
5
|
+
export declare function createHookContext(init: CreateHookContextInput): BeforeHookContext;
|
|
6
|
+
export declare function cancelledResponse(c: Context<AppEnv>): Response;
|
|
7
|
+
export declare function runBeforeHooks(model: string, operation: HookOperation, ctx: BeforeHookContext): Promise<BeforeHookResult>;
|
|
8
|
+
export declare function runAfterHooks(model: string, operation: HookOperation, ctx: AfterHookContext): Promise<void>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const HOOK_CANCELLED_STATUS = 409;
|
|
2
|
+
const HOOK_CANCELLED_MESSAGE = 'Operation cancelled by lifecycle hook';
|
|
3
|
+
const BEFORE_HOOK_KEYS = {
|
|
4
|
+
create: 'beforeCreate',
|
|
5
|
+
update: 'beforeUpdate',
|
|
6
|
+
delete: 'beforeDelete',
|
|
7
|
+
};
|
|
8
|
+
const AFTER_HOOK_KEYS = {
|
|
9
|
+
create: 'afterCreate',
|
|
10
|
+
update: 'afterUpdate',
|
|
11
|
+
delete: 'afterDelete',
|
|
12
|
+
};
|
|
13
|
+
let hooks = {};
|
|
14
|
+
export function configureHooks(next) {
|
|
15
|
+
hooks = next;
|
|
16
|
+
}
|
|
17
|
+
export function createHookContext(init) {
|
|
18
|
+
return {
|
|
19
|
+
model: init.model,
|
|
20
|
+
operation: init.operation,
|
|
21
|
+
auth: init.auth,
|
|
22
|
+
params: init.params,
|
|
23
|
+
db: init.db,
|
|
24
|
+
c: init.c,
|
|
25
|
+
data: init.data ?? {},
|
|
26
|
+
result: init.result,
|
|
27
|
+
abort(status, message) {
|
|
28
|
+
return init.c.json({ error: message }, status);
|
|
29
|
+
},
|
|
30
|
+
json(body, status = 200) {
|
|
31
|
+
return init.c.json(body, status);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function cancelledResponse(c) {
|
|
36
|
+
return c.json({ error: HOOK_CANCELLED_MESSAGE }, HOOK_CANCELLED_STATUS);
|
|
37
|
+
}
|
|
38
|
+
export async function runBeforeHooks(model, operation, ctx) {
|
|
39
|
+
const modelHooks = hooks[model];
|
|
40
|
+
if (!modelHooks) {
|
|
41
|
+
return { proceed: true };
|
|
42
|
+
}
|
|
43
|
+
const hookDef = modelHooks[BEFORE_HOOK_KEYS[operation]];
|
|
44
|
+
if (!hookDef) {
|
|
45
|
+
return { proceed: true };
|
|
46
|
+
}
|
|
47
|
+
const hookList = normalizeHookList(hookDef);
|
|
48
|
+
let index = -1;
|
|
49
|
+
return dispatchBeforeHooks(hookList, ctx, 0, () => index, (nextIndex) => {
|
|
50
|
+
index = nextIndex;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export async function runAfterHooks(model, operation, ctx) {
|
|
54
|
+
const modelHooks = hooks[model];
|
|
55
|
+
if (!modelHooks) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const hookDef = modelHooks[AFTER_HOOK_KEYS[operation]];
|
|
59
|
+
if (!hookDef) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const hookList = normalizeHookList(hookDef);
|
|
63
|
+
for (const hook of hookList) {
|
|
64
|
+
await hook(ctx);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function normalizeHookList(hookDef) {
|
|
68
|
+
return Array.isArray(hookDef) ? hookDef : [hookDef];
|
|
69
|
+
}
|
|
70
|
+
async function dispatchBeforeHooks(hookList, ctx, currentIndex, getDispatchedIndex, setDispatchedIndex) {
|
|
71
|
+
if (currentIndex <= getDispatchedIndex()) {
|
|
72
|
+
throw new Error('next() called multiple times');
|
|
73
|
+
}
|
|
74
|
+
setDispatchedIndex(currentIndex);
|
|
75
|
+
if (currentIndex === hookList.length) {
|
|
76
|
+
return { proceed: true };
|
|
77
|
+
}
|
|
78
|
+
const hook = hookList[currentIndex];
|
|
79
|
+
let innerResult = { proceed: false };
|
|
80
|
+
let nextCalled = false;
|
|
81
|
+
const next = async () => {
|
|
82
|
+
nextCalled = true;
|
|
83
|
+
innerResult = await dispatchBeforeHooks(hookList, ctx, currentIndex + 1, getDispatchedIndex, setDispatchedIndex);
|
|
84
|
+
return innerResult;
|
|
85
|
+
};
|
|
86
|
+
const result = await hook(ctx, next);
|
|
87
|
+
if (result instanceof Response) {
|
|
88
|
+
return { proceed: false, response: result };
|
|
89
|
+
}
|
|
90
|
+
if (!nextCalled) {
|
|
91
|
+
return { proceed: false };
|
|
92
|
+
}
|
|
93
|
+
return innerResult;
|
|
94
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { DbClient } from 'generated/db.js';
|
|
3
|
+
import type { AuthContext } from '../auth/types.js';
|
|
4
|
+
import type { AppEnv } from '../types.js';
|
|
5
|
+
export type HookOperation = 'create' | 'update' | 'delete';
|
|
6
|
+
export interface HookContextBase {
|
|
7
|
+
model: string;
|
|
8
|
+
operation: HookOperation;
|
|
9
|
+
auth: AuthContext;
|
|
10
|
+
params?: Record<string, unknown>;
|
|
11
|
+
db: DbClient;
|
|
12
|
+
c: Context<AppEnv>;
|
|
13
|
+
abort: (status: number, message: string) => Response;
|
|
14
|
+
json: (body: unknown, status?: number) => Response;
|
|
15
|
+
}
|
|
16
|
+
export interface BeforeHookContext<TData = Record<string, unknown>> extends HookContextBase {
|
|
17
|
+
data: TData;
|
|
18
|
+
result?: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface AfterHookContext<TRow = Record<string, unknown>> extends HookContextBase {
|
|
21
|
+
result: TRow;
|
|
22
|
+
}
|
|
23
|
+
export type BeforeHookNext = () => Promise<BeforeHookResult>;
|
|
24
|
+
export type BeforeHook = (ctx: BeforeHookContext, next: BeforeHookNext) => Promise<Response | void>;
|
|
25
|
+
export type AfterHook = (ctx: AfterHookContext) => Promise<void>;
|
|
26
|
+
export interface ModelHooks {
|
|
27
|
+
beforeCreate?: BeforeHook | BeforeHook[];
|
|
28
|
+
afterCreate?: AfterHook | AfterHook[];
|
|
29
|
+
beforeUpdate?: BeforeHook | BeforeHook[];
|
|
30
|
+
afterUpdate?: AfterHook | AfterHook[];
|
|
31
|
+
beforeDelete?: BeforeHook | BeforeHook[];
|
|
32
|
+
afterDelete?: AfterHook | AfterHook[];
|
|
33
|
+
}
|
|
34
|
+
export type HookRegistry = Record<string, ModelHooks>;
|
|
35
|
+
export interface BeforeHookResult {
|
|
36
|
+
proceed: boolean;
|
|
37
|
+
response?: Response;
|
|
38
|
+
}
|
|
39
|
+
export interface CreateHookContextInput {
|
|
40
|
+
c: Context<AppEnv>;
|
|
41
|
+
db: DbClient;
|
|
42
|
+
auth: AuthContext;
|
|
43
|
+
model: string;
|
|
44
|
+
operation: HookOperation;
|
|
45
|
+
data?: Record<string, unknown>;
|
|
46
|
+
params?: Record<string, unknown>;
|
|
47
|
+
result?: unknown;
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -39,7 +39,9 @@ export class AppGenerator {
|
|
|
39
39
|
routeImports,
|
|
40
40
|
"import { createDbClient } from './db.js';",
|
|
41
41
|
"import { POLICIES } from './policies.js';",
|
|
42
|
+
"import { HOOKS } from './hooks.js';",
|
|
42
43
|
`import { configurePolicies } from '${PACKAGE_NAME}/api/auth/policy';`,
|
|
44
|
+
`import { configureHooks } from '${PACKAGE_NAME}/api/hooks';`,
|
|
43
45
|
`import { createAuthMiddleware } from '${PACKAGE_NAME}/api/auth/middleware';`,
|
|
44
46
|
`import { createJwtResolver } from '${PACKAGE_NAME}/api/auth/jwt-resolver';`,
|
|
45
47
|
`import type { AuthResolver } from '${PACKAGE_NAME}/api/auth/types';`,
|
|
@@ -48,6 +50,7 @@ export class AppGenerator {
|
|
|
48
50
|
`import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
|
|
49
51
|
'',
|
|
50
52
|
'configurePolicies(POLICIES);',
|
|
53
|
+
'configureHooks(HOOKS);',
|
|
51
54
|
'',
|
|
52
55
|
'export interface CreateAppOptions {',
|
|
53
56
|
' pool?: Pool;',
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Schema } from '../schema-dsl/ast.js';
|
|
2
|
+
export interface HookMountEntry {
|
|
3
|
+
modelName: string;
|
|
4
|
+
importName: string;
|
|
5
|
+
importPath: string;
|
|
6
|
+
}
|
|
7
|
+
export interface HookDiscoveryResult {
|
|
8
|
+
entries: HookMountEntry[];
|
|
9
|
+
modelsWithHooks: Set<string>;
|
|
10
|
+
}
|
|
11
|
+
export declare function discoverHooks(hooksDir: string, schema: Schema): HookDiscoveryResult;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
function isHookFile(filename) {
|
|
3
|
+
return (filename.endsWith('.ts') &&
|
|
4
|
+
!filename.endsWith('.test.ts') &&
|
|
5
|
+
!filename.endsWith('.d.ts') &&
|
|
6
|
+
!filename.startsWith('_'));
|
|
7
|
+
}
|
|
8
|
+
function toHookImportName(modelName) {
|
|
9
|
+
return `${modelName.charAt(0).toLowerCase()}${modelName.slice(1)}Hooks`;
|
|
10
|
+
}
|
|
11
|
+
export function discoverHooks(hooksDir, schema) {
|
|
12
|
+
if (!existsSync(hooksDir)) {
|
|
13
|
+
return { entries: [], modelsWithHooks: new Set() };
|
|
14
|
+
}
|
|
15
|
+
const modelNames = new Set(schema.models.map((model) => model.name));
|
|
16
|
+
const entries = [];
|
|
17
|
+
const modelsWithHooks = new Set();
|
|
18
|
+
for (const filename of readdirSync(hooksDir)) {
|
|
19
|
+
if (!isHookFile(filename)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const modelName = filename.replace(/\.ts$/, '');
|
|
23
|
+
if (!modelNames.has(modelName)) {
|
|
24
|
+
console.warn(`Skipping hook file "${filename}": no matching model in schema`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
entries.push({
|
|
28
|
+
modelName,
|
|
29
|
+
importName: toHookImportName(modelName),
|
|
30
|
+
importPath: `../src/hooks/${modelName}.js`,
|
|
31
|
+
});
|
|
32
|
+
modelsWithHooks.add(modelName);
|
|
33
|
+
}
|
|
34
|
+
entries.sort((left, right) => left.modelName.localeCompare(right.modelName));
|
|
35
|
+
return { entries, modelsWithHooks };
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function generateHooksFile(entries) {
|
|
2
|
+
if (entries.length === 0) {
|
|
3
|
+
return [
|
|
4
|
+
'// Auto-generated by HooksGenerator. Do not edit manually.',
|
|
5
|
+
'',
|
|
6
|
+
'export const HOOKS = {};',
|
|
7
|
+
'',
|
|
8
|
+
].join('\n');
|
|
9
|
+
}
|
|
10
|
+
const imports = entries
|
|
11
|
+
.map((entry) => `import ${entry.importName} from '${entry.importPath}';`)
|
|
12
|
+
.join('\n');
|
|
13
|
+
const registryEntries = entries
|
|
14
|
+
.map((entry) => ` ${entry.modelName}: ${entry.importName},`)
|
|
15
|
+
.join('\n');
|
|
16
|
+
return [
|
|
17
|
+
'// Auto-generated by HooksGenerator. Do not edit manually.',
|
|
18
|
+
imports,
|
|
19
|
+
'',
|
|
20
|
+
'export const HOOKS = {',
|
|
21
|
+
registryEntries,
|
|
22
|
+
'};',
|
|
23
|
+
'',
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
@@ -3,9 +3,11 @@ export interface GeneratedApiFiles {
|
|
|
3
3
|
app: string;
|
|
4
4
|
policies: string;
|
|
5
5
|
validation: string;
|
|
6
|
+
hooks: string;
|
|
6
7
|
routes: Map<string, string>;
|
|
7
8
|
}
|
|
8
9
|
export interface GenerateApiFilesOptions {
|
|
9
10
|
customRoutesDir?: string;
|
|
11
|
+
hooksDir?: string;
|
|
10
12
|
}
|
|
11
13
|
export declare function generateApiFiles(schema: Schema, options?: GenerateApiFilesOptions): GeneratedApiFiles;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { DEFAULT_HOOKS_DIR } from '../cli/paths.js';
|
|
1
2
|
import { generateAppFile } from './app-generator.js';
|
|
3
|
+
import { discoverHooks } from './hook-scanner.js';
|
|
4
|
+
import { generateHooksFile } from './hooks-generator.js';
|
|
2
5
|
import { generatePoliciesFile } from './policy-generator.js';
|
|
3
6
|
import { generateRouteFiles } from './route-generator.js';
|
|
4
7
|
import { generateValidationSchemas } from './zod-schema-generator.js';
|
|
@@ -6,10 +9,13 @@ export function generateApiFiles(schema, options) {
|
|
|
6
9
|
const appOptions = options?.customRoutesDir
|
|
7
10
|
? { customRoutesDir: options.customRoutesDir }
|
|
8
11
|
: undefined;
|
|
12
|
+
const hooksDir = options?.hooksDir ?? DEFAULT_HOOKS_DIR;
|
|
13
|
+
const { entries: hookEntries, modelsWithHooks } = discoverHooks(hooksDir, schema);
|
|
9
14
|
return {
|
|
10
15
|
app: generateAppFile(schema, appOptions),
|
|
11
16
|
policies: generatePoliciesFile(schema),
|
|
12
17
|
validation: generateValidationSchemas(schema),
|
|
13
|
-
|
|
18
|
+
hooks: generateHooksFile(hookEntries),
|
|
19
|
+
routes: generateRouteFiles(schema, modelsWithHooks),
|
|
14
20
|
};
|
|
15
21
|
}
|
|
@@ -2,7 +2,8 @@ import type { Model, Schema } from '../schema-dsl/ast.js';
|
|
|
2
2
|
export declare class RouteGenerator {
|
|
3
3
|
private readonly model;
|
|
4
4
|
private readonly schema;
|
|
5
|
-
|
|
5
|
+
private readonly modelsWithHooks;
|
|
6
|
+
constructor(model: Model, schema: Schema, modelsWithHooks?: ReadonlySet<string>);
|
|
6
7
|
generate(): string;
|
|
7
8
|
private jsonRow;
|
|
8
9
|
private jsonRows;
|
|
@@ -15,7 +16,7 @@ export declare class RouteGenerator {
|
|
|
15
16
|
getRouteFileName(): string;
|
|
16
17
|
getRouteBasePath(): string;
|
|
17
18
|
}
|
|
18
|
-
export declare function generateRouteFiles(schema: Schema): Map<string, string>;
|
|
19
|
+
export declare function generateRouteFiles(schema: Schema, modelsWithHooks?: ReadonlySet<string>): Map<string, string>;
|
|
19
20
|
export declare function getRouteMountEntries(schema: Schema): {
|
|
20
21
|
basePath: string;
|
|
21
22
|
fileName: string;
|