schematic-pg 0.1.2 → 0.1.4

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 (42) hide show
  1. package/README.md +158 -19
  2. package/dist/api/middleware/validate.d.ts +10 -0
  3. package/dist/api/middleware/validate.js +3 -0
  4. package/dist/api/utils/list-query.d.ts +16 -0
  5. package/dist/api/utils/list-query.js +99 -0
  6. package/dist/api/utils/omit-fields.d.ts +2 -0
  7. package/dist/api/utils/omit-fields.js +16 -0
  8. package/dist/api-generator/route-generator.d.ts +2 -0
  9. package/dist/api-generator/route-generator.js +59 -25
  10. package/dist/api-generator/utils/api-fields.d.ts +8 -0
  11. package/dist/api-generator/utils/api-fields.js +25 -0
  12. package/dist/api-generator/utils/filter-operators.d.ts +14 -0
  13. package/dist/api-generator/utils/filter-operators.js +92 -0
  14. package/dist/api-generator/zod-schema-generator.d.ts +2 -0
  15. package/dist/api-generator/zod-schema-generator.js +55 -0
  16. package/dist/cli/init.js +27 -1
  17. package/dist/cli/templates.d.ts +1 -0
  18. package/dist/cli/templates.js +10 -2
  19. package/dist/constants.d.ts +2 -0
  20. package/dist/constants.js +5 -0
  21. package/dist/db/db-client-generator.js +14 -5
  22. package/dist/db/include/executor.d.ts +3 -0
  23. package/dist/db/include/executor.js +90 -0
  24. package/dist/db/include/hydrator.d.ts +4 -0
  25. package/dist/db/include/hydrator.js +34 -0
  26. package/dist/db/include/json-agg.d.ts +5 -0
  27. package/dist/db/include/json-agg.js +101 -0
  28. package/dist/db/include/load.d.ts +8 -0
  29. package/dist/db/include/load.js +46 -0
  30. package/dist/db/include/planner.d.ts +16 -0
  31. package/dist/db/include/planner.js +48 -0
  32. package/dist/db/include/types.d.ts +14 -0
  33. package/dist/db/include/types.js +1 -0
  34. package/dist/db/model-client.d.ts +14 -12
  35. package/dist/db/model-client.js +35 -21
  36. package/dist/db/model-meta.d.ts +13 -0
  37. package/dist/db/model-meta.js +6 -0
  38. package/dist/db/type-generator.d.ts +2 -0
  39. package/dist/db/type-generator.js +16 -0
  40. package/dist/db/utils/relations.d.ts +3 -0
  41. package/dist/db/utils/relations.js +95 -0
  42. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # schematic-pg
2
2
 
3
+ <img width="1254" height="1254" alt="schematic" src="https://github.com/user-attachments/assets/5b8f35b3-7e99-4779-aca3-ec3dbe27be56" />
4
+
5
+
3
6
  > A single-file backend framework for PostgreSQL and Node.js. Define your database schema, ACL policies, and validations in one declarative DSL — then generate the SQL, the API, and the types.
4
7
 
5
8
  ---
@@ -9,7 +12,7 @@
9
12
  Most backend frameworks force you to scatter your truth across migrations, ORM models, Zod schemas, route handlers, and access control lists. schematic-pg inverts that: **your schema definition is the source of truth** for everything — the database, the REST API, and the runtime validations.
10
13
 
11
14
  - **One file.** Schema, relations, triggers, indexes, and ACL in a single `.schema` file.
12
- - **Zero ORM.** We generate raw PostgreSQL and parameterized queries. No hidden query builders, no N+1 surprises.
15
+ - **Zero ORM.** We generate raw PostgreSQL and parameterized queries. No hidden query builders relation loading is explicit via `include`, with batched queries that avoid N+1 and cartesian explosion.
13
16
  - **Hand-written parser.** A small, fast recursive-descent lexer/parser with zero parser-generator dependencies.
14
17
  - **Hono-based runtime.** Lightweight HTTP handlers generated from your schema, with Zod validation on every write.
