schematic-pg 0.1.6 → 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.
Files changed (45) hide show
  1. package/README.md +256 -11
  2. package/dist/api/hooks/define.d.ts +10 -0
  3. package/dist/api/hooks/define.js +3 -0
  4. package/dist/api/hooks/index.d.ts +4 -0
  5. package/dist/api/hooks/index.js +2 -0
  6. package/dist/api/hooks/registry.d.ts +8 -0
  7. package/dist/api/hooks/registry.js +94 -0
  8. package/dist/api/hooks/types.d.ts +48 -0
  9. package/dist/api/hooks/types.js +1 -0
  10. package/dist/api/utils/include-query.d.ts +6 -0
  11. package/dist/api/utils/include-query.js +75 -0
  12. package/dist/api/utils/read-query.d.ts +12 -0
  13. package/dist/api/utils/read-query.js +10 -0
  14. package/dist/api/utils/response-shape.d.ts +2 -0
  15. package/dist/api/utils/response-shape.js +28 -0
  16. package/dist/api-generator/app-generator.js +3 -0
  17. package/dist/api-generator/hook-scanner.d.ts +11 -0
  18. package/dist/api-generator/hook-scanner.js +36 -0
  19. package/dist/api-generator/hooks-generator.d.ts +2 -0
  20. package/dist/api-generator/hooks-generator.js +25 -0
  21. package/dist/api-generator/index.d.ts +2 -0
  22. package/dist/api-generator/index.js +7 -1
  23. package/dist/api-generator/route-generator.d.ts +4 -2
  24. package/dist/api-generator/route-generator.js +132 -82
  25. package/dist/api-generator/utils/api-fields.d.ts +6 -0
  26. package/dist/api-generator/utils/api-fields.js +32 -0
  27. package/dist/api-generator/zod-schema-generator.d.ts +3 -0
  28. package/dist/api-generator/zod-schema-generator.js +66 -8
  29. package/dist/cli/dev.js +5 -36
  30. package/dist/cli/generate.js +1 -0
  31. package/dist/cli/hooks.d.ts +6 -0
  32. package/dist/cli/hooks.js +85 -0
  33. package/dist/cli/init.js +6 -1
  34. package/dist/cli/paths.d.ts +1 -0
  35. package/dist/cli/paths.js +1 -0
  36. package/dist/cli/server.d.ts +5 -0
  37. package/dist/cli/server.js +60 -0
  38. package/dist/cli/start.d.ts +7 -0
  39. package/dist/cli/start.js +35 -0
  40. package/dist/cli/templates.d.ts +2 -1
  41. package/dist/cli/templates.js +37 -1
  42. package/dist/cli.js +10 -0
  43. package/dist/constants.d.ts +1 -0
  44. package/dist/constants.js +1 -0
  45. 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? @relation(name: "UserProfile")
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
- Or run the generated entry point directly after generation:
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,14 +883,110 @@ 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.
770
985
 
771
986
  | Method | Path | Handler | Validation |
772
987
  |--------|------|---------|------------|
773
- | `GET` | `/` | `findMany({ where: mergeWhere(queryWhere, policyWhere), orderBy, take, skip })` | Query params |
774
- | `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere))` | Path params |
988
+ | `GET` | `/` | `findMany({ where: mergeWhere(queryWhere, policyWhere), orderBy, take, skip, include })` | Query params |
989
+ | `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere), { include })` | Path params + query params |
775
990
  | `POST` | `/` | `create(body)` — policy check only | JSON body |
776
991
  | `PUT` | `/{pk}` | `update({ where: mergeWhere(pk, policyWhere), data })` | Path params + JSON body |
777
992
  | `DELETE` | `/{pk}` | `delete(mergeWhere(pk, policyWhere))` | Path params |
@@ -793,17 +1008,43 @@ Query params use **API field names** (camelCase), not SQL column names:
793
1008
  | `?limit=20` | `take: 20` (max 100) |
794
1009
  | `?offset=40` | `skip: 40` |
795
1010
  | `?sort=-createdAt` | `orderBy: { createdAt: 'desc' }` |
1011
+ | `?include=profile,orders` | `include: { profile: true, orders: true }` |
1012
+ | `?include=orders.products.product` | nested boolean includes |
796
1013
 
797
1014
  On models with `@policy`, user filters are combined with the policy row filter via `mergeWhere` (AND). A USER calling `GET /users?role=ADMIN` still only sees rows allowed by policy.
798
1015
 
799
1016
  ```bash
800
1017
  curl "http://localhost:3000/products?category=books&limit=10"
801
1018
  curl "http://localhost:3000/users?role=USER&isActive=true" -H "Authorization: Bearer $TOKEN"
1019
+ curl "http://localhost:3000/users/USER_ID?include=profile,orders" -H "Authorization: Bearer $TOKEN"
802
1020
  ```
