schematic-pg 0.1.11 → 0.1.13
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 +2 -2
- package/dist/api/openapi.d.ts +7 -0
- package/dist/api/openapi.js +27 -0
- package/dist/api-generator/app-generator.js +4 -0
- package/dist/api-generator/index.d.ts +2 -0
- package/dist/api-generator/index.js +9 -4
- package/dist/api-generator/openapi-generator.d.ts +29 -0
- package/dist/api-generator/openapi-generator.js +509 -0
- package/dist/cli/generate.js +2 -0
- package/dist/cli/templates/agents.md +1 -1
- package/dist/cli/templates.d.ts +1 -1
- package/dist/cli/templates.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/db/bootstrap.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -449,14 +449,14 @@ npm run start # schematic-pg start
|
|
|
449
449
|
|
|
450
450
|
```bash
|
|
451
451
|
schematic-pg db:ping [schema] # Test DATABASE_URL connection (SELECT 1)
|
|
452
|
-
schematic-pg db:bootstrap [schema] #
|
|
452
|
+
schematic-pg db:bootstrap [schema] # Reset public schema, apply DDL, write .schema-state snapshot
|
|
453
453
|
schematic-pg db:diff [schema] # Print pending schema changes (snapshot vs app.schema)
|
|
454
454
|
schematic-pg db:diff --name add_users # Write a migration file under migrations/
|
|
455
455
|
schematic-pg db:migrate [schema] # Apply pending migration files
|
|
456
456
|
schematic-pg db:migrate:status [schema] # Show snapshot + migration file status
|
|
457
457
|
```
|
|
458
458
|
|
|
459
|
-
`db:bootstrap`
|
|
459
|
+
`db:bootstrap` resets the `public` schema then applies full DDL — safe to re-run locally (including via `dev` watch). Use `db:diff` / `db:migrate` when evolving a database you need to keep.
|
|
460
460
|
|
|
461
461
|
For a full walkthrough (mental model, local loop, and automating staging/production with GitHub Actions), see [Migrations tutorial](docs/migrations.md).
|
|
462
462
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const SCALAR_HTML = `<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>API Reference</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
11
|
+
<script>
|
|
12
|
+
Scalar.createApiReference('#app', {
|
|
13
|
+
url: '/openapi.json',
|
|
14
|
+
});
|
|
15
|
+
</script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
18
|
+
`;
|
|
19
|
+
/**
|
|
20
|
+
* Mounts public API docs routes:
|
|
21
|
+
* - GET /openapi.json — OpenAPI document
|
|
22
|
+
* - GET /docs — Scalar API reference UI (CDN)
|
|
23
|
+
*/
|
|
24
|
+
export function mountApiDocs(app, document) {
|
|
25
|
+
app.get('/openapi.json', (c) => c.json(document));
|
|
26
|
+
app.get('/docs', (c) => c.html(SCALAR_HTML));
|
|
27
|
+
}
|
|
@@ -40,6 +40,7 @@ export class AppGenerator {
|
|
|
40
40
|
"import { createDbClient } from './db.js';",
|
|
41
41
|
"import { POLICIES } from './policies.js';",
|
|
42
42
|
"import { HOOKS } from './hooks.js';",
|
|
43
|
+
"import { openApiDocument } from './openapi.js';",
|
|
43
44
|
`import { configurePolicies } from '${PACKAGE_NAME}/api/auth/policy';`,
|
|
44
45
|
`import { configureHooks } from '${PACKAGE_NAME}/api/hooks';`,
|
|
45
46
|
`import { createAuthMiddleware } from '${PACKAGE_NAME}/api/auth/middleware';`,
|
|
@@ -47,6 +48,7 @@ export class AppGenerator {
|
|
|
47
48
|
`import type { AuthResolver } from '${PACKAGE_NAME}/api/auth/types';`,
|
|
48
49
|
`import { createDbMiddleware } from '${PACKAGE_NAME}/api/middleware/db';`,
|
|
49
50
|
`import { handleError } from '${PACKAGE_NAME}/api/middleware/errors';`,
|
|
51
|
+
`import { mountApiDocs } from '${PACKAGE_NAME}/api/openapi';`,
|
|
50
52
|
`import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
|
|
51
53
|
'',
|
|
52
54
|
'configurePolicies(POLICIES);',
|
|
@@ -59,6 +61,7 @@ export class AppGenerator {
|
|
|
59
61
|
'',
|
|
60
62
|
'export function createApp(options: CreateAppOptions = {}): Hono<AppEnv> {',
|
|
61
63
|
' const app = new Hono<AppEnv>();',
|
|
64
|
+
' mountApiDocs(app, openApiDocument);',
|
|
62
65
|
' app.use(logger());',
|
|
63
66
|
' app.use(prettyJSON());',
|
|
64
67
|
' app.use(createDbMiddleware({ pool: options.pool, createDbClient }));',
|
|
@@ -76,6 +79,7 @@ export class AppGenerator {
|
|
|
76
79
|
' const port = Number(process.env.PORT ?? 3000);',
|
|
77
80
|
' serve({ fetch: createApp().fetch, port }, () => {',
|
|
78
81
|
' console.log(`Server running at http://localhost:${port}`);',
|
|
82
|
+
' console.log(`API docs at http://localhost:${port}/docs`);',
|
|
79
83
|
' });',
|
|
80
84
|
'}',
|
|
81
85
|
'',
|
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
import { DEFAULT_HOOKS_DIR } from '../cli/paths.js';
|
|
1
|
+
import { DEFAULT_CUSTOM_ROUTES_DIR, DEFAULT_HOOKS_DIR } from '../cli/paths.js';
|
|
2
2
|
import { generateAppFile } from './app-generator.js';
|
|
3
|
+
import { discoverCustomRoutes } from './custom-route-scanner.js';
|
|
3
4
|
import { discoverHooks } from './hook-scanner.js';
|
|
4
5
|
import { generateHooksFile } from './hooks-generator.js';
|
|
6
|
+
import { generateOpenApiFiles } from './openapi-generator.js';
|
|
5
7
|
import { generatePoliciesFile } from './policy-generator.js';
|
|
6
8
|
import { generateRouteFiles } from './route-generator.js';
|
|
7
9
|
import { generateValidationSchemas } from './zod-schema-generator.js';
|
|
8
10
|
export function generateApiFiles(schema, options) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
: undefined;
|
|
11
|
+
const customRoutesDir = options?.customRoutesDir ?? DEFAULT_CUSTOM_ROUTES_DIR;
|
|
12
|
+
const appOptions = { customRoutesDir };
|
|
12
13
|
const hooksDir = options?.hooksDir ?? DEFAULT_HOOKS_DIR;
|
|
13
14
|
const { entries: hookEntries, modelsWithHooks } = discoverHooks(hooksDir, schema);
|
|
15
|
+
const includeAuthPaths = discoverCustomRoutes(customRoutesDir).some((entry) => entry.basePath === 'auth');
|
|
16
|
+
const openapi = generateOpenApiFiles(schema, { includeAuthPaths });
|
|
14
17
|
return {
|
|
15
18
|
app: generateAppFile(schema, appOptions),
|
|
16
19
|
policies: generatePoliciesFile(schema),
|
|
17
20
|
validation: generateValidationSchemas(schema),
|
|
18
21
|
hooks: generateHooksFile(hookEntries),
|
|
19
22
|
routes: generateRouteFiles(schema, modelsWithHooks),
|
|
23
|
+
openapiTs: openapi.openapiTs,
|
|
24
|
+
openapiJson: openapi.openapiJson,
|
|
20
25
|
};
|
|
21
26
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Schema } from '../schema-dsl/ast.js';
|
|
2
|
+
export interface OpenApiGeneratorOptions {
|
|
3
|
+
includeAuthPaths?: boolean;
|
|
4
|
+
}
|
|
5
|
+
type OpenApiDocument = Record<string, unknown>;
|
|
6
|
+
export declare class OpenApiGenerator {
|
|
7
|
+
private readonly schema;
|
|
8
|
+
private readonly options;
|
|
9
|
+
constructor(schema: Schema, options?: OpenApiGeneratorOptions);
|
|
10
|
+
generate(): OpenApiDocument;
|
|
11
|
+
generateJson(): string;
|
|
12
|
+
generateTsModule(): string;
|
|
13
|
+
private buildModelComponentSchemas;
|
|
14
|
+
private buildModelPaths;
|
|
15
|
+
private buildListQueryParameters;
|
|
16
|
+
private buildIncludeParameter;
|
|
17
|
+
private buildPathParameters;
|
|
18
|
+
private filterParamSchema;
|
|
19
|
+
private fieldToOpenApiSchema;
|
|
20
|
+
private mapTypeToOpenApi;
|
|
21
|
+
private buildAuthComponentSchemas;
|
|
22
|
+
private buildAuthPaths;
|
|
23
|
+
}
|
|
24
|
+
export declare function generateOpenApiDocument(schema: Schema, options?: OpenApiGeneratorOptions): OpenApiDocument;
|
|
25
|
+
export declare function generateOpenApiFiles(schema: Schema, options?: OpenApiGeneratorOptions): {
|
|
26
|
+
openapiTs: string;
|
|
27
|
+
openapiJson: string;
|
|
28
|
+
};
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { toRouteBasePath } from '../api/utils/route-naming.js';
|
|
2
|
+
import { fieldHasAttribute, getModelNames, getPrimaryKey, getStoredFields, } from '../sql-generator/utils/ast-helpers.js';
|
|
3
|
+
import { getFilterableFields, getIncludableRelationFields, getOmittedFields, getSortableFieldNames, isStoredScalarField, } from './utils/api-fields.js';
|
|
4
|
+
import { buildFilterFieldMeta, queryParamKey } from './utils/filter-operators.js';
|
|
5
|
+
const ERROR_REF = { $ref: '#/components/schemas/Error' };
|
|
6
|
+
const ERROR_EXAMPLE_VALIDATION = 'Validation failed';
|
|
7
|
+
const ERROR_EXAMPLE_FORBIDDEN = 'Role "USER" is not allowed to list this resource';
|
|
8
|
+
const ERROR_EXAMPLE_CONFLICT = 'Unique constraint violation on email';
|
|
9
|
+
const OPTIONAL_BEARER_SECURITY = [{}, { bearerAuth: [] }];
|
|
10
|
+
function errorResponse(description, exampleMessage = description) {
|
|
11
|
+
return {
|
|
12
|
+
description,
|
|
13
|
+
content: {
|
|
14
|
+
'application/json': {
|
|
15
|
+
schema: ERROR_REF,
|
|
16
|
+
example: { error: exampleMessage },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function jsonContent(schema) {
|
|
22
|
+
return {
|
|
23
|
+
'application/json': {
|
|
24
|
+
schema,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export class OpenApiGenerator {
|
|
29
|
+
schema;
|
|
30
|
+
options;
|
|
31
|
+
constructor(schema, options = {}) {
|
|
32
|
+
this.schema = schema;
|
|
33
|
+
this.options = options;
|
|
34
|
+
}
|
|
35
|
+
generate() {
|
|
36
|
+
const schemas = {
|
|
37
|
+
Error: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
required: ['error'],
|
|
40
|
+
properties: {
|
|
41
|
+
error: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Human-readable error message',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const paths = {};
|
|
49
|
+
for (const model of this.schema.models) {
|
|
50
|
+
Object.assign(schemas, this.buildModelComponentSchemas(model));
|
|
51
|
+
Object.assign(paths, this.buildModelPaths(model));
|
|
52
|
+
}
|
|
53
|
+
if (this.options.includeAuthPaths) {
|
|
54
|
+
Object.assign(schemas, this.buildAuthComponentSchemas());
|
|
55
|
+
Object.assign(paths, this.buildAuthPaths());
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
openapi: '3.1.0',
|
|
59
|
+
info: {
|
|
60
|
+
title: 'schematic-pg API',
|
|
61
|
+
version: '1.0.0',
|
|
62
|
+
description: 'Auto-generated OpenAPI document from the schema AST. Regenerated by schematic-pg generate:api.',
|
|
63
|
+
},
|
|
64
|
+
components: {
|
|
65
|
+
securitySchemes: {
|
|
66
|
+
bearerAuth: {
|
|
67
|
+
type: 'http',
|
|
68
|
+
scheme: 'bearer',
|
|
69
|
+
bearerFormat: 'JWT',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
schemas,
|
|
73
|
+
},
|
|
74
|
+
paths,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
generateJson() {
|
|
78
|
+
return `${JSON.stringify(this.generate(), null, 2)}\n`;
|
|
79
|
+
}
|
|
80
|
+
generateTsModule() {
|
|
81
|
+
const document = this.generate();
|
|
82
|
+
return [
|
|
83
|
+
'// Auto-generated by OpenApiGenerator. Do not edit manually.',
|
|
84
|
+
`export const openApiDocument = ${JSON.stringify(document, null, 2)} as const;`,
|
|
85
|
+
'',
|
|
86
|
+
].join('\n');
|
|
87
|
+
}
|
|
88
|
+
buildModelComponentSchemas(model) {
|
|
89
|
+
const modelNames = getModelNames(this.schema);
|
|
90
|
+
const storedFields = getStoredFields(model, modelNames).filter((field) => isStoredScalarField(field, this.schema));
|
|
91
|
+
const primaryKey = getPrimaryKey(model);
|
|
92
|
+
const pkFields = new Set(primaryKey?.fields ?? []);
|
|
93
|
+
const omitted = new Set(getOmittedFields(model, this.schema).map((field) => field.name));
|
|
94
|
+
const responseProps = {};
|
|
95
|
+
const responseRequired = [];
|
|
96
|
+
for (const field of storedFields) {
|
|
97
|
+
if (omitted.has(field.name)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
responseProps[field.name] = this.fieldToOpenApiSchema(field, {
|
|
101
|
+
nullable: Boolean(field.type.optional),
|
|
102
|
+
});
|
|
103
|
+
if (!field.type.optional) {
|
|
104
|
+
responseRequired.push(field.name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const createProps = {};
|
|
108
|
+
const createRequired = [];
|
|
109
|
+
for (const field of storedFields) {
|
|
110
|
+
if (pkFields.has(field.name)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
createProps[field.name] = this.fieldToOpenApiSchema(field, {
|
|
114
|
+
nullable: Boolean(field.type.optional),
|
|
115
|
+
});
|
|
116
|
+
const optional = Boolean(field.type.optional) || fieldHasAttribute(field, 'default');
|
|
117
|
+
if (!optional) {
|
|
118
|
+
createRequired.push(field.name);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const updateProps = {};
|
|
122
|
+
for (const field of storedFields) {
|
|
123
|
+
if (pkFields.has(field.name)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
updateProps[field.name] = this.fieldToOpenApiSchema(field, {
|
|
127
|
+
nullable: Boolean(field.type.optional),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
[`${model.name}Response`]: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: responseProps,
|
|
134
|
+
...(responseRequired.length > 0 ? { required: responseRequired } : {}),
|
|
135
|
+
},
|
|
136
|
+
[`${model.name}Create`]: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: createProps,
|
|
139
|
+
...(createRequired.length > 0 ? { required: createRequired } : {}),
|
|
140
|
+
},
|
|
141
|
+
[`${model.name}Update`]: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: updateProps,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
buildModelPaths(model) {
|
|
148
|
+
const basePath = toRouteBasePath(model.name);
|
|
149
|
+
const collectionPath = `/${basePath}`;
|
|
150
|
+
const primaryKey = getPrimaryKey(model);
|
|
151
|
+
const pkFields = primaryKey?.fields ?? [];
|
|
152
|
+
const itemPathSegments = pkFields.map((field) => `{${field}}`);
|
|
153
|
+
const itemPath = itemPathSegments.length > 0
|
|
154
|
+
? `${collectionPath}/${itemPathSegments.join('/')}`
|
|
155
|
+
: collectionPath;
|
|
156
|
+
const responseRef = { $ref: `#/components/schemas/${model.name}Response` };
|
|
157
|
+
const createRef = { $ref: `#/components/schemas/${model.name}Create` };
|
|
158
|
+
const updateRef = { $ref: `#/components/schemas/${model.name}Update` };
|
|
159
|
+
const tag = model.name;
|
|
160
|
+
const paths = {
|
|
161
|
+
[collectionPath]: {
|
|
162
|
+
get: {
|
|
163
|
+
tags: [tag],
|
|
164
|
+
summary: `List ${model.name}`,
|
|
165
|
+
operationId: `list${model.name}`,
|
|
166
|
+
security: OPTIONAL_BEARER_SECURITY,
|
|
167
|
+
parameters: this.buildListQueryParameters(model),
|
|
168
|
+
responses: {
|
|
169
|
+
'200': {
|
|
170
|
+
description: `List of ${model.name}`,
|
|
171
|
+
content: jsonContent({ type: 'array', items: responseRef }),
|
|
172
|
+
},
|
|
173
|
+
'400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
|
|
174
|
+
'401': errorResponse('Unauthorized'),
|
|
175
|
+
'403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
|
|
176
|
+
'500': errorResponse('Internal server error'),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
post: {
|
|
180
|
+
tags: [tag],
|
|
181
|
+
summary: `Create ${model.name}`,
|
|
182
|
+
operationId: `create${model.name}`,
|
|
183
|
+
security: OPTIONAL_BEARER_SECURITY,
|
|
184
|
+
requestBody: {
|
|
185
|
+
required: true,
|
|
186
|
+
content: jsonContent(createRef),
|
|
187
|
+
},
|
|
188
|
+
responses: {
|
|
189
|
+
'201': {
|
|
190
|
+
description: `Created ${model.name}`,
|
|
191
|
+
content: jsonContent(responseRef),
|
|
192
|
+
},
|
|
193
|
+
'400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
|
|
194
|
+
'401': errorResponse('Unauthorized'),
|
|
195
|
+
'403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
|
|
196
|
+
'409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
|
|
197
|
+
'500': errorResponse('Internal server error'),
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
if (pkFields.length > 0) {
|
|
203
|
+
const pathParams = this.buildPathParameters(pkFields, model);
|
|
204
|
+
paths[itemPath] = {
|
|
205
|
+
get: {
|
|
206
|
+
tags: [tag],
|
|
207
|
+
summary: `Get ${model.name}`,
|
|
208
|
+
operationId: `get${model.name}`,
|
|
209
|
+
security: OPTIONAL_BEARER_SECURITY,
|
|
210
|
+
parameters: [...pathParams, this.buildIncludeParameter(model)],
|
|
211
|
+
responses: {
|
|
212
|
+
'200': {
|
|
213
|
+
description: `${model.name} record`,
|
|
214
|
+
content: jsonContent(responseRef),
|
|
215
|
+
},
|
|
216
|
+
'400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
|
|
217
|
+
'401': errorResponse('Unauthorized'),
|
|
218
|
+
'403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
|
|
219
|
+
'404': errorResponse('Not found'),
|
|
220
|
+
'500': errorResponse('Internal server error'),
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
put: {
|
|
224
|
+
tags: [tag],
|
|
225
|
+
summary: `Update ${model.name}`,
|
|
226
|
+
operationId: `update${model.name}`,
|
|
227
|
+
security: OPTIONAL_BEARER_SECURITY,
|
|
228
|
+
parameters: pathParams,
|
|
229
|
+
requestBody: {
|
|
230
|
+
required: true,
|
|
231
|
+
content: jsonContent(updateRef),
|
|
232
|
+
},
|
|
233
|
+
responses: {
|
|
234
|
+
'200': {
|
|
235
|
+
description: `Updated ${model.name}`,
|
|
236
|
+
content: jsonContent(responseRef),
|
|
237
|
+
},
|
|
238
|
+
'400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
|
|
239
|
+
'401': errorResponse('Unauthorized'),
|
|
240
|
+
'403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
|
|
241
|
+
'404': errorResponse('Not found'),
|
|
242
|
+
'409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
|
|
243
|
+
'500': errorResponse('Internal server error'),
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
delete: {
|
|
247
|
+
tags: [tag],
|
|
248
|
+
summary: `Delete ${model.name}`,
|
|
249
|
+
operationId: `delete${model.name}`,
|
|
250
|
+
security: OPTIONAL_BEARER_SECURITY,
|
|
251
|
+
parameters: pathParams,
|
|
252
|
+
responses: {
|
|
253
|
+
'200': {
|
|
254
|
+
description: `Deleted ${model.name}`,
|
|
255
|
+
content: jsonContent(responseRef),
|
|
256
|
+
},
|
|
257
|
+
'400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
|
|
258
|
+
'401': errorResponse('Unauthorized'),
|
|
259
|
+
'403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
|
|
260
|
+
'404': errorResponse('Not found'),
|
|
261
|
+
'500': errorResponse('Internal server error'),
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return paths;
|
|
267
|
+
}
|
|
268
|
+
buildListQueryParameters(model) {
|
|
269
|
+
const filterableFields = getFilterableFields(model, this.schema);
|
|
270
|
+
const sortableFields = getSortableFieldNames(model, this.schema);
|
|
271
|
+
const parameters = [];
|
|
272
|
+
for (const field of filterableFields) {
|
|
273
|
+
const meta = buildFilterFieldMeta(field, this.schema);
|
|
274
|
+
for (const operator of meta.operators) {
|
|
275
|
+
const name = queryParamKey(field.name, operator);
|
|
276
|
+
parameters.push({
|
|
277
|
+
name,
|
|
278
|
+
in: 'query',
|
|
279
|
+
required: false,
|
|
280
|
+
schema: this.filterParamSchema(field, operator),
|
|
281
|
+
description: operator === 'equals'
|
|
282
|
+
? `Filter by ${field.name}`
|
|
283
|
+
: `Filter ${field.name} with ${operator}`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
parameters.push({
|
|
288
|
+
name: 'limit',
|
|
289
|
+
in: 'query',
|
|
290
|
+
required: false,
|
|
291
|
+
schema: { type: 'integer', minimum: 1, maximum: 100 },
|
|
292
|
+
description: 'Max rows to return (max 100)',
|
|
293
|
+
}, {
|
|
294
|
+
name: 'offset',
|
|
295
|
+
in: 'query',
|
|
296
|
+
required: false,
|
|
297
|
+
schema: { type: 'integer', minimum: 0 },
|
|
298
|
+
description: 'Number of rows to skip',
|
|
299
|
+
}, {
|
|
300
|
+
name: 'sort',
|
|
301
|
+
in: 'query',
|
|
302
|
+
required: false,
|
|
303
|
+
schema: { type: 'string' },
|
|
304
|
+
description: `Sort field (prefix with - for desc). Allowed: ${sortableFields.join(', ') || '(none)'}`,
|
|
305
|
+
}, this.buildIncludeParameter(model));
|
|
306
|
+
return parameters;
|
|
307
|
+
}
|
|
308
|
+
buildIncludeParameter(model) {
|
|
309
|
+
const relationNames = getIncludableRelationFields(model, this.schema).map((field) => field.name);
|
|
310
|
+
return {
|
|
311
|
+
name: 'include',
|
|
312
|
+
in: 'query',
|
|
313
|
+
required: false,
|
|
314
|
+
schema: { type: 'string' },
|
|
315
|
+
description: relationNames.length > 0
|
|
316
|
+
? `Comma-separated relation paths (dot nesting). Available: ${relationNames.join(', ')}`
|
|
317
|
+
: 'Comma-separated relation paths (dot nesting)',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
buildPathParameters(pkFieldNames, model) {
|
|
321
|
+
const modelNames = getModelNames(this.schema);
|
|
322
|
+
const storedFields = getStoredFields(model, modelNames);
|
|
323
|
+
return pkFieldNames.map((name) => {
|
|
324
|
+
const field = storedFields.find((candidate) => candidate.name === name);
|
|
325
|
+
return {
|
|
326
|
+
name,
|
|
327
|
+
in: 'path',
|
|
328
|
+
required: true,
|
|
329
|
+
schema: field
|
|
330
|
+
? this.fieldToOpenApiSchema(field, { nullable: false })
|
|
331
|
+
: { type: 'string' },
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
filterParamSchema(field, operator) {
|
|
336
|
+
if (operator === 'in') {
|
|
337
|
+
return {
|
|
338
|
+
type: 'string',
|
|
339
|
+
description: 'Comma-separated values',
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return this.fieldToOpenApiSchema(field, { nullable: false });
|
|
343
|
+
}
|
|
344
|
+
fieldToOpenApiSchema(field, options = { nullable: false }) {
|
|
345
|
+
const base = this.mapTypeToOpenApi(field.type);
|
|
346
|
+
if (!options.nullable) {
|
|
347
|
+
return base;
|
|
348
|
+
}
|
|
349
|
+
const type = base.type;
|
|
350
|
+
if (typeof type === 'string') {
|
|
351
|
+
return { ...base, type: [type, 'null'] };
|
|
352
|
+
}
|
|
353
|
+
return { anyOf: [base, { type: 'null' }] };
|
|
354
|
+
}
|
|
355
|
+
mapTypeToOpenApi(type) {
|
|
356
|
+
const enumType = this.schema.enums.find((enumDef) => enumDef.name === type.name);
|
|
357
|
+
if (enumType) {
|
|
358
|
+
return {
|
|
359
|
+
type: 'string',
|
|
360
|
+
enum: enumType.values,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
if (type.array && type.name === 'TEXT') {
|
|
364
|
+
return {
|
|
365
|
+
type: 'array',
|
|
366
|
+
items: { type: 'string' },
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
switch (type.name) {
|
|
370
|
+
case 'UUID':
|
|
371
|
+
return { type: 'string', format: 'uuid' };
|
|
372
|
+
case 'VARCHAR':
|
|
373
|
+
case 'TEXT':
|
|
374
|
+
return { type: 'string' };
|
|
375
|
+
case 'INTEGER':
|
|
376
|
+
case 'SERIAL':
|
|
377
|
+
case 'SMALLINT':
|
|
378
|
+
return { type: 'integer' };
|
|
379
|
+
case 'BOOLEAN':
|
|
380
|
+
return { type: 'boolean' };
|
|
381
|
+
case 'TIMESTAMP':
|
|
382
|
+
return { type: 'string', format: 'date-time' };
|
|
383
|
+
case 'DECIMAL':
|
|
384
|
+
// Matches Zod create/update mapping (z.string()).
|
|
385
|
+
return { type: 'string' };
|
|
386
|
+
case 'JSONB':
|
|
387
|
+
return { type: 'object', additionalProperties: true };
|
|
388
|
+
case 'POINT':
|
|
389
|
+
return {};
|
|
390
|
+
default:
|
|
391
|
+
return { type: 'string' };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
buildAuthComponentSchemas() {
|
|
395
|
+
return {
|
|
396
|
+
AuthRegisterRequest: {
|
|
397
|
+
type: 'object',
|
|
398
|
+
required: ['email', 'password'],
|
|
399
|
+
properties: {
|
|
400
|
+
email: { type: 'string', format: 'email' },
|
|
401
|
+
password: { type: 'string', minLength: 1 },
|
|
402
|
+
name: { type: 'string', minLength: 1 },
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
AuthLoginRequest: {
|
|
406
|
+
type: 'object',
|
|
407
|
+
required: ['email', 'password'],
|
|
408
|
+
properties: {
|
|
409
|
+
email: { type: 'string', format: 'email' },
|
|
410
|
+
password: { type: 'string', minLength: 1 },
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
AuthTokenResponse: {
|
|
414
|
+
type: 'object',
|
|
415
|
+
required: ['token', 'user'],
|
|
416
|
+
properties: {
|
|
417
|
+
token: { type: 'string' },
|
|
418
|
+
user: { type: 'object', additionalProperties: true },
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
AuthMeResponse: {
|
|
422
|
+
type: 'object',
|
|
423
|
+
properties: {
|
|
424
|
+
role: { type: 'string' },
|
|
425
|
+
user: {
|
|
426
|
+
anyOf: [
|
|
427
|
+
{
|
|
428
|
+
type: 'object',
|
|
429
|
+
properties: {
|
|
430
|
+
id: { type: 'string' },
|
|
431
|
+
},
|
|
432
|
+
additionalProperties: true,
|
|
433
|
+
},
|
|
434
|
+
{ type: 'null' },
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
buildAuthPaths() {
|
|
442
|
+
return {
|
|
443
|
+
'/auth/register': {
|
|
444
|
+
post: {
|
|
445
|
+
tags: ['Auth'],
|
|
446
|
+
summary: 'Register',
|
|
447
|
+
operationId: 'authRegister',
|
|
448
|
+
requestBody: {
|
|
449
|
+
required: true,
|
|
450
|
+
content: jsonContent({ $ref: '#/components/schemas/AuthRegisterRequest' }),
|
|
451
|
+
},
|
|
452
|
+
responses: {
|
|
453
|
+
'201': {
|
|
454
|
+
description: 'Registered user with access token',
|
|
455
|
+
content: jsonContent({ $ref: '#/components/schemas/AuthTokenResponse' }),
|
|
456
|
+
},
|
|
457
|
+
'400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
|
|
458
|
+
'409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
|
|
459
|
+
'500': errorResponse('Internal server error'),
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
'/auth/login': {
|
|
464
|
+
post: {
|
|
465
|
+
tags: ['Auth'],
|
|
466
|
+
summary: 'Login',
|
|
467
|
+
operationId: 'authLogin',
|
|
468
|
+
requestBody: {
|
|
469
|
+
required: true,
|
|
470
|
+
content: jsonContent({ $ref: '#/components/schemas/AuthLoginRequest' }),
|
|
471
|
+
},
|
|
472
|
+
responses: {
|
|
473
|
+
'200': {
|
|
474
|
+
description: 'Access token and user',
|
|
475
|
+
content: jsonContent({ $ref: '#/components/schemas/AuthTokenResponse' }),
|
|
476
|
+
},
|
|
477
|
+
'400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
|
|
478
|
+
'401': errorResponse('Invalid email or password'),
|
|
479
|
+
'500': errorResponse('Internal server error'),
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
'/auth/me': {
|
|
484
|
+
get: {
|
|
485
|
+
tags: ['Auth'],
|
|
486
|
+
summary: 'Current auth context',
|
|
487
|
+
operationId: 'authMe',
|
|
488
|
+
security: OPTIONAL_BEARER_SECURITY,
|
|
489
|
+
responses: {
|
|
490
|
+
'200': {
|
|
491
|
+
description: 'Current auth context (PUBLIC when unauthenticated)',
|
|
492
|
+
content: jsonContent({ $ref: '#/components/schemas/AuthMeResponse' }),
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
export function generateOpenApiDocument(schema, options) {
|
|
501
|
+
return new OpenApiGenerator(schema, options).generate();
|
|
502
|
+
}
|
|
503
|
+
export function generateOpenApiFiles(schema, options) {
|
|
504
|
+
const generator = new OpenApiGenerator(schema, options);
|
|
505
|
+
return {
|
|
506
|
+
openapiTs: generator.generateTsModule(),
|
|
507
|
+
openapiJson: generator.generateJson(),
|
|
508
|
+
};
|
|
509
|
+
}
|
package/dist/cli/generate.js
CHANGED
|
@@ -36,6 +36,8 @@ export async function generateApi(schemaPath) {
|
|
|
36
36
|
await writeFile(path.join(outputDir, 'policies.ts'), files.policies, 'utf8');
|
|
37
37
|
await writeFile(path.join(outputDir, 'hooks.ts'), files.hooks, 'utf8');
|
|
38
38
|
await writeFile(path.join(schemasDir, 'validation.ts'), files.validation, 'utf8');
|
|
39
|
+
await writeFile(path.join(outputDir, 'openapi.ts'), files.openapiTs, 'utf8');
|
|
40
|
+
await writeFile(path.join(outputDir, 'openapi.json'), files.openapiJson, 'utf8');
|
|
39
41
|
for (const [fileName, content] of files.routes) {
|
|
40
42
|
await writeFile(path.join(routesDir, fileName), content, 'utf8');
|
|
41
43
|
}
|
|
@@ -264,7 +264,7 @@ Flow: `validate → assertPolicy → beforeHooks → db op → afterHooks → re
|
|
|
264
264
|
schematic-pg generate # schema.sql + db client + API
|
|
265
265
|
schematic-pg dev [--no-watch] # generate + bootstrap + server + watch
|
|
266
266
|
schematic-pg start [--no-migrate] # production: migrate + run server
|
|
267
|
-
schematic-pg db:bootstrap #
|
|
267
|
+
schematic-pg db:bootstrap # reset public schema + apply DDL
|
|
268
268
|
schematic-pg db:diff [--name label] # print or write migration
|
|
269
269
|
schematic-pg db:migrate # apply pending migrations
|
|
270
270
|
schematic-pg hooks:add [--model X] # scaffold src/hooks/{Model}.ts
|
package/dist/cli/templates.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export declare const AGENTS_TEMPLATE: string;
|
|
|
2
2
|
export declare const APP_SCHEMA_TEMPLATE = "extensions {\n\n}\n\nenums {\n UserRole { ADMIN, USER }\n}\n\nmodels {\n model User {\n id: UUID @id @default(gen_random_uuid())\n email: VARCHAR(255) @unique\n name: VARCHAR(150)?\n role: UserRole @default(USER)\n passwordHash: VARCHAR(255)? @omit @unfilterable\n createdAt: TIMESTAMP @default(now())\n\n @policy(role: USER, allow: [select, update], where: \"id = {{auth.user.id}}\")\n @policy(role: ADMIN, allow: all)\n }\n}\n";
|
|
3
3
|
export declare const ENV_TEMPLATE = "DATABASE_URL=postgresql://postgrest:postgrest@localhost:5432/postgrest\nJWT_SECRET=\nAUTH_PEPPER=\nAUTH_ACCESS_TOKEN_TTL=1h\nJWT_ROLE_CLAIM=role\nJWT_USER_ID_CLAIM=sub\n";
|
|
4
4
|
export declare const GITIGNORE_TEMPLATE = "node_modules/\ndist/\n.env\ndocker_data/\n.DS_Store\n*.log\nnpm-debug.log*\n";
|
|
5
|
-
export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgres:18.4-bookworm\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
|
|
5
|
+
export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgres:18.4-bookworm\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\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\n";
|
|
6
6
|
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";
|
|
7
7
|
export declare const MAKEFILE_TEMPLATE = ".PHONY: dev\n\ndev:\n\tdocker compose up -d --wait\n\tnpx schematic-pg dev\n";
|
|
8
8
|
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";
|
package/dist/cli/templates.js
CHANGED
|
@@ -53,7 +53,7 @@ export const DOCKER_COMPOSE_TEMPLATE = `services:
|
|
|
53
53
|
POSTGRES_PASSWORD: postgrest
|
|
54
54
|
POSTGRES_DB: postgrest
|
|
55
55
|
volumes:
|
|
56
|
-
- ./docker_data/postgres:/var/lib/postgresql
|
|
56
|
+
- ./docker_data/postgres:/var/lib/postgresql
|
|
57
57
|
healthcheck:
|
|
58
58
|
test: ["CMD-SHELL", "pg_isready -U postgrest -d postgrest"]
|
|
59
59
|
interval: 5s
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ Commands:
|
|
|
18
18
|
dev [schema] [--no-watch] Generate, bootstrap DB, start server, watch schema
|
|
19
19
|
start [schema] [--no-migrate] Run production server (migrate DB, no generate/watch)
|
|
20
20
|
db:ping Test database connection
|
|
21
|
-
db:bootstrap [schema]
|
|
21
|
+
db:bootstrap [schema] Reset public schema, apply DDL, snapshot state
|
|
22
22
|
db:diff [schema] Show schema diff (--name <name> to write migration)
|
|
23
23
|
db:migrate [schema] Apply pending migrations
|
|
24
24
|
db:migrate:status [schema] Show migration status
|
package/dist/db/bootstrap.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { SqlGenerator } from '../sql-generator/sql-generator.js';
|
|
4
4
|
import { DatabaseClient } from './client.js';
|
|
5
|
+
import { resetPublicSchema } from './reset-database.js';
|
|
5
6
|
import { writeSnapshot } from './schema-state.js';
|
|
6
7
|
export function generateBootstrapSql(schemaPath) {
|
|
7
8
|
const source = readFileSync(schemaPath, 'utf8');
|
|
@@ -10,6 +11,8 @@ export function generateBootstrapSql(schemaPath) {
|
|
|
10
11
|
export async function bootstrapDatabase(schemaPath = join(process.cwd(), 'app.schema'), client = new DatabaseClient()) {
|
|
11
12
|
const sql = generateBootstrapSql(schemaPath);
|
|
12
13
|
await client.withClient(async (pgClient) => {
|
|
14
|
+
// Bootstrap is greenfield: wipe existing objects so re-runs (e.g. `dev` watch) are idempotent.
|
|
15
|
+
await resetPublicSchema(pgClient);
|
|
13
16
|
await pgClient.query(sql);
|
|
14
17
|
});
|
|
15
18
|
writeSnapshot(schemaPath);
|