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 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,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
- └── routes/
1147
- └── 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
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/` 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.
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,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 {};
@@ -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,2 @@
1
+ import type { HookMountEntry } from './hook-scanner.js';
2
+ export declare function generateHooksFile(entries: HookMountEntry[]): string;
@@ -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
- routes: generateRouteFiles(schema),
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
- constructor(model: Model, schema: Schema);
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;