803
1021
 
1022
+ ### Relation includes (`GET /`, `GET /{pk}`)
1023
+
1024
+ Load related models via the `include` query param. Paths are comma-separated; use dots for nesting:
1025
+
1026
+ ```bash
1027
+ curl "http://localhost:3000/users?include=profile,orders"
1028
+ curl "http://localhost:3000/users/USER_ID?include=orders.products.product"
1029
+ ```
1030
+
1031
+ Each segment must name a relation field on the current model (or nested target model). Unknown relations return `400`. Maximum depth and path count are capped (see `MAX_INCLUDE_DEPTH` / `MAX_INCLUDE_PATHS` in the runtime).
1032
+
1033
+ Opt out of HTTP includes on a relation field with `@unincludeable`:
1034
+
1035
+ ```ts
1036
+ orders: Order[] @unincludeable
1037
+ ```
1038
+
1039
+ **v1 limits:**
1040
+
1041
+ - Boolean includes only — no nested `where`, `take`, or `skip` via URL (use the DB client or a custom route for that).
1042
+ - `@policy` row filters apply to the **root** model only; included relations are not policy-filtered separately.
1043
+ - `@omit` fields are stripped recursively on nested included objects in read responses.
1044
+
804
1045
  ### Response shaping (`@omit`)
805
1046
 
806
- Mark sensitive stored fields with `@omit` to exclude them from generated route JSON responses (`GET`, `POST`, `PUT`, `DELETE`). The ORM client still returns full entities.
1047
+ Mark sensitive stored fields with `@omit` to exclude them from generated route JSON responses. On read endpoints with `include`, omitted fields are stripped recursively on nested relation objects as well. Mutation responses (`POST`, `PUT`, `DELETE`) strip `@omit` fields on the root model only. The ORM client still returns full entities.
807
1048
 
808
1049
  ```ts
809
1050
  passwordHash: VARCHAR(255) @omit @unfilterable @default("")
@@ -1110,6 +1351,7 @@ my-app/
1110
1351
  │ ├── db-model-meta.ts # Runtime column metadata
1111
1352
  │ ├── app.ts # Hono entry point (starts server on :3000)
1112
1353
  │ ├── policies.ts # Generated ACL metadata from @policy
1354
+ │ ├── hooks.ts # Registry of src/hooks/* (wired at startup)
1113
1355
  │ ├── routes/
1114
1356
  │ │ ├── users.ts
1115
1357
  │ │ ├── profiles.ts
@@ -1117,11 +1359,13 @@ my-app/
1117
1359
  │ └── schemas/
1118
1360
  │ └── validation.ts # Generated Zod schemas
1119
1361
  └── src/
1120
- └── routes/
1121
- └── health.ts # Custom route → GET /health
1362
+ ├── routes/
1363
+ └── health.ts # Custom route → GET /health
1364
+ └── hooks/
1365
+ └── User.ts # Lifecycle hooks → POST/PUT/DELETE /users
1122
1366
  ```
1123
1367
 
1124
- 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.
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.
1125
1369
 
1126
1370
  ### This repository (framework source)
1127
1371
 
@@ -1159,7 +1403,7 @@ postgrest.js/
1159
1403
 
1160
1404
  ## Roadmap
1161
1405
 
1162
- - [x] npm package + CLI (`schematic-pg init`, `generate`, `dev`, `db:*`)
1406
+ - [x] npm package + CLI (`schematic-pg init`, `generate`, `dev`, `start`, `db:*`)
1163
1407
  - [x] Hand-written lexer & recursive-descent parser
1164
1408
  - [x] SQL DDL generator (full regeneration)
1165
1409
  - [x] Type-safe database client generator (`createDbClient`, parameterized query builder)
@@ -1169,6 +1413,7 @@ postgrest.js/
1169
1413
  - [x] Row-level policy injection (`WHERE` clause from `where:` templates)
1170
1414
  - [x] JWT authentication (default Bearer resolver, pluggable `AuthResolver`)
1171
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)
1172
1417
  - [x] Relation `include` in DB client (nested eager-loading, split + json_agg strategies)
1173
1418
  - [ ] Type generation for frontend consumption