15
18
 
@@ -19,7 +22,7 @@ Most backend frameworks force you to scatter your truth across migrations, ORM m
19
22
 
20
23
  - **Declarative Schema DSL** — PostgreSQL-native types, enums, extensions, indexes, and triggers
21
24
  - **Automatic SQL Generation** — idempotent DDL with snake_case naming conventions
22
- - **Type-safe Database Client** — Prisma-like query API over parameterized raw SQL (`pg` Pool, no ORM)
25
+ - **Type-safe Database Client** — Prisma-like query API over parameterized raw SQL (`pg` Pool, no ORM), with nested `include` eager-loading
23
26
  - **Type-safe REST API** — Hono routes with generated Zod validation
24
27
  - **Custom routes** — Hand-written Hono routers in `src/routes/` auto-imported into the generated app
25
28
  - **Inline ACL** — Row-level and role-based access control via `@policy` directives, enforced at runtime in generated routes
@@ -179,7 +182,7 @@ models {
179
182
 
180
183
  1. **Parse** — The hand-written lexer and recursive-descent parser turn your `.schema` file into a typed AST.
181
184
  2. **Generate SQL** — The DDL generator emits idempotent PostgreSQL: extensions, enums, tables, foreign keys, indexes, and triggers. All identifiers are automatically converted to `snake_case`.
182
- 3. **Generate DB client** — The client generator emits TypeScript interfaces and a `createDbClient(pool)` factory with per-model CRUD methods backed by a runtime query builder. All SQL uses `$1`, `$2`, … placeholders — user input is never interpolated.
185
+ 3. **Generate DB client** — The client generator emits TypeScript interfaces (including `{Model}Include` types), relation metadata, and a `createDbClient(pool)` factory with per-model CRUD methods and nested `include` eager-loading. All SQL uses `$1`, `$2`, … placeholders — user input is never interpolated.
183
186
  4. **Generate API** — The route generator emits Hono routers with:
184
187
  - Zod-validated request bodies and path params (driven by `@regex` and `@range`)
185
188
  - Full CRUD handlers backed by the generated DB client
@@ -348,8 +351,8 @@ Outputs:
348
351
 
349
352
  | File | Purpose |
350
353
  |------|---------|
351
- | `generated/db-types.ts` | Per-model interfaces: `User`, `UserCreateInput`, `UserUpdateInput`, `UserWhereInput`, `UserOrderByInput`, enum unions |
352
- | `generated/db-model-meta.ts` | Serialized field/column metadata consumed at runtime |
354
+ | `generated/db-types.ts` | Per-model interfaces: `User`, `UserCreateInput`, `UserWhereInput`, `UserInclude`, enum unions |
355
+ | `generated/db-model-meta.ts` | Serialized field/column and relation metadata consumed at runtime |
353
356
  | `generated/db.ts` | `createDbClient(pool)` factory wiring all models |
354
357
 
355
358
  ### Usage
@@ -382,6 +385,29 @@ const many = await db.user.findMany({
382
385
  });
383
386
  const total = await db.user.count({ where: { role: 'ADMIN' } });
384
387
 
388
+ // Eager-load relations (nested)
389
+ const usersWithOrders = await db.user.findMany({
390
+ where: { role: 'ADMIN' },
391
+ include: {
392
+ profile: true,
393
+ orders: {
394
+ where: { status: 'PENDING' },
395
+ orderBy: { createdAt: 'desc' },
396
+ include: {
397
+ products: {
398
+ include: { product: true },
399
+ },
400
+ },
401
+ },
402
+ },
403
+ });
404
+
405
+ // include works on all read methods
406
+ const oneWithProfile = await db.user.findUnique(
407
+ { id: user.id },
408
+ { include: { profile: true } },
409
+ );
410
+
385
411
  // Update
386
412
  const updated = await db.user.update({
387
413
  where: { id: user.id },
@@ -404,10 +430,10 @@ Each model in `app.schema` becomes a camelCase property on the client (`User`
404
430
  | Method | SQL shape |
405
431
  |--------|-----------|
406
432
  | `create(data)` | `INSERT INTO … VALUES ($1, …) RETURNING *` |
407
- | `findUnique(where)` | `SELECT * … WHERE … LIMIT 1` |
408
- | `findFirst({ where, orderBy })` | `SELECT * … ORDER BY … LIMIT 1` |
409
- | `findMany({ where, orderBy, take, skip })` | `SELECT * … ORDER BY … LIMIT … OFFSET …` |
410
- | `count({ where })` | `SELECT COUNT(*) …` |
433
+ | `findUnique(where, { include, … })` | `SELECT * … WHERE … LIMIT 1` (+ batched relation queries when `include` is set) |
434
+ | `findFirst({ where, orderBy, include, … })` | `SELECT * … ORDER BY … LIMIT 1` (+ relation queries when `include` is set) |
435
+ | `findMany({ where, orderBy, take, skip, include, … })` | `SELECT * … ORDER BY … LIMIT … OFFSET …` (+ relation queries when `include` is set) |
436
+ | `count({ where })` | `SELECT COUNT(*) …` (no `include`) |
411
437
  | `update({ where, data })` | `UPDATE … SET … WHERE … RETURNING *` |
412
438
  | `updateMany({ where, data })` | `UPDATE … SET … WHERE … RETURNING *` |
413
439
  | `delete(where)` | `DELETE … WHERE … RETURNING *` |
@@ -415,6 +441,85 @@ Each model in `app.schema` becomes a camelCase property on the client (`User`
415
441
 
416
442
  Mutations return the full row (`RETURNING *`). Rows are mapped from `snake_case` columns to `camelCase` TypeScript fields.
417
443
 
444
+ ### Eager loading (`include`)
445
+
446
+ Load related models in one call and get back a nested JSON tree. Relation fields are inferred from your schema (`orders: Order[]`, `profile: Profile?`, `@relation(fields: …, references: …)`).
447
+
448
+ **Supported on:** `findMany`, `findFirst`, and `findUnique`. Not on `count`.
449
+
450
+ ```typescript
451
+ // Shorthand — load all columns for the relation
452
+ await db.user.findMany({
453
+ include: { profile: true, orders: true },
454
+ });
455
+
456
+ // Nested — arbitrary depth
457
+ await db.user.findMany({
458
+ include: {
459
+ orders: {
460
+ include: {
461
+ products: {
462
+ include: { product: true },
463
+ },
464
+ },
465
+ },
466
+ },
467
+ });
468
+
469
+ // Filter, sort, and paginate inner relations
470
+ await db.user.findMany({
471
+ include: {
472
+ orders: {
473
+ where: { status: 'PENDING' },
474
+ orderBy: { createdAt: 'desc' },
475
+ take: 5,
476
+ include: { products: true },
477
+ },
478
+ },
479
+ });
480
+ ```
481
+
482
+ Each relation key accepts `true` or a `{ where?, orderBy?, take?, skip?, include? }` object (typed as `{Model}IncludeArgs` in `generated/db-types.ts`).
483
+
484
+ **Return shape:** scalar relation fields (`profile: Profile?`) become `Profile | null`. List relations (`orders: Order[]`) become arrays on the result object. Nested `include` keys are attached on each child row the same way.
485
+
486
+ #### Loading strategies
487
+
488
+ By default the client uses **query splitting**: one SQL query for the root rows, then one batched query per included relation level (`WHERE foreign_key = ANY($1)` with deduplicated parent keys). This avoids cartesian explosion when loading multiple `hasMany` relations at the same level (e.g. `profile` + `orders` on `User`).
489
+
490
+ ```typescript
491
+ // Default — split queries (recommended for large result sets)
492
+ await db.user.findMany({
493
+ include: { orders: true },
494
+ });
495
+
496
+ // Optional — single round-trip via PostgreSQL LATERAL + json_agg
497
+ await db.user.findMany({
498
+ include: { profile: true, orders: true },
499
+ relationLoadStrategy: 'join',
500
+ });
501
+ ```
502
+
503
+ | Strategy | Option | Behavior |
504
+ |----------|--------|----------|
505
+ | Split (default) | omit or `'split'` | One query per relation edge; no row duplication on the wire |
506
+ | Join | `'join'` | One SQL statement; nested JSON built in PostgreSQL |
507
+
508
+ Use `'join'` when latency dominates (fewer round trips). Use the default split strategy when loading many rows or several sibling collections.
509
+
510
+ #### How it works
511
+
512
+ ```
513
+ findMany({ include }) → buildLoadPlan (relation tree from schema metadata)
514
+ → SELECT root rows
515
+ → for each included relation: SELECT … WHERE fk = ANY($parentIds)
516
+ → stitch (hash-join children onto parents in O(n))
517
+ ```
518
+
519
+ Relation metadata is generated into `generated/db-model-meta.ts` and resolved through an in-memory model registry inside `createDbClient`. Include depth is capped at 10 levels.
520
+
521
+ Generated types: `{Model}Include` and `{Model}IncludeArgs` in `generated/db-types.ts`.
522
+
418
523
  ### Where filters
419
524
 
420
525
  Direct values are treated as equality. Structured operators are supported per field type:
@@ -492,12 +597,12 @@ schematic-pg/dist/db/ # Published runtime (import as schematic-pg/db/*)
492
597
  ├── query-builder.ts # INSERT / SELECT / UPDATE / DELETE / COUNT
493
598
  ├── where-translator.ts # WhereInput → SQL + params
494
599
  ├── model-client.ts # createModelClient factory
600
+ ├── include/ # Eager-loading planner, executor, hydrator, json_agg
601
+ ├── utils/relations.ts # Relation graph from schema AST
495
602
  ├── row-mapper.ts # snake_case rows → camelCase + coercion
496
603
  └── errors.ts # UniqueConstraintError, ForeignKeyConstraintError, …
497
604
  ```
498
605
 
499
- Relation `include` (e.g. `findMany({ include: { profile: true } })`) is planned for a future release.
500
-
501
606
  ### Integration tests
502
607
 
503
608
  One command starts Docker Postgres, generates the client and API, and runs all integration tests (DB client + ACL):
@@ -508,7 +613,7 @@ npm run test:integration
508
613
 
509
614
  This resets the `public` schema, bootstraps from `app.schema`, seeds test data, and exercises:
510
615
 
511
- - **DB client** — CRUD, filters, and error handling ([`src/db/__tests__/db-client.integration.test.ts`](src/db/__tests__/db-client.integration.test.ts))
616
+ - **DB client** — CRUD, filters, nested `include` eager-loading, and error handling ([`src/db/__tests__/db-client.integration.test.ts`](src/db/__tests__/db-client.integration.test.ts))
512
617
  - **ACL over HTTP** — role checks, row-level filters, JWT auth, and open endpoints ([`src/api/__tests__/acl.integration.test.ts`](src/api/__tests__/acl.integration.test.ts))
513
618
 
514
619
  Tests run in-process via Hono `app.request()` against the exported `createApp()` factory from `generated/app.ts`.
@@ -640,13 +745,46 @@ Every router exposes the same CRUD shape. Models with `@policy` attributes enfor
640
745
 
641
746
  | Method | Path | Handler | Validation |
642
747
  |--------|------|---------|------------|
643
- | `GET` | `/` | `findMany({ where: policyWhere })` | |
748
+ | `GET` | `/` | `findMany({ where: mergeWhere(queryWhere, policyWhere), orderBy, take, skip })` | Query params |
644
749
  | `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere))` | Path params |
645
750
  | `POST` | `/` | `create(body)` — policy check only | JSON body |
646
751
  | `PUT` | `/{pk}` | `update({ where: mergeWhere(pk, policyWhere), data })` | Path params + JSON body |
647
752
  | `DELETE` | `/{pk}` | `delete(mergeWhere(pk, policyWhere))` | Path params |
648
753
 
649
- `POST` returns `201 Created`. Missing records on `GET` return `404`.
754
+ `POST` returns `201 Created`. Missing records on `GET` return `404`. All handlers strip `@omit` fields from JSON responses before returning.
755
+
756
+ ### Query filters (`GET /`)
757
+
758
+ All stored scalar fields are URL-filterable by default. Opt out with `@unfilterable` on a field. Fields marked `@omit` are never filterable.
759
+
760
+ Query params use **API field names** (camelCase), not SQL column names:
761
+
762
+ | Param | Maps to |
763
+ |-------|---------|
764
+ | `?role=ADMIN` | `{ role: 'ADMIN' }` |
765
+ | `?email_contains=@` | `{ email: { contains: '@' } }` |
766
+ | `?balance_gte=100` | `{ balance: { gte: 100 } }` |
767
+ | `?role_in=ADMIN,USER` | `{ role: { in: ['ADMIN', 'USER'] } }` |
768
+ | `?limit=20` | `take: 20` (max 100) |
769
+ | `?offset=40` | `skip: 40` |
770
+ | `?sort=-createdAt` | `orderBy: { createdAt: 'desc' }` |
771
+
772
+ 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.
773
+
774
+ ```bash
775
+ curl "http://localhost:3000/products?category=books&limit=10"
776
+ curl "http://localhost:3000/users?role=USER&isActive=true" -H "Authorization: Bearer $TOKEN"
777
+ ```
778
+
779
+ ### Response shaping (`@omit`)
780
+
781
+ 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.
782
+
783
+ ```ts
784
+ passwordHash: VARCHAR(255) @omit @unfilterable @default("")
785
+ ```
786
+
787
+ Generated types include `{Model}Response` (for example `UserResponse = Omit<User, 'passwordHash'>`) in `generated/schemas/validation.ts`.
650
788
 
651
789
  ### Validation
652
790
 
@@ -678,6 +816,9 @@ Fields with `@default` or optional (`?`) types are optional on create. Update sc
678
816
  # Health check (custom route from src/routes/health.ts)
679
817
  curl http://localhost:3000/health
680
818
 
819
+ # List users with filters
820
+ curl "http://localhost:3000/users?role=USER&limit=10"
821
+
681
822
  # List users
682
823
  curl http://localhost:3000/users
683
824
 
@@ -767,8 +908,6 @@ your-project/src/routes/ # Hand-written custom Hono routers (auto-imported)
767
908
 
768
909
  The generators live in this repo under `src/api-generator/` and are invoked by the CLI at build time.
769
910
 
770
- URL query-string filters for `findMany` (e.g. `?role=ADMIN`) are planned for a future release.
771
-
772
911
  ---
773
912
 
774
913
  ## Access Control (`@policy`)
@@ -966,7 +1105,7 @@ postgrest.js/
966
1105
  ├── src/
967
1106
  │ ├── schema-dsl/ # Lexer, parser, AST
968
1107
  │ ├── sql-generator/ # DDL + migration planner
969
- │ ├── db/ # Query builder + client runtime
1108
+ │ ├── db/ # Query builder + client runtime + include eager-loading
970
1109
  │ ├── api/ # Hono runtime (published as schematic-pg/api/*)
971
1110
  │ ├── api-generator/ # AST → routes, Zod, policies, app
972
1111
  │ ├── cli/ # init templates + command helpers
@@ -986,7 +1125,7 @@ postgrest.js/
986
1125
  | Schema truth | Migrations + models + Zod + routes | One `.schema` file |
987
1126
  | Query visibility | Hidden behind ORM methods | Raw, parameterized SQL |
988
1127
  | Client ergonomics | ORM model API | Generated Prisma-like client, no ORM runtime |
989
- | Performance | N+1, lazy loading pitfalls | Explicit joins, no magic |
1128
+ | Performance | N+1, lazy loading pitfalls | Explicit `include`; batched split queries by default |
990
1129
  | ACL | External service or manual checks | Inline `@policy` directives |
991
1130
  | Validation | Separate Zod schemas | Derived from `@regex` / `@range` |
992
1131
  | Dependencies | Heavy (Prisma, Drizzle, etc.) | Hono + pg + Zod + hand-written parser |
@@ -1005,7 +1144,7 @@ postgrest.js/
1005
1144
  - [x] Row-level policy injection (`WHERE` clause from `where:` templates)
1006
1145
  - [x] JWT authentication (default Bearer resolver, pluggable `AuthResolver`)
1007
1146
  - [x] Custom routes (`src/routes/` auto-imported into generated app)
1008
- - [ ] Relation `include` in DB client
1147
+ - [x] Relation `include` in DB client (nested eager-loading, split + json_agg strategies)
1009
1148
  - [ ] Type generation for frontend consumption
1010
1149
  - [ ] Tree-sitter grammar for editor support
1011
1150
  - [x] VS Code extension with syntax highlighting and language server
@@ -20,4 +20,14 @@ export declare function validateParam<T extends ZodSchema>(schema: T): import("h
20
20
  param: T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").output<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").infer<T> : never;
21
21
  };
22
22
  }, Response>;
23
+ export declare function validateQuery<T extends ZodSchema>(schema: T): import("hono").MiddlewareHandler<import("hono").Env, string, {
24
+ in: (undefined extends (T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) ? true : false) extends true ? {
25
+ query?: ([T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never] extends [Record<string, string | string[]>] ? Record<string, string | string[]> & (T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) : [Exclude<T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never, undefined>] extends [never] ? {} : [Exclude<T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never, undefined>] extends [object] ? undefined extends (T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) ? ((T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) & undefined) | (((Exclude<T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never, (T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) & undefined> extends infer T_3 ? { [K_2 in keyof T_3]: ([Exclude<T_3[K_2], undefined>] extends [string] ? [string & Exclude<T_3[K_2], undefined>] extends [import("hono/utils/types").UnionToIntersection<string & Exclude<T_3[K_2], undefined>>] ? false : true : false) extends true ? T_3[K_2] : ([unknown] extends [T_3[K_2]] ? false : undefined extends T_3[K_2] ? true : false) extends true ? T_3[K_2] : Target extends "form" ? T$1 | T$1[] : Target extends "query" ? string | string[] : Target extends "param" ? string : Target extends "header" ? string : Target extends "cookie" ? string : unknown; } : never) extends infer T_2 ? { [K_1 in keyof T_2]: T_2[K_1]; } : never) extends infer T_1 ? { [K in keyof T_1]: T_1[K]; } : never) : (((T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) extends infer T_6 ? { [K_5 in keyof T_6]: ([Exclude<T_6[K_5], undefined>] extends [string] ? [string & Exclude<T_6[K_5], undefined>] extends [import("hono/utils/types").UnionToIntersection<string & Exclude<T_6[K_5], undefined>>] ? false : true : false) extends true ? T_6[K_5] : ([unknown] extends [T_6[K_5]] ? false : undefined extends T_6[K_5] ? true : false) extends true ? T_6[K_5] : Target extends "form" ? T$1 | T$1[] : Target extends "query" ? string | string[] : Target extends "param" ? string : Target extends "header" ? string : Target extends "cookie" ? string : unknown; } : never) extends infer T_5 ? { [K_4 in keyof T_5]: T_5[K_4]; } : never) extends infer T_4 ? { [K_3 in keyof T_4]: T_4[K_3]; } : never : {}) | undefined;
26
+ } : {
27
+ query: [T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never] extends [Record<string, string | string[]>] ? Record<string, string | string[]> & (T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) : [Exclude<T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never, undefined>] extends [never] ? {} : [Exclude<T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never, undefined>] extends [object] ? undefined extends (T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) ? ((T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) & undefined) | (((Exclude<T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never, (T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) & undefined> extends infer T_9 ? { [K_2 in keyof T_9]: ([Exclude<T_9[K_2], undefined>] extends [string] ? [string & Exclude<T_9[K_2], undefined>] extends [import("hono/utils/types").UnionToIntersection<string & Exclude<T_9[K_2], undefined>>] ? false : true : false) extends true ? T_9[K_2] : ([unknown] extends [T_9[K_2]] ? false : undefined extends T_9[K_2] ? true : false) extends true ? T_9[K_2] : Target extends "form" ? T$1 | T$1[] : Target extends "query" ? string | string[] : Target extends "param" ? string : Target extends "header" ? string : Target extends "cookie" ? string : unknown; } : never) extends infer T_8 ? { [K_1 in keyof T_8]: T_8[K_1]; } : never) extends infer T_7 ? { [K in keyof T_7]: T_7[K]; } : never) : (((T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never) extends infer T_12 ? { [K_5 in keyof T_12]: ([Exclude<T_12[K_5], undefined>] extends [string] ? [string & Exclude<T_12[K_5], undefined>] extends [import("hono/utils/types").UnionToIntersection<string & Exclude<T_12[K_5], undefined>>] ? false : true : false) extends true ? T_12[K_5] : ([unknown] extends [T_12[K_5]] ? false : undefined extends T_12[K_5] ? true : false) extends true ? T_12[K_5] : Target extends "form" ? T$1 | T$1[] : Target extends "query" ? string | string[] : Target extends "param" ? string : Target extends "header" ? string : Target extends "cookie" ? string : unknown; } : never) extends infer T_11 ? { [K_4 in keyof T_11]: T_11[K_4]; } : never) extends infer T_10 ? { [K_3 in keyof T_10]: T_10[K_3]; } : never : {};
28
+ };
29
+ out: {
30
+ query: T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").output<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").infer<T> : never;
31
+ };
32
+ }, Response>;
23
33
  export type ValidationTarget = keyof ValidationTargets;
@@ -11,3 +11,6 @@ export function validateJson(schema) {
11
11
  export function validateParam(schema) {
12
12
  return zValidator('param', schema, validationHook);
13
13
  }
14
+ export function validateQuery(schema) {
15
+ return zValidator('query', schema, validationHook);
16
+ }
@@ -0,0 +1,16 @@
1
+ import type { WhereInput } from '../../db/where-translator.js';
2
+ export type FilterFieldKind = 'string' | 'enum' | 'numeric' | 'boolean' | 'timestamp' | 'json' | 'other';
3
+ export type FilterOperator = 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'gt' | 'gte' | 'lt' | 'lte' | 'in';
4
+ export interface FilterFieldMeta {
5
+ name: string;
6
+ kind: FilterFieldKind;
7
+ operators: readonly FilterOperator[];
8
+ enumValues?: readonly string[];
9
+ }
10
+ export interface ListQueryResult {
11
+ where: WhereInput;
12
+ orderBy?: Record<string, 'asc' | 'desc'>;
13
+ take?: number;
14
+ skip?: number;
15
+ }
16
+ export declare function buildListQuery(query: Record<string, unknown>, fields: readonly FilterFieldMeta[], sortableFields: readonly string[]): ListQueryResult;
@@ -0,0 +1,99 @@
1
+ function queryParamKey(fieldName, operator) {
2
+ if (operator === 'equals') {
3
+ return fieldName;
4
+ }
5
+ return `${fieldName}_${operator}`;
6
+ }
7
+ function parseFilterValue(rawValue, kind, operator) {
8
+ if (rawValue === undefined || rawValue === null || rawValue === '') {
9
+ return undefined;
10
+ }
11
+ if (operator === 'in') {
12
+ const values = String(rawValue)
13
+ .split(',')
14
+ .map((entry) => entry.trim())
15
+ .filter((entry) => entry.length > 0);
16
+ return values.length > 0 ? values : undefined;
17
+ }
18
+ if (kind === 'boolean') {
19
+ if (typeof rawValue === 'boolean') {
20
+ return rawValue;
21
+ }
22
+ const normalized = String(rawValue).toLowerCase();
23
+ if (normalized === 'true') {
24
+ return true;
25
+ }
26
+ if (normalized === 'false') {
27
+ return false;
28
+ }
29
+ return rawValue;
30
+ }
31
+ if (kind === 'numeric') {
32
+ if (typeof rawValue === 'number') {
33
+ return rawValue;
34
+ }
35
+ const parsed = Number(rawValue);
36
+ return Number.isNaN(parsed) ? rawValue : parsed;
37
+ }
38
+ if (kind === 'timestamp' && !(rawValue instanceof Date)) {
39
+ const parsed = new Date(String(rawValue));
40
+ return Number.isNaN(parsed.getTime()) ? rawValue : parsed;
41
+ }
42
+ return rawValue;
43
+ }
44
+ function buildFieldCondition(field, operator, rawValue) {
45
+ const value = parseFilterValue(rawValue, field.kind, operator);
46
+ if (value === undefined) {
47
+ return undefined;
48
+ }
49
+ if (operator === 'equals') {
50
+ return { [field.name]: value };
51
+ }
52
+ return { [field.name]: { [operator]: value } };
53
+ }
54
+ export function buildListQuery(query, fields, sortableFields) {
55
+ const whereParts = [];
56
+ for (const field of fields) {
57
+ let equalitySet = false;
58
+ for (const operator of field.operators) {
59
+ const key = queryParamKey(field.name, operator);
60
+ if (!(key in query)) {
61
+ continue;
62
+ }
63
+ const condition = buildFieldCondition(field, operator, query[key]);
64
+ if (!condition) {
65
+ continue;
66
+ }
67
+ if (operator === 'equals') {
68
+ equalitySet = true;
69
+ }
70
+ else if (equalitySet) {
71
+ continue;
72
+ }
73
+ whereParts.push(condition);
74
+ }
75
+ }
76
+ let where = {};
77
+ if (whereParts.length === 1) {
78
+ where = whereParts[0];
79
+ }
80
+ else if (whereParts.length > 1) {
81
+ where = { AND: whereParts };
82
+ }
83
+ const result = { where };
84
+ if (query.limit !== undefined && query.limit !== '') {
85
+ result.take = Number(query.limit);
86
+ }
87
+ if (query.offset !== undefined && query.offset !== '') {
88
+ result.skip = Number(query.offset);
89
+ }
90
+ if (typeof query.sort === 'string' && query.sort.length > 0) {
91
+ const descending = query.sort.startsWith('-');
92
+ const fieldName = descending ? query.sort.slice(1) : query.sort;
93
+ if (!sortableFields.includes(fieldName)) {
94
+ throw new Error(`Invalid sort field "${fieldName}"`);
95
+ }
96
+ result.orderBy = { [fieldName]: descending ? 'desc' : 'asc' };
97
+ }
98
+ return result;
99
+ }
@@ -0,0 +1,2 @@
1
+ export declare function omitFields<T extends Record<string, unknown>>(row: T, omitted: readonly string[]): Omit<T, (typeof omitted)[number]>;
2
+ export declare function omitFieldsMany<T extends Record<string, unknown>>(rows: T[], omitted: readonly string[]): Array<Omit<T, (typeof omitted)[number]>>;
@@ -0,0 +1,16 @@
1
+ export function omitFields(row, omitted) {
2
+ if (omitted.length === 0) {
3
+ return row;
4
+ }
5
+ const result = { ...row };
6
+ for (const field of omitted) {
7
+ delete result[field];
8
+ }
9
+ return result;
10
+ }
11
+ export function omitFieldsMany(rows, omitted) {
12
+ if (omitted.length === 0) {
13
+ return rows;
14
+ }
15
+ return rows.map((row) => omitFields(row, omitted));
16
+ }
@@ -4,6 +4,8 @@ export declare class RouteGenerator {
4
4
  private readonly schema;
5
5
  constructor(model: Model, schema: Schema);
6
6
  generate(): string;
7
+ private jsonRow;
8
+ private jsonRows;
7
9
  private generateListRoute;
8
10
  private generateGetRoute;
9
11
  private generateCreateRoute;