schematic-pg 0.1.5 → 0.1.7
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 +58 -17
- package/dist/api/utils/include-query.d.ts +6 -0
- package/dist/api/utils/include-query.js +75 -0
- package/dist/api/utils/read-query.d.ts +12 -0
- package/dist/api/utils/read-query.js +10 -0
- package/dist/api/utils/response-shape.d.ts +2 -0
- package/dist/api/utils/response-shape.js +28 -0
- package/dist/api-generator/route-generator.d.ts +1 -0
- package/dist/api-generator/route-generator.js +53 -24
- package/dist/api-generator/utils/api-fields.d.ts +6 -0
- package/dist/api-generator/utils/api-fields.js +32 -0
- package/dist/api-generator/zod-schema-generator.d.ts +3 -0
- package/dist/api-generator/zod-schema-generator.js +66 -8
- package/dist/cli/db.js +2 -0
- package/dist/cli/dev.d.ts +1 -1
- package/dist/cli/dev.js +147 -7
- package/dist/cli/init.js +5 -2
- package/dist/cli/templates.d.ts +2 -2
- package/dist/cli/templates.js +2 -4
- package/dist/cli/wait-for-database.d.ts +9 -0
- package/dist/cli/wait-for-database.js +39 -0
- package/dist/cli.js +2 -2
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -201,11 +201,12 @@ npx schematic-pg init my-app
|
|
|
201
201
|
cd my-app
|
|
202
202
|
```
|
|
203
203
|
|
|
204
|
-
Edit `app.schema`, then
|
|
204
|
+
Edit `app.schema`, then start the full dev loop:
|
|
205
205
|
|
|
206
206
|
```bash
|
|
207
207
|
make dev
|
|
208
|
-
# → starts PostgreSQL, generates code, bootstraps the DB,
|
|
208
|
+
# → starts PostgreSQL, generates code, bootstraps the DB, runs the dev server,
|
|
209
|
+
# and watches app.schema for changes (regenerate + bootstrap + restart)
|
|
209
210
|
# → http://localhost:3000
|
|
210
211
|
```
|
|
211
212
|
|
|
@@ -213,17 +214,22 @@ Or run each step individually:
|
|
|
213
214
|
|
|
214
215
|
```bash
|
|
215
216
|
# Start PostgreSQL (PostGIS-enabled, matches .env defaults)
|
|
216
|
-
docker compose up -d
|
|
217
|
+
docker compose up -d --wait
|
|
217
218
|
|
|
218
|
-
# Generate
|
|
219
|
-
npx schematic-pg generate
|
|
220
|
-
|
|
221
|
-
# Apply DDL to the database and snapshot schema state
|
|
222
|
-
npx schematic-pg db:bootstrap
|
|
223
|
-
|
|
224
|
-
# Regenerate client + API and start the server
|
|
219
|
+
# Generate, bootstrap, start server, and watch app.schema (default)
|
|
225
220
|
npx schematic-pg dev
|
|
226
221
|
# → http://localhost:3000
|
|
222
|
+
|
|
223
|
+
# One-shot dev server without schema watching:
|
|
224
|
+
npx schematic-pg dev --no-watch
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Manual split when you need finer control:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
npx schematic-pg generate
|
|
231
|
+
npx schematic-pg db:bootstrap
|
|
232
|
+
npx schematic-pg dev --no-watch
|
|
227
233
|
```
|
|
228
234
|
|
|
229
235
|
The `init` command creates everything you need to get running:
|
|
@@ -233,7 +239,7 @@ The `init` command creates everything you need to get running:
|
|
|
233
239
|
| `app.schema` | Starter schema (one `User` model) — edit this |
|
|
234
240
|
| `.env` | `DATABASE_URL`, JWT settings |
|
|
235
241
|
| `docker-compose.yml` | Local PostGIS PostgreSQL on `:5432` |
|
|
236
|
-
| `Makefile` | `make dev` — docker compose
|
|
242
|
+
| `Makefile` | `make dev` — docker compose (with health wait) + `schematic-pg dev` |
|
|
237
243
|
| `tsconfig.json` | TypeScript config for `generated/` and `src/routes/` |
|
|
238
244
|
| `package.json` | `schematic-pg` + runtime deps (`hono`, `pg`, `zod`, …) |
|
|
239
245
|
| `src/routes/health.ts` | Example custom route mounted at `/health` |
|
|
@@ -261,7 +267,7 @@ Generated code imports the runtime from the `schematic-pg` package (`schematic-p
|
|
|
261
267
|
| `JWT_ROLE_CLAIM` | `role` | JWT claim mapped to `auth.role` |
|
|
262
268
|
| `JWT_USER_ID_CLAIM` | `sub` | JWT claim mapped to `auth.user.id` |
|
|
263
269
|
|
|
264
|
-
Set these in `.env` before running `
|
|
270
|
+
Set these in `.env` before running `dev` or `db:bootstrap`.
|
|
265
271
|
|
|
266
272
|
---
|
|
267
273
|
|
|
@@ -289,13 +295,22 @@ Run `generate:client` before `generate:api` when using the split commands — ro
|
|
|
289
295
|
### Development server
|
|
290
296
|
|
|
291
297
|
```bash
|
|
292
|
-
schematic-pg dev [schema]
|
|
298
|
+
schematic-pg dev [schema] [--no-watch]
|
|
293
299
|
```
|
|
294
300
|
|
|
301
|
+
`dev` runs the full local loop:
|
|
302
|
+
|
|
303
|
+
1. `generate` — writes `schema.sql` and `generated/*`
|
|
304
|
+
2. `db:bootstrap` — waits for Postgres, applies DDL, snapshots schema state
|
|
305
|
+
3. Starts `generated/app.ts`
|
|
306
|
+
4. Watches `app.schema` (default) — on change, re-runs generate, bootstrap, and server restart
|
|
307
|
+
|
|
308
|
+
Pass `--no-watch` for a one-shot run without file watching.
|
|
309
|
+
|
|
295
310
|
Equivalent npm scripts in a project created by `init`:
|
|
296
311
|
|
|
297
312
|
```bash
|
|
298
|
-
make dev # docker compose up -d
|
|
313
|
+
make dev # docker compose up -d --wait + schematic-pg dev
|
|
299
314
|
npm run dev # schematic-pg dev
|
|
300
315
|
npm run generate # schematic-pg generate
|
|
301
316
|
```
|
|
@@ -755,8 +770,8 @@ Every router exposes the same CRUD shape. Models with `@policy` attributes enfor
|
|
|
755
770
|
|
|
756
771
|
| Method | Path | Handler | Validation |
|
|
757
772
|
|--------|------|---------|------------|
|
|
758
|
-
| `GET` | `/` | `findMany({ where: mergeWhere(queryWhere, policyWhere), orderBy, take, skip })` | Query params |
|
|
759
|
-
| `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere))` | Path params |
|
|
773
|
+
| `GET` | `/` | `findMany({ where: mergeWhere(queryWhere, policyWhere), orderBy, take, skip, include })` | Query params |
|
|
774
|
+
| `GET` | `/{pk}` | `findUnique(mergeWhere(pk, policyWhere), { include })` | Path params + query params |
|
|
760
775
|
| `POST` | `/` | `create(body)` — policy check only | JSON body |
|
|
761
776
|
| `PUT` | `/{pk}` | `update({ where: mergeWhere(pk, policyWhere), data })` | Path params + JSON body |
|
|
762
777
|
| `DELETE` | `/{pk}` | `delete(mergeWhere(pk, policyWhere))` | Path params |
|
|
@@ -778,17 +793,43 @@ Query params use **API field names** (camelCase), not SQL column names:
|
|
|
778
793
|
| `?limit=20` | `take: 20` (max 100) |
|
|
779
794
|
| `?offset=40` | `skip: 40` |
|
|
780
795
|
| `?sort=-createdAt` | `orderBy: { createdAt: 'desc' }` |
|
|
796
|
+
| `?include=profile,orders` | `include: { profile: true, orders: true }` |
|
|
797
|
+
| `?include=orders.products.product` | nested boolean includes |
|
|
781
798
|
|
|
782
799
|
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
800
|
|
|
784
801
|
```bash
|
|
785
802
|
curl "http://localhost:3000/products?category=books&limit=10"
|
|
786
803
|
curl "http://localhost:3000/users?role=USER&isActive=true" -H "Authorization: Bearer $TOKEN"
|
|
804
|
+
curl "http://localhost:3000/users/USER_ID?include=profile,orders" -H "Authorization: Bearer $TOKEN"
|
|
787
805
|
```
|
|
788
806
|
|
|
807
|
+
### Relation includes (`GET /`, `GET /{pk}`)
|
|
808
|
+
|
|
809
|
+
Load related models via the `include` query param. Paths are comma-separated; use dots for nesting:
|
|
810
|
+
|
|
811
|
+
```bash
|
|
812
|
+
curl "http://localhost:3000/users?include=profile,orders"
|
|
813
|
+
curl "http://localhost:3000/users/USER_ID?include=orders.products.product"
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
Each segment must name a relation field on the current model (or nested target model). Unknown relations return `400`. Maximum depth and path count are capped (see `MAX_INCLUDE_DEPTH` / `MAX_INCLUDE_PATHS` in the runtime).
|
|
817
|
+
|
|
818
|
+
Opt out of HTTP includes on a relation field with `@unincludeable`:
|
|
819
|
+
|
|
820
|
+
```ts
|
|
821
|
+
orders: Order[] @unincludeable
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
**v1 limits:**
|
|
825
|
+
|
|
826
|
+
- Boolean includes only — no nested `where`, `take`, or `skip` via URL (use the DB client or a custom route for that).
|
|
827
|
+
- `@policy` row filters apply to the **root** model only; included relations are not policy-filtered separately.
|
|
828
|
+
- `@omit` fields are stripped recursively on nested included objects in read responses.
|
|
829
|
+
|
|
789
830
|
### Response shaping (`@omit`)
|
|
790
831
|
|
|
791
|
-
Mark sensitive stored fields with `@omit` to exclude them from generated route JSON responses
|
|
832
|
+
Mark sensitive stored fields with `@omit` to exclude them from generated route JSON responses. On read endpoints with `include`, omitted fields are stripped recursively on nested relation objects as well. Mutation responses (`POST`, `PUT`, `DELETE`) strip `@omit` fields on the root model only. The ORM client still returns full entities.
|
|
792
833
|
|
|
793
834
|
```ts
|
|
794
835
|
passwordHash: VARCHAR(255) @omit @unfilterable @default("")
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { IncludeInput } from '../../db/include/types.js';
|
|
2
|
+
export interface IncludableRelationTree {
|
|
3
|
+
[relationName: string]: IncludableRelationTree;
|
|
4
|
+
}
|
|
5
|
+
export declare function validateIncludePaths(raw: string, tree: IncludableRelationTree): string | undefined;
|
|
6
|
+
export declare function parseIncludeQuery(raw: string, tree: IncludableRelationTree): IncludeInput;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { MAX_INCLUDE_DEPTH, MAX_INCLUDE_PATHS } from '../../constants.js';
|
|
2
|
+
export function validateIncludePaths(raw, tree) {
|
|
3
|
+
const segments = splitIncludePaths(raw);
|
|
4
|
+
if (segments.some((segment) => segment.length === 0)) {
|
|
5
|
+
return 'Include paths cannot contain empty relation segments';
|
|
6
|
+
}
|
|
7
|
+
if (segments.length === 0) {
|
|
8
|
+
return 'Include parameter must contain at least one relation path';
|
|
9
|
+
}
|
|
10
|
+
if (segments.length > MAX_INCLUDE_PATHS) {
|
|
11
|
+
return `Include parameter exceeds maximum of ${MAX_INCLUDE_PATHS} paths`;
|
|
12
|
+
}
|
|
13
|
+
for (const segment of segments) {
|
|
14
|
+
const parts = segment.split('.');
|
|
15
|
+
if (parts.length > MAX_INCLUDE_DEPTH) {
|
|
16
|
+
return `Include path "${segment}" exceeds maximum depth of ${MAX_INCLUDE_DEPTH}`;
|
|
17
|
+
}
|
|
18
|
+
let currentTree = tree;
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (!part) {
|
|
21
|
+
return 'Include paths cannot contain empty relation segments';
|
|
22
|
+
}
|
|
23
|
+
const nextTree = currentTree[part];
|
|
24
|
+
if (!nextTree) {
|
|
25
|
+
return `Unknown include relation "${part}" in path "${segment}"`;
|
|
26
|
+
}
|
|
27
|
+
currentTree = nextTree;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
export function parseIncludeQuery(raw, tree) {
|
|
33
|
+
const error = validateIncludePaths(raw, tree);
|
|
34
|
+
if (error) {
|
|
35
|
+
throw new Error(error);
|
|
36
|
+
}
|
|
37
|
+
const root = {};
|
|
38
|
+
for (const segment of splitIncludePaths(raw)) {
|
|
39
|
+
mergeIncludePath(root, segment.split('.'));
|
|
40
|
+
}
|
|
41
|
+
return toIncludeInput(root);
|
|
42
|
+
}
|
|
43
|
+
function splitIncludePaths(raw) {
|
|
44
|
+
return raw.split(',').map((segment) => segment.trim());
|
|
45
|
+
}
|
|
46
|
+
function mergeIncludePath(root, parts) {
|
|
47
|
+
let current = root;
|
|
48
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
49
|
+
const part = parts[index];
|
|
50
|
+
const isLeaf = index === parts.length - 1;
|
|
51
|
+
if (!current[part]) {
|
|
52
|
+
current[part] = {};
|
|
53
|
+
}
|
|
54
|
+
if (isLeaf) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!current[part].include) {
|
|
58
|
+
current[part].include = {};
|
|
59
|
+
}
|
|
60
|
+
current = current[part].include;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function toIncludeInput(nodes) {
|
|
64
|
+
const include = {};
|
|
65
|
+
for (const [relationName, node] of Object.entries(nodes)) {
|
|
66
|
+
if (node.include && Object.keys(node.include).length > 0) {
|
|
67
|
+
include[relationName] = {
|
|
68
|
+
include: toIncludeInput(node.include),
|
|
69
|
+
};
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
include[relationName] = true;
|
|
73
|
+
}
|
|
74
|
+
return include;
|
|
75
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FilterFieldMeta } from './list-query.js';
|
|
2
|
+
import { buildListQuery } from './list-query.js';
|
|
3
|
+
import type { IncludeInput } from '../../db/include/types.js';
|
|
4
|
+
import type { IncludableRelationTree } from './include-query.js';
|
|
5
|
+
export interface ReadQueryResult {
|
|
6
|
+
where: ReturnType<typeof buildListQuery>['where'];
|
|
7
|
+
orderBy?: ReturnType<typeof buildListQuery>['orderBy'];
|
|
8
|
+
take?: number;
|
|
9
|
+
skip?: number;
|
|
10
|
+
include?: IncludeInput;
|
|
11
|
+
}
|
|
12
|
+
export declare function buildReadQuery(query: Record<string, unknown>, fields: readonly FilterFieldMeta[], sortableFields: readonly string[], includableRelations: IncludableRelationTree): ReadQueryResult;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { buildListQuery } from './list-query.js';
|
|
2
|
+
import { parseIncludeQuery } from './include-query.js';
|
|
3
|
+
export function buildReadQuery(query, fields, sortableFields, includableRelations) {
|
|
4
|
+
const { where, orderBy, take, skip } = buildListQuery(query, fields, sortableFields);
|
|
5
|
+
const result = { where, orderBy, take, skip };
|
|
6
|
+
if (typeof query.include === 'string' && query.include.length > 0) {
|
|
7
|
+
result.include = parseIncludeQuery(query.include, includableRelations);
|
|
8
|
+
}
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare function shapeResponse<T extends Record<string, unknown>>(row: T, modelName: string, omitByModel: Readonly<Record<string, readonly string[]>>, relationTargets: Readonly<Record<string, Readonly<Record<string, string>>>>): T;
|
|
2
|
+
export declare function shapeResponseMany<T extends Record<string, unknown>>(rows: T[], modelName: string, omitByModel: Readonly<Record<string, readonly string[]>>, relationTargets: Readonly<Record<string, Readonly<Record<string, string>>>>): T[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function shapeResponse(row, modelName, omitByModel, relationTargets) {
|
|
2
|
+
const omitted = omitByModel[modelName] ?? [];
|
|
3
|
+
const relations = relationTargets[modelName] ?? {};
|
|
4
|
+
const result = { ...row };
|
|
5
|
+
for (const field of omitted) {
|
|
6
|
+
delete result[field];
|
|
7
|
+
}
|
|
8
|
+
for (const [relationName, targetModel] of Object.entries(relations)) {
|
|
9
|
+
if (!(relationName in result)) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const value = result[relationName];
|
|
13
|
+
if (value === null || value === undefined) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
result[relationName] = value.map((entry) => shapeResponse(entry, targetModel, omitByModel, relationTargets));
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (typeof value === 'object') {
|
|
21
|
+
result[relationName] = shapeResponse(value, targetModel, omitByModel, relationTargets);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
export function shapeResponseMany(rows, modelName, omitByModel, relationTargets) {
|
|
27
|
+
return rows.map((row) => shapeResponse(row, modelName, omitByModel, relationTargets));
|
|
28
|
+
}
|
|
@@ -21,6 +21,7 @@ export class RouteGenerator {
|
|
|
21
21
|
const whereFromParams = primaryKey.fields.map((field) => `${field}: params.${field}`).join(', ');
|
|
22
22
|
const paramSchemaName = `${this.model.name}ParamSchema`;
|
|
23
23
|
const listQuerySchemaName = `${this.model.name}ListQuerySchema`;
|
|
24
|
+
const getQuerySchemaName = `${this.model.name}GetQuerySchema`;
|
|
24
25
|
const modelHasPolicies = hasPolicies(this.model);
|
|
25
26
|
const constantPrefix = toModelConstantPrefix(this.model.name);
|
|
26
27
|
return [
|
|
@@ -29,8 +30,10 @@ export class RouteGenerator {
|
|
|
29
30
|
`import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
|
|
30
31
|
`import { validateJson, validateParam, validateQuery } from '${PACKAGE_NAME}/api/middleware/validate';`,
|
|
31
32
|
`import { notFoundResponse } from '${PACKAGE_NAME}/api/middleware/errors';`,
|
|
32
|
-
`import {
|
|
33
|
-
`import {
|
|
33
|
+
`import { buildReadQuery } from '${PACKAGE_NAME}/api/utils/read-query';`,
|
|
34
|
+
`import { parseIncludeQuery } from '${PACKAGE_NAME}/api/utils/include-query';`,
|
|
35
|
+
`import { omitFields } from '${PACKAGE_NAME}/api/utils/omit-fields';`,
|
|
36
|
+
`import { shapeResponse, shapeResponseMany } from '${PACKAGE_NAME}/api/utils/response-shape';`,
|
|
34
37
|
...(modelHasPolicies
|
|
35
38
|
? [
|
|
36
39
|
`import { assertPolicy, mergeWhere, resolvePolicyWhere } from '${PACKAGE_NAME}/api/auth/policy';`,
|
|
@@ -41,16 +44,20 @@ export class RouteGenerator {
|
|
|
41
44
|
` ${this.model.name}UpdateSchema,`,
|
|
42
45
|
` ${paramSchemaName},`,
|
|
43
46
|
` ${listQuerySchemaName},`,
|
|
47
|
+
` ${getQuerySchemaName},`,
|
|
44
48
|
` ${constantPrefix}_LIST_QUERY_FIELDS,`,
|
|
49
|
+
` ${constantPrefix}_INCLUDABLE_RELATIONS,`,
|
|
45
50
|
` ${constantPrefix}_OMIT_FIELDS,`,
|
|
46
51
|
` ${constantPrefix}_SORTABLE_FIELDS,`,
|
|
52
|
+
` API_OMIT_FIELDS_BY_MODEL,`,
|
|
53
|
+
` API_RELATION_TARGETS,`,
|
|
47
54
|
`} from '../schemas/validation.js';`,
|
|
48
55
|
'',
|
|
49
56
|
'const router = new Hono<AppEnv>();',
|
|
50
57
|
'',
|
|
51
58
|
...this.generateListRoute(clientKey, modelHasPolicies, listQuerySchemaName, constantPrefix),
|
|
52
59
|
'',
|
|
53
|
-
...this.generateGetRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix),
|
|
60
|
+
...this.generateGetRoute(clientKey, pathParams, paramSchemaName, getQuerySchemaName, whereFromParams, modelHasPolicies, constantPrefix),
|
|
54
61
|
'',
|
|
55
62
|
...this.generateCreateRoute(clientKey, modelHasPolicies, constantPrefix),
|
|
56
63
|
'',
|
|
@@ -62,32 +69,45 @@ export class RouteGenerator {
|
|
|
62
69
|
'',
|
|
63
70
|
].join('\n');
|
|
64
71
|
}
|
|
65
|
-
jsonRow(variableName, constantPrefix, statusCode) {
|
|
66
|
-
const payload = `
|
|
72
|
+
jsonRow(variableName, modelName, constantPrefix, statusCode) {
|
|
73
|
+
const payload = `shapeResponse(${variableName}, '${modelName}', API_OMIT_FIELDS_BY_MODEL, API_RELATION_TARGETS)`;
|
|
67
74
|
if (statusCode === undefined) {
|
|
68
75
|
return `c.json(${payload})`;
|
|
69
76
|
}
|
|
70
77
|
return `c.json(${payload}, ${statusCode})`;
|
|
71
78
|
}
|
|
72
|
-
jsonRows(variableName,
|
|
73
|
-
return `c.json(
|
|
79
|
+
jsonRows(variableName, modelName) {
|
|
80
|
+
return `c.json(shapeResponseMany(${variableName}, '${modelName}', API_OMIT_FIELDS_BY_MODEL, API_RELATION_TARGETS))`;
|
|
81
|
+
}
|
|
82
|
+
mutationJsonRow(variableName, constantPrefix, statusCode) {
|
|
83
|
+
const payload = `omitFields(${variableName}, ${constantPrefix}_OMIT_FIELDS)`;
|
|
84
|
+
if (statusCode === undefined) {
|
|
85
|
+
return `c.json(${payload})`;
|
|
86
|
+
}
|
|
87
|
+
return `c.json(${payload}, ${statusCode})`;
|
|
74
88
|
}
|
|
75
89
|
generateListRoute(clientKey, modelHasPolicies, listQuerySchemaName, constantPrefix) {
|
|
76
90
|
const listQueryBlock = [
|
|
77
91
|
` const query = c.req.valid('query');`,
|
|
78
|
-
` const { where, orderBy, take, skip } =
|
|
92
|
+
` const { where, orderBy, take, skip, include } = buildReadQuery(`,
|
|
79
93
|
` query,`,
|
|
80
94
|
` ${constantPrefix}_LIST_QUERY_FIELDS,`,
|
|
81
95
|
` ${constantPrefix}_SORTABLE_FIELDS,`,
|
|
96
|
+
` ${constantPrefix}_INCLUDABLE_RELATIONS,`,
|
|
82
97
|
` );`,
|
|
83
98
|
];
|
|
99
|
+
const findManyArgs = ['where', 'orderBy', 'take', 'skip', 'include']
|
|
100
|
+
.map((key) => ` ${key},`)
|
|
101
|
+
.join('\n');
|
|
84
102
|
if (!modelHasPolicies) {
|
|
85
103
|
return [
|
|
86
104
|
`router.get('/', validateQuery(${listQuerySchemaName}), async (c) => {`,
|
|
87
105
|
' const db = c.get(\'db\');',
|
|
88
106
|
...listQueryBlock,
|
|
89
|
-
` const rows = await db.${clientKey}.findMany({
|
|
90
|
-
|
|
107
|
+
` const rows = await db.${clientKey}.findMany({`,
|
|
108
|
+
findManyArgs,
|
|
109
|
+
' });',
|
|
110
|
+
` return ${this.jsonRows('rows', this.model.name)};`,
|
|
91
111
|
'});',
|
|
92
112
|
];
|
|
93
113
|
}
|
|
@@ -103,37 +123,46 @@ export class RouteGenerator {
|
|
|
103
123
|
' orderBy,',
|
|
104
124
|
' take,',
|
|
105
125
|
' skip,',
|
|
126
|
+
' include,',
|
|
106
127
|
' });',
|
|
107
|
-
` return ${this.jsonRows('rows',
|
|
128
|
+
` return ${this.jsonRows('rows', this.model.name)};`,
|
|
108
129
|
'});',
|
|
109
130
|
];
|
|
110
131
|
}
|
|
111
|
-
generateGetRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix) {
|
|
132
|
+
generateGetRoute(clientKey, pathParams, paramSchemaName, getQuerySchemaName, whereFromParams, modelHasPolicies, constantPrefix) {
|
|
133
|
+
const includeBlock = [
|
|
134
|
+
' const query = c.req.valid(\'query\');',
|
|
135
|
+
` const include = query.include`,
|
|
136
|
+
` ? parseIncludeQuery(query.include, ${constantPrefix}_INCLUDABLE_RELATIONS)`,
|
|
137
|
+
' : undefined;',
|
|
138
|
+
];
|
|
112
139
|
if (!modelHasPolicies) {
|
|
113
140
|
return [
|
|
114
|
-
`router.get('/${pathParams}', validateParam(${paramSchemaName}), async (c) => {`,
|
|
141
|
+
`router.get('/${pathParams}', validateParam(${paramSchemaName}), validateQuery(${getQuerySchemaName}), async (c) => {`,
|
|
115
142
|
' const db = c.get(\'db\');',
|
|
116
143
|
' const params = c.req.valid(\'param\');',
|
|
117
|
-
|
|
144
|
+
...includeBlock,
|
|
145
|
+
` const row = await db.${clientKey}.findUnique({ ${whereFromParams} }, { include });`,
|
|
118
146
|
' if (!row) {',
|
|
119
147
|
' return notFoundResponse(c);',
|
|
120
148
|
' }',
|
|
121
|
-
` return ${this.jsonRow('row', constantPrefix)};`,
|
|
149
|
+
` return ${this.jsonRow('row', this.model.name, constantPrefix)};`,
|
|
122
150
|
'});',
|
|
123
151
|
];
|
|
124
152
|
}
|
|
125
153
|
return [
|
|
126
|
-
`router.get('/${pathParams}', validateParam(${paramSchemaName}), async (c) => {`,
|
|
154
|
+
`router.get('/${pathParams}', validateParam(${paramSchemaName}), validateQuery(${getQuerySchemaName}), async (c) => {`,
|
|
127
155
|
' const db = c.get(\'db\');',
|
|
128
156
|
' const auth = c.get(\'auth\');',
|
|
129
157
|
` const policy = assertPolicy('${this.model.name}', auth.role, 'select');`,
|
|
130
158
|
' const policyWhere = resolvePolicyWhere(policy, auth);',
|
|
131
159
|
' const params = c.req.valid(\'param\');',
|
|
132
|
-
|
|
160
|
+
...includeBlock,
|
|
161
|
+
` const row = await db.${clientKey}.findUnique(mergeWhere({ ${whereFromParams} }, policyWhere), { include });`,
|
|
133
162
|
' if (!row) {',
|
|
134
163
|
' return notFoundResponse(c);',
|
|
135
164
|
' }',
|
|
136
|
-
` return ${this.jsonRow('row', constantPrefix)};`,
|
|
165
|
+
` return ${this.jsonRow('row', this.model.name, constantPrefix)};`,
|
|
137
166
|
'});',
|
|
138
167
|
];
|
|
139
168
|
}
|
|
@@ -144,7 +173,7 @@ export class RouteGenerator {
|
|
|
144
173
|
' const db = c.get(\'db\');',
|
|
145
174
|
' const body = c.req.valid(\'json\');',
|
|
146
175
|
` const row = await db.${clientKey}.create(body);`,
|
|
147
|
-
` return ${this.
|
|
176
|
+
` return ${this.mutationJsonRow('row', constantPrefix, 201)};`,
|
|
148
177
|
'});',
|
|
149
178
|
];
|
|
150
179
|
}
|
|
@@ -155,7 +184,7 @@ export class RouteGenerator {
|
|
|
155
184
|
` assertPolicy('${this.model.name}', auth.role, 'insert');`,
|
|
156
185
|
' const body = c.req.valid(\'json\');',
|
|
157
186
|
` const row = await db.${clientKey}.create(body);`,
|
|
158
|
-
` return ${this.
|
|
187
|
+
` return ${this.mutationJsonRow('row', constantPrefix, 201)};`,
|
|
159
188
|
'});',
|
|
160
189
|
];
|
|
161
190
|
}
|
|
@@ -167,7 +196,7 @@ export class RouteGenerator {
|
|
|
167
196
|
' const params = c.req.valid(\'param\');',
|
|
168
197
|
' const body = c.req.valid(\'json\');',
|
|
169
198
|
` const row = await db.${clientKey}.update({ where: { ${whereFromParams} }, data: body });`,
|
|
170
|
-
` return ${this.
|
|
199
|
+
` return ${this.mutationJsonRow('row', constantPrefix)};`,
|
|
171
200
|
'});',
|
|
172
201
|
];
|
|
173
202
|
}
|
|
@@ -180,7 +209,7 @@ export class RouteGenerator {
|
|
|
180
209
|
' const params = c.req.valid(\'param\');',
|
|
181
210
|
' const body = c.req.valid(\'json\');',
|
|
182
211
|
` const row = await db.${clientKey}.update({ where: mergeWhere({ ${whereFromParams} }, policyWhere), data: body });`,
|
|
183
|
-
` return ${this.
|
|
212
|
+
` return ${this.mutationJsonRow('row', constantPrefix)};`,
|
|
184
213
|
'});',
|
|
185
214
|
];
|
|
186
215
|
}
|
|
@@ -191,7 +220,7 @@ export class RouteGenerator {
|
|
|
191
220
|
' const db = c.get(\'db\');',
|
|
192
221
|
' const params = c.req.valid(\'param\');',
|
|
193
222
|
` const row = await db.${clientKey}.delete({ ${whereFromParams} });`,
|
|
194
|
-
` return ${this.
|
|
223
|
+
` return ${this.mutationJsonRow('row', constantPrefix)};`,
|
|
195
224
|
'});',
|
|
196
225
|
];
|
|
197
226
|
}
|
|
@@ -203,7 +232,7 @@ export class RouteGenerator {
|
|
|
203
232
|
' const policyWhere = resolvePolicyWhere(policy, auth);',
|
|
204
233
|
' const params = c.req.valid(\'param\');',
|
|
205
234
|
` const row = await db.${clientKey}.delete(mergeWhere({ ${whereFromParams} }, policyWhere));`,
|
|
206
|
-
` return ${this.
|
|
235
|
+
` return ${this.mutationJsonRow('row', constantPrefix)};`,
|
|
207
236
|
'});',
|
|
208
237
|
];
|
|
209
238
|
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { Field, Model, Schema } from '../../schema-dsl/ast.js';
|
|
2
|
+
import type { IncludableRelationTree } from '../../api/utils/include-query.js';
|
|
2
3
|
export declare function isStoredScalarField(field: Field, schema: Schema): boolean;
|
|
4
|
+
export declare function isRelationField(field: Field, schema: Schema): boolean;
|
|
3
5
|
export declare function isUnfilterable(field: Field): boolean;
|
|
6
|
+
export declare function isUnincludeable(field: Field): boolean;
|
|
4
7
|
export declare function isOmitted(field: Field): boolean;
|
|
5
8
|
export declare function getFilterableFields(model: Model, schema: Schema): Field[];
|
|
6
9
|
export declare function getOmittedFields(model: Model, schema: Schema): Field[];
|
|
10
|
+
export declare function getIncludableRelationFields(model: Model, schema: Schema): Field[];
|
|
11
|
+
export declare function buildIncludableRelationTree(model: Model, schema: Schema, visited?: Set<string>): IncludableRelationTree;
|
|
12
|
+
export declare function buildRelationTargets(model: Model, schema: Schema): Record<string, string>;
|
|
7
13
|
export declare function getSortableFieldNames(model: Model, schema: Schema): string[];
|
|
8
14
|
export declare function toModelConstantPrefix(modelName: string): string;
|
|
@@ -3,9 +3,15 @@ export function isStoredScalarField(field, schema) {
|
|
|
3
3
|
const modelNames = getModelNames(schema);
|
|
4
4
|
return !modelNames.has(field.type.name);
|
|
5
5
|
}
|
|
6
|
+
export function isRelationField(field, schema) {
|
|
7
|
+
return getModelNames(schema).has(field.type.name);
|
|
8
|
+
}
|
|
6
9
|
export function isUnfilterable(field) {
|
|
7
10
|
return fieldHasAttribute(field, 'unfilterable') || fieldHasAttribute(field, 'omit');
|
|
8
11
|
}
|
|
12
|
+
export function isUnincludeable(field) {
|
|
13
|
+
return fieldHasAttribute(field, 'unincludeable');
|
|
14
|
+
}
|
|
9
15
|
export function isOmitted(field) {
|
|
10
16
|
return fieldHasAttribute(field, 'omit');
|
|
11
17
|
}
|
|
@@ -15,6 +21,32 @@ export function getFilterableFields(model, schema) {
|
|
|
15
21
|
export function getOmittedFields(model, schema) {
|
|
16
22
|
return getStoredFields(model, getModelNames(schema)).filter((field) => isStoredScalarField(field, schema) && isOmitted(field));
|
|
17
23
|
}
|
|
24
|
+
export function getIncludableRelationFields(model, schema) {
|
|
25
|
+
return model.fields.filter((field) => isRelationField(field, schema) && !isUnincludeable(field));
|
|
26
|
+
}
|
|
27
|
+
export function buildIncludableRelationTree(model, schema, visited = new Set()) {
|
|
28
|
+
if (visited.has(model.name)) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
const nextVisited = new Set(visited);
|
|
32
|
+
nextVisited.add(model.name);
|
|
33
|
+
const tree = {};
|
|
34
|
+
for (const field of getIncludableRelationFields(model, schema)) {
|
|
35
|
+
const targetModel = schema.models.find((candidate) => candidate.name === field.type.name);
|
|
36
|
+
if (!targetModel) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
tree[field.name] = buildIncludableRelationTree(targetModel, schema, nextVisited);
|
|
40
|
+
}
|
|
41
|
+
return tree;
|
|
42
|
+
}
|
|
43
|
+
export function buildRelationTargets(model, schema) {
|
|
44
|
+
const targets = {};
|
|
45
|
+
for (const field of getIncludableRelationFields(model, schema)) {
|
|
46
|
+
targets[field.name] = field.type.name;
|
|
47
|
+
}
|
|
48
|
+
return targets;
|
|
49
|
+
}
|
|
18
50
|
export function getSortableFieldNames(model, schema) {
|
|
19
51
|
return getStoredFields(model, getModelNames(schema))
|
|
20
52
|
.filter((field) => isStoredScalarField(field, schema))
|
|
@@ -3,8 +3,11 @@ export declare class ZodSchemaGenerator {
|
|
|
3
3
|
private readonly schema;
|
|
4
4
|
constructor(schema: Schema);
|
|
5
5
|
generate(): string;
|
|
6
|
+
private generateGlobalMetadata;
|
|
6
7
|
private generateModelSchemas;
|
|
7
8
|
private generateListQuerySchemas;
|
|
9
|
+
private generateIncludeRefinement;
|
|
10
|
+
private generateReadQueryRefinement;
|
|
8
11
|
private generateListQueryFieldLines;
|
|
9
12
|
private generateObjectField;
|
|
10
13
|
private generateParamField;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { PACKAGE_NAME } from '../constants.js';
|
|
1
2
|
import { fieldHasAttribute, getFieldAttribute, getModelNames, getOptionalKvPair, getPrimaryKey, getStoredFields, } from '../sql-generator/utils/ast-helpers.js';
|
|
2
|
-
import { getFilterableFields, getOmittedFields, getSortableFieldNames, toModelConstantPrefix, } from './utils/api-fields.js';
|
|
3
|
+
import { buildIncludableRelationTree, buildRelationTargets, getFilterableFields, getOmittedFields, getSortableFieldNames, toModelConstantPrefix, } from './utils/api-fields.js';
|
|
3
4
|
import { buildFilterFieldMeta, queryParamKey, toFilterZodType, } from './utils/filter-operators.js';
|
|
4
5
|
export class ZodSchemaGenerator {
|
|
5
6
|
schema;
|
|
@@ -12,9 +13,26 @@ export class ZodSchemaGenerator {
|
|
|
12
13
|
return [
|
|
13
14
|
'// Auto-generated by ZodSchemaGenerator. Do not edit manually.',
|
|
14
15
|
"import { z } from 'zod';",
|
|
16
|
+
`import { validateIncludePaths } from '${PACKAGE_NAME}/api/utils/include-query';`,
|
|
15
17
|
`import type {\n ${typeImports},\n} from '../db-types.js';`,
|
|
16
18
|
'',
|
|
17
19
|
...modelBlocks,
|
|
20
|
+
this.generateGlobalMetadata(),
|
|
21
|
+
].join('\n');
|
|
22
|
+
}
|
|
23
|
+
generateGlobalMetadata() {
|
|
24
|
+
const omitByModel = Object.fromEntries(this.schema.models.map((model) => [
|
|
25
|
+
model.name,
|
|
26
|
+
getOmittedFields(model, this.schema).map((field) => field.name),
|
|
27
|
+
]));
|
|
28
|
+
const relationTargets = Object.fromEntries(this.schema.models.map((model) => [
|
|
29
|
+
model.name,
|
|
30
|
+
buildRelationTargets(model, this.schema),
|
|
31
|
+
]));
|
|
32
|
+
return [
|
|
33
|
+
`export const API_OMIT_FIELDS_BY_MODEL = ${JSON.stringify(omitByModel, null, 2)} as const;`,
|
|
34
|
+
`export const API_RELATION_TARGETS = ${JSON.stringify(relationTargets, null, 2)} as const;`,
|
|
35
|
+
'',
|
|
18
36
|
].join('\n');
|
|
19
37
|
}
|
|
20
38
|
generateModelSchemas(model) {
|
|
@@ -49,39 +67,79 @@ export class ZodSchemaGenerator {
|
|
|
49
67
|
const omittedFields = getOmittedFields(model, this.schema);
|
|
50
68
|
const filterFieldMeta = filterableFields.map((field) => buildFilterFieldMeta(field, this.schema));
|
|
51
69
|
const queryLines = filterableFields.flatMap((field) => this.generateListQueryFieldLines(field, filterFieldMeta.find((meta) => meta.name === field.name)));
|
|
52
|
-
queryLines.push('limit: z.coerce.number().int().min(1).max(100).optional(),', 'offset: z.coerce.number().int().min(0).optional(),', 'sort: z.string().optional(),');
|
|
70
|
+
queryLines.push('limit: z.coerce.number().int().min(1).max(100).optional(),', 'offset: z.coerce.number().int().min(0).optional(),', 'sort: z.string().optional(),', 'include: z.string().optional(),');
|
|
53
71
|
const sortableLiteral = sortableFields.map((field) => `'${field}'`).join(', ');
|
|
54
72
|
const listQueryFieldsJson = JSON.stringify(filterFieldMeta, null, 2);
|
|
55
73
|
const omitFieldNames = omittedFields.map((field) => field.name);
|
|
56
74
|
const omitFieldsJson = JSON.stringify(omitFieldNames);
|
|
75
|
+
const includableRelationsJson = JSON.stringify(buildIncludableRelationTree(model, this.schema), null, 2);
|
|
57
76
|
const responseType = omittedFields.length === 0
|
|
58
77
|
? `export type ${model.name}Response = ${model.name};`
|
|
59
78
|
: `export type ${model.name}Response = Omit<${model.name}, ${omitFieldNames.map((name) => `'${name}'`).join(' | ')}>;`;
|
|
60
79
|
return [
|
|
61
80
|
`export const ${prefix}_SORTABLE_FIELDS = [${sortableLiteral}] as const;`,
|
|
62
81
|
`export const ${prefix}_LIST_QUERY_FIELDS = ${listQueryFieldsJson} as const;`,
|
|
82
|
+
`export const ${prefix}_INCLUDABLE_RELATIONS = ${includableRelationsJson} as const;`,
|
|
63
83
|
`export const ${prefix}_OMIT_FIELDS = ${omitFieldsJson} as const;`,
|
|
64
84
|
responseType + '\n',
|
|
85
|
+
`export const ${model.name}GetQuerySchema = z`,
|
|
86
|
+
' .object({',
|
|
87
|
+
' include: z.string().optional(),',
|
|
88
|
+
' })',
|
|
89
|
+
...this.generateIncludeRefinement(prefix),
|
|
90
|
+
'\n',
|
|
65
91
|
`export const ${model.name}ListQuerySchema = z`,
|
|
66
92
|
' .object({',
|
|
67
93
|
...queryLines.map((line) => ` ${line}`),
|
|
68
94
|
' })',
|
|
95
|
+
...this.generateReadQueryRefinement(prefix),
|
|
96
|
+
'\n',
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
generateIncludeRefinement(prefix) {
|
|
100
|
+
return [
|
|
69
101
|
' .superRefine((data, ctx) => {',
|
|
70
|
-
' if (data.
|
|
102
|
+
' if (data.include === undefined) {',
|
|
71
103
|
' return;',
|
|
72
104
|
' }',
|
|
73
|
-
|
|
74
|
-
'
|
|
75
|
-
` if (!(${prefix}_SORTABLE_FIELDS as readonly string[]).includes(field)) {`,
|
|
105
|
+
` const includeError = validateIncludePaths(data.include, ${prefix}_INCLUDABLE_RELATIONS);`,
|
|
106
|
+
' if (includeError) {',
|
|
76
107
|
' ctx.addIssue({',
|
|
77
108
|
' code: \'custom\',',
|
|
78
|
-
' message:
|
|
79
|
-
' path: [\'
|
|
109
|
+
' message: includeError,',
|
|
110
|
+
' path: [\'include\'],',
|
|
80
111
|
' });',
|
|
81
112
|
' }',
|
|
82
113
|
' });\n',
|
|
83
114
|
];
|
|
84
115
|
}
|
|
116
|
+
generateReadQueryRefinement(prefix) {
|
|
117
|
+
return [
|
|
118
|
+
' .superRefine((data, ctx) => {',
|
|
119
|
+
' if (data.sort !== undefined) {',
|
|
120
|
+
' const descending = data.sort.startsWith(\'-\');',
|
|
121
|
+
' const field = descending ? data.sort.slice(1) : data.sort;',
|
|
122
|
+
` if (!(${prefix}_SORTABLE_FIELDS as readonly string[]).includes(field)) {`,
|
|
123
|
+
' ctx.addIssue({',
|
|
124
|
+
' code: \'custom\',',
|
|
125
|
+
' message: `Invalid sort field "${field}"`,',
|
|
126
|
+
' path: [\'sort\'],',
|
|
127
|
+
' });',
|
|
128
|
+
' }',
|
|
129
|
+
' }',
|
|
130
|
+
' if (data.include !== undefined) {',
|
|
131
|
+
` const includeError = validateIncludePaths(data.include, ${prefix}_INCLUDABLE_RELATIONS);`,
|
|
132
|
+
' if (includeError) {',
|
|
133
|
+
' ctx.addIssue({',
|
|
134
|
+
' code: \'custom\',',
|
|
135
|
+
' message: includeError,',
|
|
136
|
+
' path: [\'include\'],',
|
|
137
|
+
' });',
|
|
138
|
+
' }',
|
|
139
|
+
' }',
|
|
140
|
+
' });\n',
|
|
141
|
+
];
|
|
142
|
+
}
|
|
85
143
|
generateListQueryFieldLines(field, meta) {
|
|
86
144
|
const lines = [];
|
|
87
145
|
for (const operator of meta.operators) {
|
package/dist/cli/db.js
CHANGED
|
@@ -5,6 +5,7 @@ import { applyPendingMigrations } from '../db/migrate.js';
|
|
|
5
5
|
import { createMigration, getAppliedMigrationFilenames, listMigrationFiles } from '../db/migrations.js';
|
|
6
6
|
import { snapshotExists } from '../db/schema-state.js';
|
|
7
7
|
import { resolveSchemaPath } from './paths.js';
|
|
8
|
+
import { waitForDatabase } from './wait-for-database.js';
|
|
8
9
|
function parseDiffArgs(args) {
|
|
9
10
|
let schemaPath = resolveSchemaPath();
|
|
10
11
|
let name;
|
|
@@ -50,6 +51,7 @@ export async function runDbBootstrap(schemaPath) {
|
|
|
50
51
|
const resolvedSchemaPath = resolveSchemaPath(schemaPath);
|
|
51
52
|
const client = new DatabaseClient();
|
|
52
53
|
try {
|
|
54
|
+
await waitForDatabase({ client });
|
|
53
55
|
await bootstrapDatabase(resolvedSchemaPath, client);
|
|
54
56
|
process.stdout.write(`Database bootstrapped from ${resolvedSchemaPath}\n`);
|
|
55
57
|
}
|
package/dist/cli/dev.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function runDev(
|
|
1
|
+
export declare function runDev(args?: string[]): Promise<void>;
|
package/dist/cli/dev.js
CHANGED
|
@@ -1,10 +1,150 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { watch } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { runDbBootstrap } from './db.js';
|
|
5
|
+
import { generateAll } from './generate.js';
|
|
6
|
+
import { DEFAULT_OUTPUT_DIR, resolveSchemaPath } from './paths.js';
|
|
7
|
+
const WATCH_DEBOUNCE_MS = 300;
|
|
8
|
+
const SERVER_STOP_TIMEOUT_MS = 5000;
|
|
9
|
+
function parseDevArgs(args) {
|
|
10
|
+
let schemaPath = resolveSchemaPath();
|
|
11
|
+
let watchSchema = true;
|
|
12
|
+
for (const arg of args) {
|
|
13
|
+
if (arg === '--no-watch') {
|
|
14
|
+
watchSchema = false;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (!arg.startsWith('--')) {
|
|
18
|
+
schemaPath = resolveSchemaPath(arg);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { schemaPath, watchSchema };
|
|
22
|
+
}
|
|
23
|
+
function createDebouncer(fn, ms) {
|
|
24
|
+
let timer;
|
|
25
|
+
return () => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
timer = setTimeout(() => {
|
|
28
|
+
void fn();
|
|
29
|
+
}, ms);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function startServer(appPath) {
|
|
33
|
+
return spawn(process.execPath, ['--import', 'tsx', appPath], {
|
|
34
|
+
stdio: 'inherit',
|
|
35
|
+
cwd: process.cwd(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function stopServer(serverProcess) {
|
|
39
|
+
if (!serverProcess || serverProcess.exitCode !== null || serverProcess.killed) {
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
}
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
serverProcess.once('exit', () => resolve());
|
|
44
|
+
if (process.platform === 'win32') {
|
|
45
|
+
serverProcess.kill();
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
serverProcess.kill('SIGTERM');
|
|
49
|
+
}
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
if (serverProcess.exitCode === null && !serverProcess.killed) {
|
|
52
|
+
serverProcess.kill('SIGKILL');
|
|
53
|
+
}
|
|
54
|
+
}, SERVER_STOP_TIMEOUT_MS);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function waitForServerExit(serverProcess) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
serverProcess.once('exit', () => resolve());
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export async function runDev(args = []) {
|
|
63
|
+
const { schemaPath, watchSchema } = parseDevArgs(args);
|
|
8
64
|
const appPath = path.resolve(DEFAULT_OUTPUT_DIR, 'app.ts');
|
|
9
|
-
|
|
65
|
+
let serverProcess = null;
|
|
66
|
+
let syncInProgress = false;
|
|
67
|
+
let restarting = false;
|
|
68
|
+
let shuttingDown = false;
|
|
69
|
+
async function syncAndServe() {
|
|
70
|
+
if (syncInProgress) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
syncInProgress = true;
|
|
74
|
+
try {
|
|
75
|
+
await generateAll(schemaPath);
|
|
76
|
+
await runDbBootstrap(schemaPath);
|
|
77
|
+
await stopServer(serverProcess);
|
|
78
|
+
serverProcess = startServer(appPath);
|
|
79
|
+
serverProcess.on('exit', (code, signal) => {
|
|
80
|
+
if (restarting || shuttingDown) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!watchSchema) {
|
|
84
|
+
if (code !== 0 && code !== null) {
|
|
85
|
+
process.exitCode = code;
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (code !== 0 && code !== null) {
|
|
90
|
+
process.stderr.write(`Dev server exited with code ${code}\n`);
|
|
91
|
+
process.exitCode = code;
|
|
92
|
+
shuttingDown = true;
|
|
93
|
+
}
|
|
94
|
+
else if (signal) {
|
|
95
|
+
process.stderr.write(`Dev server terminated by signal ${signal}\n`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
syncInProgress = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function reload() {
|
|
104
|
+
if (shuttingDown || syncInProgress) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
process.stderr.write('\nSchema changed — regenerating, bootstrapping, and restarting...\n');
|
|
108
|
+
restarting = true;
|
|
109
|
+
try {
|
|
110
|
+
await syncAndServe();
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
restarting = false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const scheduleReload = createDebouncer(reload, WATCH_DEBOUNCE_MS);
|
|
117
|
+
async function shutdown() {
|
|
118
|
+
if (shuttingDown) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
shuttingDown = true;
|
|
122
|
+
await stopServer(serverProcess);
|
|
123
|
+
}
|
|
124
|
+
process.once('SIGINT', () => {
|
|
125
|
+
void shutdown().finally(() => {
|
|
126
|
+
process.exit(process.exitCode ?? 0);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
process.once('SIGTERM', () => {
|
|
130
|
+
void shutdown().finally(() => {
|
|
131
|
+
process.exit(process.exitCode ?? 0);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
await syncAndServe();
|
|
135
|
+
if (!watchSchema) {
|
|
136
|
+
if (serverProcess) {
|
|
137
|
+
await waitForServerExit(serverProcess);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
watch(schemaPath, scheduleReload);
|
|
142
|
+
await new Promise((resolve) => {
|
|
143
|
+
const interval = setInterval(() => {
|
|
144
|
+
if (shuttingDown) {
|
|
145
|
+
clearInterval(interval);
|
|
146
|
+
resolve();
|
|
147
|
+
}
|
|
148
|
+
}, 100);
|
|
149
|
+
});
|
|
10
150
|
}
|
package/dist/cli/init.js
CHANGED
|
@@ -105,8 +105,11 @@ export async function runInit(args) {
|
|
|
105
105
|
console.log(' make dev');
|
|
106
106
|
console.log('');
|
|
107
107
|
console.log(' # or run individually:');
|
|
108
|
-
console.log(' docker compose up -d');
|
|
108
|
+
console.log(' docker compose up -d --wait');
|
|
109
|
+
console.log(` npx ${PACKAGE_NAME} dev # generate + bootstrap + server + schema watch`);
|
|
110
|
+
console.log('');
|
|
111
|
+
console.log(' # split steps (dev already includes generate, bootstrap, and watch):');
|
|
109
112
|
console.log(` npx ${PACKAGE_NAME} generate`);
|
|
110
113
|
console.log(` npx ${PACKAGE_NAME} db:bootstrap`);
|
|
111
|
-
console.log(` npx ${PACKAGE_NAME} dev`);
|
|
114
|
+
console.log(` npx ${PACKAGE_NAME} dev --no-watch`);
|
|
112
115
|
}
|
package/dist/cli/templates.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export declare const APP_SCHEMA_TEMPLATE = "extensions {\n\n}\n\nenums {\n\n}\n\nmodels {\n model User {\n id: UUID @id @default(gen_random_uuid())\n email: VARCHAR(255) @unique\n name: VARCHAR(150)\n createdAt: TIMESTAMP @default(now())\n }\n}\n";
|
|
2
2
|
export declare const ENV_TEMPLATE = "DATABASE_URL=postgresql://postgrest:postgrest@localhost:5432/postgrest\nJWT_SECRET=\nJWT_ROLE_CLAIM=role\nJWT_USER_ID_CLAIM=sub\n";
|
|
3
3
|
export declare const GITIGNORE_TEMPLATE = "node_modules/\ndist/\n.env\ndocker_data/\n.DS_Store\n*.log\nnpm-debug.log*\n";
|
|
4
|
-
export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgis/postgis:16-3.4\n container_name: schematic-pg
|
|
4
|
+
export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgis/postgis:16-3.4\n container_name: schematic-pg\n restart: unless-stopped\n ports:\n - \"5432:5432\"\n environment:\n POSTGRES_USER: postgrest\n POSTGRES_PASSWORD: postgrest\n POSTGRES_DB: postgrest\n volumes:\n - ./docker_data/postgres:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\n";
|
|
5
5
|
export declare const TSCONFIG_TEMPLATE = "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"strict\": true,\n \"esModuleInterop\": true,\n \"skipLibCheck\": true,\n \"outDir\": \"dist\",\n \"rootDir\": \".\"\n },\n \"include\": [\"generated/**/*\", \"src/**/*\"]\n}\n";
|
|
6
|
-
export declare const MAKEFILE_TEMPLATE = ".PHONY: dev\n\ndev:\n\tdocker compose up -d
|
|
6
|
+
export declare const MAKEFILE_TEMPLATE = ".PHONY: dev\n\ndev:\n\tdocker compose up -d --wait\n\tnpx schematic-pg dev\n";
|
|
7
7
|
export declare const HEALTH_ROUTE_TEMPLATE = "import { Hono } from 'hono';\nimport type { AppEnv } from 'schematic-pg/api/types';\n\nconst router = new Hono<AppEnv>();\nrouter.get('/', (c) => c.json({ ok: true }));\nexport default router;\n";
|
|
8
8
|
export declare function createPackageJsonTemplate(projectName: string): string;
|
package/dist/cli/templates.js
CHANGED
|
@@ -32,7 +32,7 @@ npm-debug.log*
|
|
|
32
32
|
export const DOCKER_COMPOSE_TEMPLATE = `services:
|
|
33
33
|
postgres:
|
|
34
34
|
image: postgis/postgis:16-3.4
|
|
35
|
-
container_name: schematic-pg
|
|
35
|
+
container_name: schematic-pg
|
|
36
36
|
restart: unless-stopped
|
|
37
37
|
ports:
|
|
38
38
|
- "5432:5432"
|
|
@@ -65,9 +65,7 @@ export const TSCONFIG_TEMPLATE = `{
|
|
|
65
65
|
export const MAKEFILE_TEMPLATE = `.PHONY: dev
|
|
66
66
|
|
|
67
67
|
dev:
|
|
68
|
-
\tdocker compose up -d
|
|
69
|
-
\tnpx ${PACKAGE_NAME} generate
|
|
70
|
-
\tnpx ${PACKAGE_NAME} db:bootstrap
|
|
68
|
+
\tdocker compose up -d --wait
|
|
71
69
|
\tnpx ${PACKAGE_NAME} dev
|
|
72
70
|
`;
|
|
73
71
|
export const HEALTH_ROUTE_TEMPLATE = `import { Hono } from 'hono';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { DatabaseClient } from '../db/client.js';
|
|
2
|
+
export type WaitForDatabaseOptions = {
|
|
3
|
+
maxAttempts?: number;
|
|
4
|
+
intervalMs?: number;
|
|
5
|
+
client?: Pick<DatabaseClient, 'query'>;
|
|
6
|
+
sleep?: (ms: number) => Promise<void>;
|
|
7
|
+
};
|
|
8
|
+
export declare function pingDatabase(client: Pick<DatabaseClient, 'query'>): Promise<void>;
|
|
9
|
+
export declare function waitForDatabase(options?: WaitForDatabaseOptions): Promise<void>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { DatabaseClient } from '../db/client.js';
|
|
2
|
+
export async function pingDatabase(client) {
|
|
3
|
+
const result = await client.query('SELECT 1 AS ok');
|
|
4
|
+
const ok = result.rows[0]?.ok;
|
|
5
|
+
if (ok !== 1) {
|
|
6
|
+
throw new Error(`Unexpected ping result: ${String(ok)}`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function waitForDatabase(options = {}) {
|
|
10
|
+
const maxAttempts = options.maxAttempts ?? 30;
|
|
11
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
12
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
13
|
+
const ownClient = options.client ? null : new DatabaseClient();
|
|
14
|
+
const client = options.client ?? ownClient;
|
|
15
|
+
try {
|
|
16
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
17
|
+
try {
|
|
18
|
+
await pingDatabase(client);
|
|
19
|
+
if (attempt > 1) {
|
|
20
|
+
process.stderr.write(`Database ready (attempt ${attempt}/${maxAttempts})\n`);
|
|
21
|
+
}
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (attempt === maxAttempts) {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
throw new Error(`Database not ready after ${maxAttempts} attempts (${intervalMs}ms interval): ${message}`);
|
|
28
|
+
}
|
|
29
|
+
process.stderr.write(`Waiting for database (${attempt}/${maxAttempts})...\n`);
|
|
30
|
+
await sleep(intervalMs);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
if (ownClient) {
|
|
36
|
+
await ownClient.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ Commands:
|
|
|
12
12
|
generate:sql [schema] Generate SQL DDL to stdout
|
|
13
13
|
generate:client [schema] Generate db client files
|
|
14
14
|
generate:api [schema] Generate API files
|
|
15
|
-
dev [schema]
|
|
15
|
+
dev [schema] [--no-watch] Generate, bootstrap DB, start server, watch schema
|
|
16
16
|
db:ping Test database connection
|
|
17
17
|
db:bootstrap [schema] Apply DDL and snapshot schema state
|
|
18
18
|
db:diff [schema] Show schema diff (--name <name> to write migration)
|
|
@@ -50,7 +50,7 @@ async function main() {
|
|
|
50
50
|
await generateApi(schemaPath);
|
|
51
51
|
break;
|
|
52
52
|
case 'dev':
|
|
53
|
-
await runDev(
|
|
53
|
+
await runDev(args);
|
|
54
54
|
break;
|
|
55
55
|
case 'db:ping':
|
|
56
56
|
await runDbPing();
|
package/dist/constants.d.ts
CHANGED
package/dist/constants.js
CHANGED