1174
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,3 @@
1
+ export function defineHooks(hooks) {
2
+ return hooks;
3
+ }
@@ -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,2 @@
1
+ export { defineHooks } from './define.js';
2
+ export { cancelledResponse, configureHooks, createHookContext, runAfterHooks, runBeforeHooks, } from './registry.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 {};
@@ -0,0 +1,6 @@
1
+ import type { IncludeInput } from '../../db/include/types.js';
2
+ export interface IncludableRelationTree {
3
+ [relationName: string]: IncludableRelationTree;
4
+ }
5
+ export declare function validateIncludePaths(raw: string, tree: IncludableRelationTree): string | undefined;
6
+ export declare function parseIncludeQuery(raw: string, tree: IncludableRelationTree): IncludeInput;
@@ -0,0 +1,75 @@
1
+ import { MAX_INCLUDE_DEPTH, MAX_INCLUDE_PATHS } from '../../constants.js';
2
+ export function validateIncludePaths(raw, tree) {
3
+ const segments = splitIncludePaths(raw);
4
+ if (segments.some((segment) => segment.length === 0)) {
5
+ return 'Include paths cannot contain empty relation segments';
6
+ }
7
+ if (segments.length === 0) {
8
+ return 'Include parameter must contain at least one relation path';
9
+ }
10
+ if (segments.length > MAX_INCLUDE_PATHS) {
11
+ return `Include parameter exceeds maximum of ${MAX_INCLUDE_PATHS} paths`;
12
+ }
13
+ for (const segment of segments) {
14
+ const parts = segment.split('.');
15
+ if (parts.length > MAX_INCLUDE_DEPTH) {
16
+ return `Include path "${segment}" exceeds maximum depth of ${MAX_INCLUDE_DEPTH}`;
17
+ }
18
+ let currentTree = tree;
19
+ for (const part of parts) {
20
+ if (!part) {
21
+ return 'Include paths cannot contain empty relation segments';
22
+ }
23
+ const nextTree = currentTree[part];
24
+ if (!nextTree) {
25
+ return `Unknown include relation "${part}" in path "${segment}"`;
26
+ }
27
+ currentTree = nextTree;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+ export function parseIncludeQuery(raw, tree) {
33
+ const error = validateIncludePaths(raw, tree);
34
+ if (error) {
35
+ throw new Error(error);
36
+ }
37
+ const root = {};
38
+ for (const segment of splitIncludePaths(raw)) {
39
+ mergeIncludePath(root, segment.split('.'));
40
+ }
41
+ return toIncludeInput(root);
42
+ }
43
+ function splitIncludePaths(raw) {
44
+ return raw.split(',').map((segment) => segment.trim());
45
+ }
46
+ function mergeIncludePath(root, parts) {
47
+ let current = root;
48
+ for (let index = 0; index < parts.length; index += 1) {
49
+ const part = parts[index];
50
+ const isLeaf = index === parts.length - 1;
51
+ if (!current[part]) {
52
+ current[part] = {};
53
+ }
54
+ if (isLeaf) {
55
+ continue;
56
+ }
57
+ if (!current[part].include) {
58
+ current[part].include = {};
59
+ }
60
+ current = current[part].include;
61
+ }
62
+ }
63
+ function toIncludeInput(nodes) {
64
+ const include = {};
65
+ for (const [relationName, node] of Object.entries(nodes)) {
66
+ if (node.include && Object.keys(node.include).length > 0) {
67
+ include[relationName] = {
68
+ include: toIncludeInput(node.include),
69
+ };
70
+ continue;
71
+ }
72
+ include[relationName] = true;
73
+ }
74
+ return include;
75
+ }
@@ -0,0 +1,12 @@
1
+ import type { FilterFieldMeta } from './list-query.js';
2
+ import { buildListQuery } from './list-query.js';
3
+ import type { IncludeInput } from '../../db/include/types.js';
4
+ import type { IncludableRelationTree } from './include-query.js';
5
+ export interface ReadQueryResult {
6
+ where: ReturnType<typeof buildListQuery>['where'];
7
+ orderBy?: ReturnType<typeof buildListQuery>['orderBy'];
8
+ take?: number;
9
+ skip?: number;
10
+ include?: IncludeInput;
11
+ }
12
+ export declare function buildReadQuery(query: Record<string, unknown>, fields: readonly FilterFieldMeta[], sortableFields: readonly string[], includableRelations: IncludableRelationTree): ReadQueryResult;
@@ -0,0 +1,10 @@
1
+ import { buildListQuery } from './list-query.js';
2
+ import { parseIncludeQuery } from './include-query.js';
3
+ export function buildReadQuery(query, fields, sortableFields, includableRelations) {
4
+ const { where, orderBy, take, skip } = buildListQuery(query, fields, sortableFields);
5
+ const result = { where, orderBy, take, skip };
6
+ if (typeof query.include === 'string' && query.include.length > 0) {
7
+ result.include = parseIncludeQuery(query.include, includableRelations);
8
+ }
9
+ return result;
10
+ }