schematic-pg 0.1.3 → 0.1.5
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 +168 -19
- package/dist/api/middleware/validate.d.ts +10 -0
- package/dist/api/middleware/validate.js +3 -0
- package/dist/api/utils/list-query.d.ts +16 -0
- package/dist/api/utils/list-query.js +99 -0
- package/dist/api/utils/omit-fields.d.ts +2 -0
- package/dist/api/utils/omit-fields.js +16 -0
- package/dist/api-generator/route-generator.d.ts +2 -0
- package/dist/api-generator/route-generator.js +59 -25
- package/dist/api-generator/utils/api-fields.d.ts +8 -0
- package/dist/api-generator/utils/api-fields.js +25 -0
- package/dist/api-generator/utils/filter-operators.d.ts +15 -0
- package/dist/api-generator/utils/filter-operators.js +98 -0
- package/dist/api-generator/zod-schema-generator.d.ts +2 -0
- package/dist/api-generator/zod-schema-generator.js +54 -0
- package/dist/cli/init.js +31 -1
- package/dist/cli/templates.d.ts +3 -1
- package/dist/cli/templates.js +17 -3
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/db/db-client-generator.js +14 -5
- package/dist/db/include/executor.d.ts +3 -0
- package/dist/db/include/executor.js +90 -0
- package/dist/db/include/hydrator.d.ts +4 -0
- package/dist/db/include/hydrator.js +34 -0
- package/dist/db/include/json-agg.d.ts +5 -0
- package/dist/db/include/json-agg.js +101 -0
- package/dist/db/include/load.d.ts +8 -0
- package/dist/db/include/load.js +46 -0
- package/dist/db/include/planner.d.ts +16 -0
- package/dist/db/include/planner.js +48 -0
- package/dist/db/include/types.d.ts +14 -0
- package/dist/db/include/types.js +1 -0
- package/dist/db/model-client.d.ts +14 -12
- package/dist/db/model-client.js +35 -21
- package/dist/db/model-meta.d.ts +13 -0
- package/dist/db/model-meta.js +6 -0
- package/dist/db/type-generator.d.ts +2 -0
- package/dist/db/type-generator.js +16 -0
- package/dist/db/utils/relations.d.ts +3 -0
- package/dist/db/utils/relations.js +95 -0
- 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
|
|
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
|
|
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
|
|
@@ -200,6 +203,14 @@ cd my-app
|
|
|
200
203
|
|
|
201
204
|
Edit `app.schema`, then generate code and start the API:
|
|
202
205
|
|
|
206
|
+
```bash
|
|
207
|
+
make dev
|
|
208
|
+
# → starts PostgreSQL, generates code, bootstraps the DB, and runs the dev server
|
|
209
|
+
# → http://localhost:3000
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Or run each step individually:
|
|
213
|
+
|
|
203
214
|
```bash
|
|
204
215
|
# Start PostgreSQL (PostGIS-enabled, matches .env defaults)
|
|
205
216
|
docker compose up -d
|
|
@@ -222,6 +233,7 @@ The `init` command creates everything you need to get running:
|
|
|
222
233
|
| `app.schema` | Starter schema (one `User` model) — edit this |
|
|
223
234
|
| `.env` | `DATABASE_URL`, JWT settings |
|
|
224
235
|
| `docker-compose.yml` | Local PostGIS PostgreSQL on `:5432` |
|
|
236
|
+
| `Makefile` | `make dev` — docker compose + generate + db:bootstrap + dev |
|
|
225
237
|
| `tsconfig.json` | TypeScript config for `generated/` and `src/routes/` |
|
|
226
238
|
| `package.json` | `schematic-pg` + runtime deps (`hono`, `pg`, `zod`, …) |
|
|
227
239
|
| `src/routes/health.ts` | Example custom route mounted at `/health` |
|
|
@@ -283,6 +295,7 @@ schematic-pg dev [schema] # generate:client + generate:api, then start generat
|
|
|
283
295
|
Equivalent npm scripts in a project created by `init`:
|
|
284
296
|
|
|
285
297
|
```bash
|
|
298
|
+
make dev # docker compose up -d + generate + db:bootstrap + dev
|
|
286
299
|
npm run dev # schematic-pg dev
|
|
287
300
|
npm run generate # schematic-pg generate
|
|
288
301
|
```
|
|
@@ -348,8 +361,8 @@ Outputs:
|
|
|
348
361
|
|
|
349
362
|
| File | Purpose |
|
|
350
363
|
|------|---------|
|
|
351
|
-
| `generated/db-types.ts` | Per-model interfaces: `User`, `UserCreateInput`, `
|
|
352
|
-
| `generated/db-model-meta.ts` | Serialized field/column metadata consumed at runtime |
|
|
364
|
+
| `generated/db-types.ts` | Per-model interfaces: `User`, `UserCreateInput`, `UserWhereInput`, `UserInclude`, enum unions |
|
|
365
|
+
| `generated/db-model-meta.ts` | Serialized field/column and relation metadata consumed at runtime |
|
|
353
366
|
| `generated/db.ts` | `createDbClient(pool)` factory wiring all models |
|
|
354
367
|
|
|
355
368
|
### Usage
|
|
@@ -382,6 +395,29 @@ const many = await db.user.findMany({
|
|
|
382
395
|
});
|
|
383
396
|
const total = await db.user.count({ where: { role: 'ADMIN' } });
|
|
384
397
|
|
|
398
|
+
// Eager-load relations (nested)
|
|
399
|
+
const usersWithOrders = await db.user.findMany({
|
|
400
|
+
where: { role: 'ADMIN' },
|
|
401
|
+
include: {
|
|
402
|
+
profile: true,
|
|
403
|
+
orders: {
|
|
404
|
+
where: { status: 'PENDING' },
|
|
405
|
+
orderBy: { createdAt: 'desc' },
|
|
406
|
+
include: {
|
|
407
|
+
products: {
|
|
408
|
+
include: { product: true },
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// include works on all read methods
|
|
416
|
+
const oneWithProfile = await db.user.findUnique(
|
|
417
|
+
{ id: user.id },
|
|
418
|
+
{ include: { profile: true } },
|
|
419
|
+
);
|
|
420
|
+
|
|
385
421
|
// Update
|
|
386
422
|
const updated = await db.user.update({
|
|
387
423
|
where: { id: user.id },
|
|
@@ -404,10 +440,10 @@ Each model in `app.schema` becomes a camelCase property on the client (`User`
|
|
|
404
440
|
| Method | SQL shape |
|
|
405
441
|
|--------|-----------|
|
|
406
442
|
| `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(*) …` |
|
|
443
|
+
| `findUnique(where, { include, … })` | `SELECT * … WHERE … LIMIT 1` (+ batched relation queries when `include` is set) |
|
|
444
|
+
| `findFirst({ where, orderBy, include, … })` | `SELECT * … ORDER BY … LIMIT 1` (+ relation queries when `include` is set) |
|
|
445
|
+
| `findMany({ where, orderBy, take, skip, include, … })` | `SELECT * … ORDER BY … LIMIT … OFFSET …` (+ relation queries when `include` is set) |
|
|
446
|
+
| `count({ where })` | `SELECT COUNT(*) …` (no `include`) |
|
|
411
447
|
| `update({ where, data })` | `UPDATE … SET … WHERE … RETURNING *` |
|
|
412
448
|
| `updateMany({ where, data })` | `UPDATE … SET … WHERE … RETURNING *` |
|
|
413
449
|
| `delete(where)` | `DELETE … WHERE … RETURNING *` |
|
|
@@ -415,6 +451,85 @@ Each model in `app.schema` becomes a camelCase property on the client (`User`
|
|
|
415
451
|
|
|
416
452
|
Mutations return the full row (`RETURNING *`). Rows are mapped from `snake_case` columns to `camelCase` TypeScript fields.
|
|
417
453
|
|
|
454
|
+
### Eager loading (`include`)
|
|
455
|
+
|
|
456
|
+
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: …)`).
|
|
457
|
+
|
|
458
|
+
**Supported on:** `findMany`, `findFirst`, and `findUnique`. Not on `count`.
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
// Shorthand — load all columns for the relation
|
|
462
|
+
await db.user.findMany({
|
|
463
|
+
include: { profile: true, orders: true },
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Nested — arbitrary depth
|
|
467
|
+
await db.user.findMany({
|
|
468
|
+
include: {
|
|
469
|
+
orders: {
|
|
470
|
+
include: {
|
|
471
|
+
products: {
|
|
472
|
+
include: { product: true },
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// Filter, sort, and paginate inner relations
|
|
480
|
+
await db.user.findMany({
|
|
481
|
+
include: {
|
|
482
|
+
orders: {
|
|
483
|
+
where: { status: 'PENDING' },
|
|
484
|
+
orderBy: { createdAt: 'desc' },
|
|
485
|
+
take: 5,
|
|
486
|
+
include: { products: true },
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Each relation key accepts `true` or a `{ where?, orderBy?, take?, skip?, include? }` object (typed as `{Model}IncludeArgs` in `generated/db-types.ts`).
|
|
493
|
+
|
|
494
|
+
**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.
|
|
495
|
+
|
|
496
|
+
#### Loading strategies
|
|
497
|
+
|
|
498
|
+
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`).
|
|
499
|
+
|
|
500
|
+
```typescript
|
|
501
|
+
// Default — split queries (recommended for large result sets)
|
|
502
|
+
await db.user.findMany({
|
|
503
|
+
include: { orders: true },
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Optional — single round-trip via PostgreSQL LATERAL + json_agg
|
|
507
|
+
await db.user.findMany({
|
|
508
|
+
include: { profile: true, orders: true },
|
|
509
|
+
relationLoadStrategy: 'join',
|
|
510
|
+
});
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
| Strategy | Option | Behavior |
|
|
514
|
+
|----------|--------|----------|
|
|
515
|
+
| Split (default) | omit or `'split'` | One query per relation edge; no row duplication on the wire |
|
|
516
|
+
| Join | `'join'` | One SQL statement; nested JSON built in PostgreSQL |
|
|
517
|
+
|
|
518
|
+
Use `'join'` when latency dominates (fewer round trips). Use the default split strategy when loading many rows or several sibling collections.
|
|
519
|
+
|
|
520
|
+
#### How it works
|
|
521
|
+
|
|
522
|
+
```
|
|
523
|
+
findMany({ include }) → buildLoadPlan (relation tree from schema metadata)
|
|
524
|
+
→ SELECT root rows
|
|
525
|
+
→ for each included relation: SELECT … WHERE fk = ANY($parentIds)
|
|
526
|
+
→ stitch (hash-join children onto parents in O(n))
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
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.
|
|
530
|
+
|
|
531
|
+
Generated types: `{Model}Include` and `{Model}IncludeArgs` in `generated/db-types.ts`.
|
|
532
|
+
|
|
418
533
|
### Where filters
|
|
419
534
|
|
|
420
535
|
Direct values are treated as equality. Structured operators are supported per field type:
|
|
@@ -492,12 +607,12 @@ schematic-pg/dist/db/ # Published runtime (import as schematic-pg/db/*)
|
|
|
492
607
|
├── query-builder.ts # INSERT / SELECT / UPDATE / DELETE / COUNT
|
|
493
608
|
├── where-translator.ts # WhereInput → SQL + params
|
|
494
609
|
├── model-client.ts # createModelClient factory
|
|
610
|
+
├── include/ # Eager-loading planner, executor, hydrator, json_agg
|
|
611
|
+
├── utils/relations.ts # Relation graph from schema AST
|
|
495
612
|
├── row-mapper.ts # snake_case rows → camelCase + coercion
|
|
496
613
|
└── errors.ts # UniqueConstraintError, ForeignKeyConstraintError, …
|
|
497
614
|
```
|
|
498
615
|
|
|
499
|
-
Relation `include` (e.g. `findMany({ include: { profile: true } })`) is planned for a future release.
|
|
500
|
-
|
|
501
616
|
### Integration tests
|
|
502
617
|
|
|
503
618
|
One command starts Docker Postgres, generates the client and API, and runs all integration tests (DB client + ACL):
|
|
@@ -508,7 +623,7 @@ npm run test:integration
|
|
|
508
623
|
|
|
509
624
|
This resets the `public` schema, bootstraps from `app.schema`, seeds test data, and exercises:
|
|
510
625
|
|
|
511
|
-
- **DB client** — CRUD, filters, and error handling ([`src/db/__tests__/db-client.integration.test.ts`](src/db/__tests__/db-client.integration.test.ts))
|
|
626
|
+
- **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
627
|
- **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
628
|
|
|
514
629
|
Tests run in-process via Hono `app.request()` against the exported `createApp()` factory from `generated/app.ts`.
|
|
@@ -640,13 +755,46 @@ Every router exposes the same CRUD shape. Models with `@policy` attributes enfor
|
|
|
640
755
|
|
|
641
756
|
| Method | Path | Handler | Validation |
|
|
642
757
|
|--------|------|---------|------------|
|
|
643
|
-
| `GET` | `/` | `findMany({ where: policyWhere })` |
|
|
758
|
+
| `GET` | `/` | `findMany({ where: mergeWhere(queryWhere, policyWhere), orderBy, take, skip })` | Query params |
|
|
644
759
|
| `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere))` | Path params |
|
|
645
760
|
| `POST` | `/` | `create(body)` — policy check only | JSON body |
|
|
646
761
|
| `PUT` | `/{pk}` | `update({ where: mergeWhere(pk, policyWhere), data })` | Path params + JSON body |
|
|
647
762
|
| `DELETE` | `/{pk}` | `delete(mergeWhere(pk, policyWhere))` | Path params |
|
|
648
763
|
|
|
649
|
-
`POST` returns `201 Created`. Missing records on `GET` return `404`.
|
|
764
|
+
`POST` returns `201 Created`. Missing records on `GET` return `404`. All handlers strip `@omit` fields from JSON responses before returning.
|
|
765
|
+
|
|
766
|
+
### Query filters (`GET /`)
|
|
767
|
+
|
|
768
|
+
All stored scalar fields are URL-filterable by default. Opt out with `@unfilterable` on a field. Fields marked `@omit` are never filterable.
|
|
769
|
+
|
|
770
|
+
Query params use **API field names** (camelCase), not SQL column names:
|
|
771
|
+
|
|
772
|
+
| Param | Maps to |
|
|
773
|
+
|-------|---------|
|
|
774
|
+
| `?role=ADMIN` | `{ role: 'ADMIN' }` |
|
|
775
|
+
| `?email_contains=@` | `{ email: { contains: '@' } }` |
|
|
776
|
+
| `?balance_gte=100` | `{ balance: { gte: 100 } }` |
|
|
777
|
+
| `?role_in=ADMIN,USER` | `{ role: { in: ['ADMIN', 'USER'] } }` |
|
|
778
|
+
| `?limit=20` | `take: 20` (max 100) |
|
|
779
|
+
| `?offset=40` | `skip: 40` |
|
|
780
|
+
| `?sort=-createdAt` | `orderBy: { createdAt: 'desc' }` |
|
|
781
|
+
|
|
782
|
+
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.
|
|
783
|
+
|
|
784
|
+
```bash
|
|
785
|
+
curl "http://localhost:3000/products?category=books&limit=10"
|
|
786
|
+
curl "http://localhost:3000/users?role=USER&isActive=true" -H "Authorization: Bearer $TOKEN"
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
### Response shaping (`@omit`)
|
|
790
|
+
|
|
791
|
+
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.
|
|
792
|
+
|
|
793
|
+
```ts
|
|
794
|
+
passwordHash: VARCHAR(255) @omit @unfilterable @default("")
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
Generated types include `{Model}Response` (for example `UserResponse = Omit<User, 'passwordHash'>`) in `generated/schemas/validation.ts`.
|
|
650
798
|
|
|
651
799
|
### Validation
|
|
652
800
|
|
|
@@ -678,6 +826,9 @@ Fields with `@default` or optional (`?`) types are optional on create. Update sc
|
|
|
678
826
|
# Health check (custom route from src/routes/health.ts)
|
|
679
827
|
curl http://localhost:3000/health
|
|
680
828
|
|
|
829
|
+
# List users with filters
|
|
830
|
+
curl "http://localhost:3000/users?role=USER&limit=10"
|
|
831
|
+
|
|
681
832
|
# List users
|
|
682
833
|
curl http://localhost:3000/users
|
|
683
834
|
|
|
@@ -767,8 +918,6 @@ your-project/src/routes/ # Hand-written custom Hono routers (auto-imported)
|
|
|
767
918
|
|
|
768
919
|
The generators live in this repo under `src/api-generator/` and are invoked by the CLI at build time.
|
|
769
920
|
|
|
770
|
-
URL query-string filters for `findMany` (e.g. `?role=ADMIN`) are planned for a future release.
|
|
771
|
-
|
|
772
921
|
---
|
|
773
922
|
|
|
774
923
|
## Access Control (`@policy`)
|
|
@@ -966,7 +1115,7 @@ postgrest.js/
|
|
|
966
1115
|
├── src/
|
|
967
1116
|
│ ├── schema-dsl/ # Lexer, parser, AST
|
|
968
1117
|
│ ├── sql-generator/ # DDL + migration planner
|
|
969
|
-
│ ├── db/ # Query builder + client runtime
|
|
1118
|
+
│ ├── db/ # Query builder + client runtime + include eager-loading
|
|
970
1119
|
│ ├── api/ # Hono runtime (published as schematic-pg/api/*)
|
|
971
1120
|
│ ├── api-generator/ # AST → routes, Zod, policies, app
|
|
972
1121
|
│ ├── cli/ # init templates + command helpers
|
|
@@ -986,7 +1135,7 @@ postgrest.js/
|
|
|
986
1135
|
| Schema truth | Migrations + models + Zod + routes | One `.schema` file |
|
|
987
1136
|
| Query visibility | Hidden behind ORM methods | Raw, parameterized SQL |
|
|
988
1137
|
| Client ergonomics | ORM model API | Generated Prisma-like client, no ORM runtime |
|
|
989
|
-
| Performance | N+1, lazy loading pitfalls | Explicit
|
|
1138
|
+
| Performance | N+1, lazy loading pitfalls | Explicit `include`; batched split queries by default |
|
|
990
1139
|
| ACL | External service or manual checks | Inline `@policy` directives |
|
|
991
1140
|
| Validation | Separate Zod schemas | Derived from `@regex` / `@range` |
|
|
992
1141
|
| Dependencies | Heavy (Prisma, Drizzle, etc.) | Hono + pg + Zod + hand-written parser |
|
|
@@ -1005,7 +1154,7 @@ postgrest.js/
|
|
|
1005
1154
|
- [x] Row-level policy injection (`WHERE` clause from `where:` templates)
|
|
1006
1155
|
- [x] JWT authentication (default Bearer resolver, pluggable `AuthResolver`)
|
|
1007
1156
|
- [x] Custom routes (`src/routes/` auto-imported into generated app)
|
|
1008
|
-
- [
|
|
1157
|
+
- [x] Relation `include` in DB client (nested eager-loading, split + json_agg strategies)
|
|
1009
1158
|
- [ ] Type generation for frontend consumption
|
|
1010
1159
|
- [ ] Tree-sitter grammar for editor support
|
|
1011
1160
|
- [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;
|
|
@@ -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
|
+
}
|