schematic-pg 0.1.15 → 0.1.16
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/dist/api-generator/app-generator.d.ts +5 -0
- package/dist/api-generator/app-generator.js +14 -6
- package/dist/api-generator/custom-route-scanner.d.ts +9 -0
- package/dist/api-generator/custom-route-scanner.js +17 -1
- package/dist/api-generator/index.js +10 -4
- package/dist/api-generator/openapi-generator.js +74 -52
- package/dist/api-generator/route-generator.d.ts +11 -4
- package/dist/api-generator/route-generator.js +151 -51
- package/dist/api-generator/utils/rest.d.ts +9 -0
- package/dist/api-generator/utils/rest.js +93 -0
- package/dist/cli/generate.d.ts +1 -0
- package/dist/cli/generate.js +10 -3
- package/dist/cli/templates/agents.md +6 -2
- package/dist/cli/templates.d.ts +1 -1
- package/dist/cli/templates.js +2 -1
- package/package.json +1 -1
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { Schema } from '../schema-dsl/ast.js';
|
|
2
|
+
import { type CustomRouteMountEntry } from './custom-route-scanner.js';
|
|
2
3
|
export interface AppGeneratorOptions {
|
|
3
4
|
customRoutesDir?: string;
|
|
5
|
+
/** When provided, skip re-discovering and use these standalone mounts only. */
|
|
6
|
+
standaloneCustomRoutes?: CustomRouteMountEntry[];
|
|
7
|
+
overlays?: ReadonlyMap<string, CustomRouteMountEntry>;
|
|
4
8
|
}
|
|
5
9
|
export declare class AppGenerator {
|
|
6
10
|
private readonly schema;
|
|
7
11
|
private readonly options;
|
|
8
12
|
constructor(schema: Schema, options?: AppGeneratorOptions);
|
|
9
13
|
generate(): string;
|
|
14
|
+
private resolveStandaloneCustomRoutes;
|
|
10
15
|
}
|
|
11
16
|
export declare function generateAppFile(schema: Schema, options?: AppGeneratorOptions): string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { PACKAGE_NAME } from '../constants.js';
|
|
3
|
-
import { discoverCustomRoutes } from './custom-route-scanner.js';
|
|
3
|
+
import { discoverCustomRoutes, partitionCustomRoutes, } from './custom-route-scanner.js';
|
|
4
4
|
import { getRouteMountEntries } from './route-generator.js';
|
|
5
5
|
const DEFAULT_CUSTOM_ROUTES_DIR = path.resolve('src/routes');
|
|
6
6
|
export class AppGenerator {
|
|
@@ -11,19 +11,19 @@ export class AppGenerator {
|
|
|
11
11
|
this.options = options;
|
|
12
12
|
}
|
|
13
13
|
generate() {
|
|
14
|
-
const
|
|
14
|
+
const overlays = this.options.overlays ?? new Map();
|
|
15
|
+
const mounts = getRouteMountEntries(this.schema, overlays);
|
|
15
16
|
const generatedImports = mounts
|
|
16
17
|
.map((entry) => `import ${entry.importName} from './routes/${entry.fileName.replace(/\.ts$/, '.js')}';`)
|
|
17
18
|
.join('\n');
|
|
18
19
|
const generatedRoutes = mounts
|
|
19
20
|
.map((entry) => ` app.route('/${entry.basePath}', ${entry.importName});`)
|
|
20
21
|
.join('\n');
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const customImports = customMounts
|
|
22
|
+
const standaloneMounts = this.resolveStandaloneCustomRoutes();
|
|
23
|
+
const customImports = standaloneMounts
|
|
24
24
|
.map((entry) => `import ${entry.importName} from '${entry.importPath}';`)
|
|
25
25
|
.join('\n');
|
|
26
|
-
const customRoutes =
|
|
26
|
+
const customRoutes = standaloneMounts
|
|
27
27
|
.map((entry) => ` app.route('/${entry.basePath}', ${entry.importName});`)
|
|
28
28
|
.join('\n');
|
|
29
29
|
const routeImports = [generatedImports, customImports].filter(Boolean).join('\n');
|
|
@@ -85,6 +85,14 @@ export class AppGenerator {
|
|
|
85
85
|
'',
|
|
86
86
|
].join('\n');
|
|
87
87
|
}
|
|
88
|
+
resolveStandaloneCustomRoutes() {
|
|
89
|
+
if (this.options.standaloneCustomRoutes) {
|
|
90
|
+
return this.options.standaloneCustomRoutes;
|
|
91
|
+
}
|
|
92
|
+
const customRoutesDir = this.options.customRoutesDir ?? DEFAULT_CUSTOM_ROUTES_DIR;
|
|
93
|
+
const { standalone } = partitionCustomRoutes(discoverCustomRoutes(customRoutesDir), this.schema);
|
|
94
|
+
return standalone;
|
|
95
|
+
}
|
|
88
96
|
}
|
|
89
97
|
export function generateAppFile(schema, options) {
|
|
90
98
|
return new AppGenerator(schema, options).generate();
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import type { Schema } from '../schema-dsl/ast.js';
|
|
1
2
|
export interface CustomRouteMountEntry {
|
|
2
3
|
basePath: string;
|
|
3
4
|
importName: string;
|
|
5
|
+
/** Import path relative to generated/app.ts */
|
|
4
6
|
importPath: string;
|
|
7
|
+
/** Import path relative to generated/routes/*.ts */
|
|
8
|
+
routeImportPath: string;
|
|
9
|
+
}
|
|
10
|
+
export interface PartitionedCustomRoutes {
|
|
11
|
+
overlays: Map<string, CustomRouteMountEntry>;
|
|
12
|
+
standalone: CustomRouteMountEntry[];
|
|
5
13
|
}
|
|
6
14
|
export declare function discoverCustomRoutes(customRoutesDir: string): CustomRouteMountEntry[];
|
|
15
|
+
export declare function partitionCustomRoutes(entries: CustomRouteMountEntry[], schema: Schema): PartitionedCustomRoutes;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { toRouteImportName } from '../api/utils/route-naming.js';
|
|
3
|
+
import { toRouteBasePath, toRouteImportName } from '../api/utils/route-naming.js';
|
|
4
4
|
function isRouteFile(filename) {
|
|
5
5
|
return (filename.endsWith('.ts') &&
|
|
6
6
|
!filename.endsWith('.test.ts') &&
|
|
@@ -29,6 +29,7 @@ function scanDirectory(customRoutesDir, relativeDir, entries) {
|
|
|
29
29
|
basePath,
|
|
30
30
|
importName: toRouteImportName(basePath),
|
|
31
31
|
importPath: `../src/routes/${basePath}.js`,
|
|
32
|
+
routeImportPath: `../../src/routes/${basePath}.js`,
|
|
32
33
|
});
|
|
33
34
|
}
|
|
34
35
|
}
|
|
@@ -40,3 +41,18 @@ export function discoverCustomRoutes(customRoutesDir) {
|
|
|
40
41
|
scanDirectory(customRoutesDir, '', entries);
|
|
41
42
|
return entries.sort((left, right) => left.basePath.localeCompare(right.basePath));
|
|
42
43
|
}
|
|
44
|
+
export function partitionCustomRoutes(entries, schema) {
|
|
45
|
+
const modelBasePaths = new Map(schema.models.map((model) => [toRouteBasePath(model.name), model.name]));
|
|
46
|
+
const overlays = new Map();
|
|
47
|
+
const standalone = [];
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const modelName = modelBasePaths.get(entry.basePath);
|
|
50
|
+
if (modelName) {
|
|
51
|
+
overlays.set(modelName, entry);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
standalone.push(entry);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { overlays, standalone };
|
|
58
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
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
|
+
import { discoverCustomRoutes, partitionCustomRoutes } from './custom-route-scanner.js';
|
|
4
4
|
import { discoverHooks } from './hook-scanner.js';
|
|
5
5
|
import { generateHooksFile } from './hooks-generator.js';
|
|
6
6
|
import { generateOpenApiFiles } from './openapi-generator.js';
|
|
@@ -9,17 +9,23 @@ import { generateRouteFiles } from './route-generator.js';
|
|
|
9
9
|
import { generateValidationSchemas } from './zod-schema-generator.js';
|
|
10
10
|
export function generateApiFiles(schema, options) {
|
|
11
11
|
const customRoutesDir = options?.customRoutesDir ?? DEFAULT_CUSTOM_ROUTES_DIR;
|
|
12
|
-
const appOptions = { customRoutesDir };
|
|
13
12
|
const hooksDir = options?.hooksDir ?? DEFAULT_HOOKS_DIR;
|
|
14
13
|
const { entries: hookEntries, modelsWithHooks } = discoverHooks(hooksDir, schema);
|
|
15
|
-
const
|
|
14
|
+
const customEntries = discoverCustomRoutes(customRoutesDir);
|
|
15
|
+
const { overlays, standalone } = partitionCustomRoutes(customEntries, schema);
|
|
16
|
+
const includeAuthPaths = customEntries.some((entry) => entry.basePath === 'auth');
|
|
16
17
|
const openapi = generateOpenApiFiles(schema, { includeAuthPaths });
|
|
18
|
+
const appOptions = {
|
|
19
|
+
customRoutesDir,
|
|
20
|
+
standaloneCustomRoutes: standalone,
|
|
21
|
+
overlays,
|
|
22
|
+
};
|
|
17
23
|
return {
|
|
18
24
|
app: generateAppFile(schema, appOptions),
|
|
19
25
|
policies: generatePoliciesFile(schema),
|
|
20
26
|
validation: generateValidationSchemas(schema),
|
|
21
27
|
hooks: generateHooksFile(hookEntries),
|
|
22
|
-
routes: generateRouteFiles(schema, modelsWithHooks),
|
|
28
|
+
routes: generateRouteFiles(schema, { modelsWithHooks, overlays }),
|
|
23
29
|
openapiTs: openapi.openapiTs,
|
|
24
30
|
openapiJson: openapi.openapiJson,
|
|
25
31
|
};
|
|
@@ -2,6 +2,7 @@ import { toRouteBasePath } from '../api/utils/route-naming.js';
|
|
|
2
2
|
import { fieldHasAttribute, getModelNames, getPrimaryKey, getStoredFields, } from '../sql-generator/utils/ast-helpers.js';
|
|
3
3
|
import { getFilterableFields, getIncludableRelationFields, getOmittedFields, getSortableFieldNames, isStoredScalarField, } from './utils/api-fields.js';
|
|
4
4
|
import { buildFilterFieldMeta, queryParamKey } from './utils/filter-operators.js';
|
|
5
|
+
import { hasRestOperation, isRestEnabled } from './utils/rest.js';
|
|
5
6
|
const ERROR_REF = { $ref: '#/components/schemas/Error' };
|
|
6
7
|
const ERROR_EXAMPLE_VALIDATION = 'Validation failed';
|
|
7
8
|
const ERROR_EXAMPLE_FORBIDDEN = 'Role "USER" is not allowed to list this resource';
|
|
@@ -47,6 +48,9 @@ export class OpenApiGenerator {
|
|
|
47
48
|
};
|
|
48
49
|
const paths = {};
|
|
49
50
|
for (const model of this.schema.models) {
|
|
51
|
+
if (!isRestEnabled(model)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
50
54
|
Object.assign(schemas, this.buildModelComponentSchemas(model));
|
|
51
55
|
Object.assign(paths, this.buildModelPaths(model));
|
|
52
56
|
}
|
|
@@ -127,22 +131,27 @@ export class OpenApiGenerator {
|
|
|
127
131
|
nullable: Boolean(field.type.optional),
|
|
128
132
|
});
|
|
129
133
|
}
|
|
130
|
-
|
|
134
|
+
const schemas = {
|
|
131
135
|
[`${model.name}Response`]: {
|
|
132
136
|
type: 'object',
|
|
133
137
|
properties: responseProps,
|
|
134
138
|
...(responseRequired.length > 0 ? { required: responseRequired } : {}),
|
|
135
139
|
},
|
|
136
|
-
|
|
140
|
+
};
|
|
141
|
+
if (hasRestOperation(model, 'create')) {
|
|
142
|
+
schemas[`${model.name}Create`] = {
|
|
137
143
|
type: 'object',
|
|
138
144
|
properties: createProps,
|
|
139
145
|
...(createRequired.length > 0 ? { required: createRequired } : {}),
|
|
140
|
-
}
|
|
141
|
-
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (hasRestOperation(model, 'update')) {
|
|
149
|
+
schemas[`${model.name}Update`] = {
|
|
142
150
|
type: 'object',
|
|
143
151
|
properties: updateProps,
|
|
144
|
-
}
|
|
145
|
-
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return schemas;
|
|
146
155
|
}
|
|
147
156
|
buildModelPaths(model) {
|
|
148
157
|
const basePath = toRouteBasePath(model.name);
|
|
@@ -157,52 +166,58 @@ export class OpenApiGenerator {
|
|
|
157
166
|
const createRef = { $ref: `#/components/schemas/${model.name}Create` };
|
|
158
167
|
const updateRef = { $ref: `#/components/schemas/${model.name}Update` };
|
|
159
168
|
const tag = model.name;
|
|
160
|
-
const paths = {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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'),
|
|
169
|
+
const paths = {};
|
|
170
|
+
const collectionOps = {};
|
|
171
|
+
if (hasRestOperation(model, 'list')) {
|
|
172
|
+
collectionOps.get = {
|
|
173
|
+
tags: [tag],
|
|
174
|
+
summary: `List ${model.name}`,
|
|
175
|
+
operationId: `list${model.name}`,
|
|
176
|
+
security: OPTIONAL_BEARER_SECURITY,
|
|
177
|
+
parameters: this.buildListQueryParameters(model),
|
|
178
|
+
responses: {
|
|
179
|
+
'200': {
|
|
180
|
+
description: `List of ${model.name}`,
|
|
181
|
+
content: jsonContent({ type: 'array', items: responseRef }),
|
|
177
182
|
},
|
|
183
|
+
'400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
|
|
184
|
+
'401': errorResponse('Unauthorized'),
|
|
185
|
+
'403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
|
|
186
|
+
'500': errorResponse('Internal server error'),
|
|
178
187
|
},
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
'403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
|
|
196
|
-
'409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
|
|
197
|
-
'500': errorResponse('Internal server error'),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (hasRestOperation(model, 'create')) {
|
|
191
|
+
collectionOps.post = {
|
|
192
|
+
tags: [tag],
|
|
193
|
+
summary: `Create ${model.name}`,
|
|
194
|
+
operationId: `create${model.name}`,
|
|
195
|
+
security: OPTIONAL_BEARER_SECURITY,
|
|
196
|
+
requestBody: {
|
|
197
|
+
required: true,
|
|
198
|
+
content: jsonContent(createRef),
|
|
199
|
+
},
|
|
200
|
+
responses: {
|
|
201
|
+
'201': {
|
|
202
|
+
description: `Created ${model.name}`,
|
|
203
|
+
content: jsonContent(responseRef),
|
|
198
204
|
},
|
|
205
|
+
'400': errorResponse('Validation error', ERROR_EXAMPLE_VALIDATION),
|
|
206
|
+
'401': errorResponse('Unauthorized'),
|
|
207
|
+
'403': errorResponse('Forbidden', ERROR_EXAMPLE_FORBIDDEN),
|
|
208
|
+
'409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
|
|
209
|
+
'500': errorResponse('Internal server error'),
|
|
199
210
|
},
|
|
200
|
-
}
|
|
201
|
-
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (Object.keys(collectionOps).length > 0) {
|
|
214
|
+
paths[collectionPath] = collectionOps;
|
|
215
|
+
}
|
|
202
216
|
if (pkFields.length > 0) {
|
|
203
217
|
const pathParams = this.buildPathParameters(pkFields, model);
|
|
204
|
-
|
|
205
|
-
|
|
218
|
+
const itemOps = {};
|
|
219
|
+
if (hasRestOperation(model, 'get')) {
|
|
220
|
+
itemOps.get = {
|
|
206
221
|
tags: [tag],
|
|
207
222
|
summary: `Get ${model.name}`,
|
|
208
223
|
operationId: `get${model.name}`,
|
|
@@ -219,8 +234,10 @@ export class OpenApiGenerator {
|
|
|
219
234
|
'404': errorResponse('Not found'),
|
|
220
235
|
'500': errorResponse('Internal server error'),
|
|
221
236
|
},
|
|
222
|
-
}
|
|
223
|
-
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (hasRestOperation(model, 'update')) {
|
|
240
|
+
itemOps.put = {
|
|
224
241
|
tags: [tag],
|
|
225
242
|
summary: `Update ${model.name}`,
|
|
226
243
|
operationId: `update${model.name}`,
|
|
@@ -242,8 +259,10 @@ export class OpenApiGenerator {
|
|
|
242
259
|
'409': errorResponse('Conflict', ERROR_EXAMPLE_CONFLICT),
|
|
243
260
|
'500': errorResponse('Internal server error'),
|
|
244
261
|
},
|
|
245
|
-
}
|
|
246
|
-
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (hasRestOperation(model, 'delete')) {
|
|
265
|
+
itemOps.delete = {
|
|
247
266
|
tags: [tag],
|
|
248
267
|
summary: `Delete ${model.name}`,
|
|
249
268
|
operationId: `delete${model.name}`,
|
|
@@ -260,8 +279,11 @@ export class OpenApiGenerator {
|
|
|
260
279
|
'404': errorResponse('Not found'),
|
|
261
280
|
'500': errorResponse('Internal server error'),
|
|
262
281
|
},
|
|
263
|
-
}
|
|
264
|
-
}
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (Object.keys(itemOps).length > 0) {
|
|
285
|
+
paths[itemPath] = itemOps;
|
|
286
|
+
}
|
|
265
287
|
}
|
|
266
288
|
return paths;
|
|
267
289
|
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import type { Model, Schema } from '../schema-dsl/ast.js';
|
|
2
|
+
import type { CustomRouteMountEntry } from './custom-route-scanner.js';
|
|
3
|
+
export interface RouteGeneratorOptions {
|
|
4
|
+
modelsWithHooks?: ReadonlySet<string>;
|
|
5
|
+
overlays?: ReadonlyMap<string, CustomRouteMountEntry>;
|
|
6
|
+
}
|
|
2
7
|
export declare class RouteGenerator {
|
|
3
8
|
private readonly model;
|
|
4
9
|
private readonly schema;
|
|
5
10
|
private readonly modelsWithHooks;
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
private readonly overlays;
|
|
12
|
+
constructor(model: Model, schema: Schema, options?: RouteGeneratorOptions);
|
|
13
|
+
generate(): string | null;
|
|
14
|
+
private generateOverlayOnly;
|
|
8
15
|
private jsonRow;
|
|
9
16
|
private jsonRows;
|
|
10
17
|
private mutationJsonRow;
|
|
@@ -16,8 +23,8 @@ export declare class RouteGenerator {
|
|
|
16
23
|
getRouteFileName(): string;
|
|
17
24
|
getRouteBasePath(): string;
|
|
18
25
|
}
|
|
19
|
-
export declare function generateRouteFiles(schema: Schema,
|
|
20
|
-
export declare function getRouteMountEntries(schema: Schema): {
|
|
26
|
+
export declare function generateRouteFiles(schema: Schema, modelsWithHooksOrOptions?: ReadonlySet<string> | RouteGeneratorOptions): Map<string, string>;
|
|
27
|
+
export declare function getRouteMountEntries(schema: Schema, overlays?: ReadonlyMap<string, CustomRouteMountEntry>): {
|
|
21
28
|
basePath: string;
|
|
22
29
|
fileName: string;
|
|
23
30
|
importName: string;
|
|
@@ -4,74 +4,154 @@ import { getClientExportName } from '../db/type-generator.js';
|
|
|
4
4
|
import { toRouteBasePath, toRouteFileName, toRouteImportName } from '../api/utils/route-naming.js';
|
|
5
5
|
import { toModelConstantPrefix } from './utils/api-fields.js';
|
|
6
6
|
import { hasPolicies } from './utils/policy.js';
|
|
7
|
+
import { isRestEnabled, normalizeRest } from './utils/rest.js';
|
|
7
8
|
export class RouteGenerator {
|
|
8
9
|
model;
|
|
9
10
|
schema;
|
|
10
11
|
modelsWithHooks;
|
|
11
|
-
|
|
12
|
+
overlays;
|
|
13
|
+
constructor(model, schema, options = {}) {
|
|
12
14
|
this.model = model;
|
|
13
15
|
this.schema = schema;
|
|
14
|
-
this.modelsWithHooks = modelsWithHooks;
|
|
16
|
+
this.modelsWithHooks = options.modelsWithHooks ?? new Set();
|
|
17
|
+
this.overlays = options.overlays ?? new Map();
|
|
15
18
|
}
|
|
16
19
|
generate() {
|
|
17
|
-
const
|
|
20
|
+
const rest = normalizeRest(this.model);
|
|
21
|
+
const overlay = this.overlays.get(this.model.name);
|
|
22
|
+
const operations = rest.operations;
|
|
23
|
+
if (operations.size === 0 && !overlay) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (operations.size === 0 && overlay) {
|
|
27
|
+
return this.generateOverlayOnly(overlay);
|
|
28
|
+
}
|
|
29
|
+
const needsPk = operations.has('get') || operations.has('update') || operations.has('delete');
|
|
18
30
|
const primaryKey = getPrimaryKey(this.model);
|
|
19
|
-
if (!primaryKey) {
|
|
31
|
+
if (needsPk && !primaryKey) {
|
|
20
32
|
throw new Error(`Model ${this.model.name} has no primary key`);
|
|
21
33
|
}
|
|
22
|
-
const
|
|
23
|
-
const
|
|
34
|
+
const clientKey = getClientExportName(this.model.name);
|
|
35
|
+
const pathParams = primaryKey?.fields.map((field) => `:${field}`).join('/') ?? '';
|
|
36
|
+
const whereFromParams = primaryKey?.fields.map((field) => `${field}: params.${field}`).join(', ') ?? '';
|
|
24
37
|
const paramSchemaName = `${this.model.name}ParamSchema`;
|
|
25
38
|
const listQuerySchemaName = `${this.model.name}ListQuerySchema`;
|
|
26
39
|
const getQuerySchemaName = `${this.model.name}GetQuerySchema`;
|
|
27
40
|
const modelHasPolicies = hasPolicies(this.model);
|
|
28
|
-
const
|
|
41
|
+
const hasWriteOps = operations.has('create') || operations.has('update') || operations.has('delete');
|
|
42
|
+
const modelHasHooks = hasWriteOps && this.modelsWithHooks.has(this.model.name);
|
|
29
43
|
const constantPrefix = toModelConstantPrefix(this.model.name);
|
|
44
|
+
const needsValidateJson = operations.has('create') || operations.has('update');
|
|
45
|
+
const needsValidateParam = operations.has('get') || operations.has('update') || operations.has('delete');
|
|
46
|
+
const needsValidateQuery = operations.has('list') || operations.has('get');
|
|
47
|
+
const needsNotFound = operations.has('get');
|
|
48
|
+
const needsBuildReadQuery = operations.has('list');
|
|
49
|
+
const needsParseInclude = operations.has('get');
|
|
50
|
+
const needsOmitFields = hasWriteOps;
|
|
51
|
+
const needsShapeResponse = operations.has('list') || operations.has('get');
|
|
52
|
+
const validateImports = [];
|
|
53
|
+
if (needsValidateJson)
|
|
54
|
+
validateImports.push('validateJson');
|
|
55
|
+
if (needsValidateParam)
|
|
56
|
+
validateImports.push('validateParam');
|
|
57
|
+
if (needsValidateQuery)
|
|
58
|
+
validateImports.push('validateQuery');
|
|
59
|
+
const schemaImports = [];
|
|
60
|
+
if (operations.has('create'))
|
|
61
|
+
schemaImports.push(` ${this.model.name}CreateSchema,`);
|
|
62
|
+
if (operations.has('update'))
|
|
63
|
+
schemaImports.push(` ${this.model.name}UpdateSchema,`);
|
|
64
|
+
if (needsValidateParam)
|
|
65
|
+
schemaImports.push(` ${paramSchemaName},`);
|
|
66
|
+
if (operations.has('list'))
|
|
67
|
+
schemaImports.push(` ${listQuerySchemaName},`);
|
|
68
|
+
if (operations.has('get'))
|
|
69
|
+
schemaImports.push(` ${getQuerySchemaName},`);
|
|
70
|
+
if (operations.has('list')) {
|
|
71
|
+
schemaImports.push(` ${constantPrefix}_LIST_QUERY_FIELDS,`);
|
|
72
|
+
schemaImports.push(` ${constantPrefix}_SORTABLE_FIELDS,`);
|
|
73
|
+
}
|
|
74
|
+
if (operations.has('list') || operations.has('get')) {
|
|
75
|
+
schemaImports.push(` ${constantPrefix}_INCLUDABLE_RELATIONS,`);
|
|
76
|
+
}
|
|
77
|
+
if (needsOmitFields)
|
|
78
|
+
schemaImports.push(` ${constantPrefix}_OMIT_FIELDS,`);
|
|
79
|
+
if (needsShapeResponse) {
|
|
80
|
+
schemaImports.push(` API_OMIT_FIELDS_BY_MODEL,`);
|
|
81
|
+
schemaImports.push(` API_RELATION_TARGETS,`);
|
|
82
|
+
}
|
|
83
|
+
const handlerBlocks = [];
|
|
84
|
+
if (operations.has('list')) {
|
|
85
|
+
handlerBlocks.push(this.generateListRoute(clientKey, modelHasPolicies, listQuerySchemaName, constantPrefix));
|
|
86
|
+
}
|
|
87
|
+
if (operations.has('get')) {
|
|
88
|
+
handlerBlocks.push(this.generateGetRoute(clientKey, pathParams, paramSchemaName, getQuerySchemaName, whereFromParams, modelHasPolicies, constantPrefix));
|
|
89
|
+
}
|
|
90
|
+
if (operations.has('create')) {
|
|
91
|
+
handlerBlocks.push(this.generateCreateRoute(clientKey, modelHasPolicies, modelHasHooks, constantPrefix));
|
|
92
|
+
}
|
|
93
|
+
if (operations.has('update')) {
|
|
94
|
+
handlerBlocks.push(this.generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix));
|
|
95
|
+
}
|
|
96
|
+
if (operations.has('delete')) {
|
|
97
|
+
handlerBlocks.push(this.generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix));
|
|
98
|
+
}
|
|
99
|
+
const lines = [
|
|
100
|
+
'// Auto-generated by RouteGenerator. Do not edit manually.',
|
|
101
|
+
"import { Hono } from 'hono';",
|
|
102
|
+
`import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
|
|
103
|
+
];
|
|
104
|
+
if (validateImports.length > 0) {
|
|
105
|
+
lines.push(`import { ${validateImports.join(', ')} } from '${PACKAGE_NAME}/api/middleware/validate';`);
|
|
106
|
+
}
|
|
107
|
+
if (needsNotFound) {
|
|
108
|
+
lines.push(`import { notFoundResponse } from '${PACKAGE_NAME}/api/middleware/errors';`);
|
|
109
|
+
}
|
|
110
|
+
if (needsBuildReadQuery) {
|
|
111
|
+
lines.push(`import { buildReadQuery } from '${PACKAGE_NAME}/api/utils/read-query';`);
|
|
112
|
+
}
|
|
113
|
+
if (needsParseInclude) {
|
|
114
|
+
lines.push(`import { parseIncludeQuery } from '${PACKAGE_NAME}/api/utils/include-query';`);
|
|
115
|
+
}
|
|
116
|
+
if (needsOmitFields) {
|
|
117
|
+
lines.push(`import { omitFields } from '${PACKAGE_NAME}/api/utils/omit-fields';`);
|
|
118
|
+
}
|
|
119
|
+
if (needsShapeResponse) {
|
|
120
|
+
lines.push(`import { shapeResponse, shapeResponseMany } from '${PACKAGE_NAME}/api/utils/response-shape';`);
|
|
121
|
+
}
|
|
122
|
+
if (modelHasPolicies) {
|
|
123
|
+
lines.push(`import { assertPolicy, mergeWhere, resolvePolicyWhere } from '${PACKAGE_NAME}/api/auth/policy';`);
|
|
124
|
+
}
|
|
125
|
+
if (modelHasHooks) {
|
|
126
|
+
lines.push(`import { cancelledResponse, createHookContext, runAfterHooks, runBeforeHooks } from '${PACKAGE_NAME}/api/hooks';`);
|
|
127
|
+
}
|
|
128
|
+
if (schemaImports.length > 0) {
|
|
129
|
+
lines.push('import {');
|
|
130
|
+
lines.push(...schemaImports);
|
|
131
|
+
lines.push(`} from '../schemas/validation.js';`);
|
|
132
|
+
}
|
|
133
|
+
if (overlay) {
|
|
134
|
+
lines.push(`import ${overlay.importName}Overlay from '${overlay.routeImportPath}';`);
|
|
135
|
+
}
|
|
136
|
+
lines.push('', 'const router = new Hono<AppEnv>();', '');
|
|
137
|
+
for (const block of handlerBlocks) {
|
|
138
|
+
lines.push(...block, '');
|
|
139
|
+
}
|
|
140
|
+
if (overlay) {
|
|
141
|
+
lines.push(`router.route('/', ${overlay.importName}Overlay);`, '');
|
|
142
|
+
}
|
|
143
|
+
lines.push('export default router;', '');
|
|
144
|
+
return lines.join('\n');
|
|
145
|
+
}
|
|
146
|
+
generateOverlayOnly(overlay) {
|
|
30
147
|
return [
|
|
31
148
|
'// Auto-generated by RouteGenerator. Do not edit manually.',
|
|
32
149
|
"import { Hono } from 'hono';",
|
|
33
150
|
`import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
|
|
34
|
-
`import {
|
|
35
|
-
`import { notFoundResponse } from '${PACKAGE_NAME}/api/middleware/errors';`,
|
|
36
|
-
`import { buildReadQuery } from '${PACKAGE_NAME}/api/utils/read-query';`,
|
|
37
|
-
`import { parseIncludeQuery } from '${PACKAGE_NAME}/api/utils/include-query';`,
|
|
38
|
-
`import { omitFields } from '${PACKAGE_NAME}/api/utils/omit-fields';`,
|
|
39
|
-
`import { shapeResponse, shapeResponseMany } from '${PACKAGE_NAME}/api/utils/response-shape';`,
|
|
40
|
-
...(modelHasPolicies
|
|
41
|
-
? [
|
|
42
|
-
`import { assertPolicy, mergeWhere, resolvePolicyWhere } from '${PACKAGE_NAME}/api/auth/policy';`,
|
|
43
|
-
]
|
|
44
|
-
: []),
|
|
45
|
-
...(modelHasHooks
|
|
46
|
-
? [
|
|
47
|
-
`import { cancelledResponse, createHookContext, runAfterHooks, runBeforeHooks } from '${PACKAGE_NAME}/api/hooks';`,
|
|
48
|
-
]
|
|
49
|
-
: []),
|
|
50
|
-
`import {`,
|
|
51
|
-
` ${this.model.name}CreateSchema,`,
|
|
52
|
-
` ${this.model.name}UpdateSchema,`,
|
|
53
|
-
` ${paramSchemaName},`,
|
|
54
|
-
` ${listQuerySchemaName},`,
|
|
55
|
-
` ${getQuerySchemaName},`,
|
|
56
|
-
` ${constantPrefix}_LIST_QUERY_FIELDS,`,
|
|
57
|
-
` ${constantPrefix}_INCLUDABLE_RELATIONS,`,
|
|
58
|
-
` ${constantPrefix}_OMIT_FIELDS,`,
|
|
59
|
-
` ${constantPrefix}_SORTABLE_FIELDS,`,
|
|
60
|
-
` API_OMIT_FIELDS_BY_MODEL,`,
|
|
61
|
-
` API_RELATION_TARGETS,`,
|
|
62
|
-
`} from '../schemas/validation.js';`,
|
|
151
|
+
`import ${overlay.importName}Overlay from '${overlay.routeImportPath}';`,
|
|
63
152
|
'',
|
|
64
153
|
'const router = new Hono<AppEnv>();',
|
|
65
|
-
'',
|
|
66
|
-
...this.generateListRoute(clientKey, modelHasPolicies, listQuerySchemaName, constantPrefix),
|
|
67
|
-
'',
|
|
68
|
-
...this.generateGetRoute(clientKey, pathParams, paramSchemaName, getQuerySchemaName, whereFromParams, modelHasPolicies, constantPrefix),
|
|
69
|
-
'',
|
|
70
|
-
...this.generateCreateRoute(clientKey, modelHasPolicies, modelHasHooks, constantPrefix),
|
|
71
|
-
'',
|
|
72
|
-
...this.generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix),
|
|
73
|
-
'',
|
|
74
|
-
...this.generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix),
|
|
154
|
+
`router.route('/', ${overlay.importName}Overlay);`,
|
|
75
155
|
'',
|
|
76
156
|
'export default router;',
|
|
77
157
|
'',
|
|
@@ -264,16 +344,36 @@ export class RouteGenerator {
|
|
|
264
344
|
return toRouteBasePath(this.model.name);
|
|
265
345
|
}
|
|
266
346
|
}
|
|
267
|
-
export function generateRouteFiles(schema,
|
|
347
|
+
export function generateRouteFiles(schema, modelsWithHooksOrOptions = new Set()) {
|
|
348
|
+
const options = normalizeRouteGeneratorOptions(modelsWithHooksOrOptions);
|
|
268
349
|
const files = new Map();
|
|
269
350
|
for (const model of schema.models) {
|
|
270
|
-
const generator = new RouteGenerator(model, schema,
|
|
271
|
-
|
|
351
|
+
const generator = new RouteGenerator(model, schema, options);
|
|
352
|
+
const content = generator.generate();
|
|
353
|
+
if (content !== null) {
|
|
354
|
+
files.set(generator.getRouteFileName(), content);
|
|
355
|
+
}
|
|
272
356
|
}
|
|
273
357
|
return files;
|
|
274
358
|
}
|
|
275
|
-
|
|
276
|
-
|
|
359
|
+
function normalizeRouteGeneratorOptions(value) {
|
|
360
|
+
if (value instanceof Set) {
|
|
361
|
+
return { modelsWithHooks: value };
|
|
362
|
+
}
|
|
363
|
+
if (value &&
|
|
364
|
+
typeof value === 'object' &&
|
|
365
|
+
'has' in value &&
|
|
366
|
+
typeof value.has === 'function' &&
|
|
367
|
+
!('overlays' in value) &&
|
|
368
|
+
!('modelsWithHooks' in value)) {
|
|
369
|
+
return { modelsWithHooks: value };
|
|
370
|
+
}
|
|
371
|
+
return value;
|
|
372
|
+
}
|
|
373
|
+
export function getRouteMountEntries(schema, overlays = new Map()) {
|
|
374
|
+
return schema.models
|
|
375
|
+
.filter((model) => isRestEnabled(model) || overlays.has(model.name))
|
|
376
|
+
.map((model) => {
|
|
277
377
|
const basePath = toRouteBasePath(model.name);
|
|
278
378
|
const fileName = toRouteFileName(model.name);
|
|
279
379
|
const importName = toRouteImportName(basePath);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Model } from '../../schema-dsl/ast.js';
|
|
2
|
+
export type RestOperation = 'list' | 'get' | 'create' | 'update' | 'delete';
|
|
3
|
+
export declare const REST_OPERATIONS: RestOperation[];
|
|
4
|
+
export interface NormalizedRest {
|
|
5
|
+
operations: Set<RestOperation>;
|
|
6
|
+
}
|
|
7
|
+
export declare function normalizeRest(model: Model): NormalizedRest;
|
|
8
|
+
export declare function isRestEnabled(model: Model): boolean;
|
|
9
|
+
export declare function hasRestOperation(model: Model, operation: RestOperation): boolean;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { assertKeyValueArgs, getOptionalKvPair } from '../../sql-generator/utils/ast-helpers.js';
|
|
2
|
+
export const REST_OPERATIONS = ['list', 'get', 'create', 'update', 'delete'];
|
|
3
|
+
const HTTP_VERB_HINTS = {
|
|
4
|
+
GET: 'list or get',
|
|
5
|
+
POST: 'create',
|
|
6
|
+
PUT: 'update',
|
|
7
|
+
PATCH: 'update',
|
|
8
|
+
DELETE: 'delete',
|
|
9
|
+
};
|
|
10
|
+
export function normalizeRest(model) {
|
|
11
|
+
const fieldRest = model.fields.find((field) => field.attributes.some((attribute) => attribute.name === 'rest'));
|
|
12
|
+
if (fieldRest) {
|
|
13
|
+
throw new Error(`@rest on model ${model.name} must be a model attribute, not a field attribute on "${fieldRest.name}"`);
|
|
14
|
+
}
|
|
15
|
+
const restAttributes = model.attributes.filter((attribute) => attribute.name === 'rest');
|
|
16
|
+
if (restAttributes.length === 0) {
|
|
17
|
+
return { operations: new Set(REST_OPERATIONS) };
|
|
18
|
+
}
|
|
19
|
+
if (restAttributes.length > 1) {
|
|
20
|
+
throw new Error(`Model ${model.name} has duplicate @rest attributes`);
|
|
21
|
+
}
|
|
22
|
+
return normalizeRestAttribute(restAttributes[0], model.name);
|
|
23
|
+
}
|
|
24
|
+
export function isRestEnabled(model) {
|
|
25
|
+
return normalizeRest(model).operations.size > 0;
|
|
26
|
+
}
|
|
27
|
+
export function hasRestOperation(model, operation) {
|
|
28
|
+
return normalizeRest(model).operations.has(operation);
|
|
29
|
+
}
|
|
30
|
+
function normalizeRestAttribute(attribute, modelName) {
|
|
31
|
+
if (!attribute.args) {
|
|
32
|
+
return { operations: new Set() };
|
|
33
|
+
}
|
|
34
|
+
if (attribute.args.kind === 'ExpressionArgs') {
|
|
35
|
+
if (attribute.args.expressions.length === 0) {
|
|
36
|
+
throw new Error(`@rest() on model ${modelName} is empty; use @rest, @rest(false), only, or except`);
|
|
37
|
+
}
|
|
38
|
+
if (attribute.args.expressions.length !== 1) {
|
|
39
|
+
throw new Error(`@rest on model ${modelName} expects a single boolean or key-value args`);
|
|
40
|
+
}
|
|
41
|
+
const expression = attribute.args.expressions[0];
|
|
42
|
+
if (expression.kind === 'BooleanLiteral') {
|
|
43
|
+
if (expression.value === false) {
|
|
44
|
+
return { operations: new Set() };
|
|
45
|
+
}
|
|
46
|
+
return { operations: new Set(REST_OPERATIONS) };
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`@rest on model ${modelName} expects false, only, or except`);
|
|
49
|
+
}
|
|
50
|
+
const args = assertKeyValueArgs(attribute.args);
|
|
51
|
+
const onlyPair = getOptionalKvPair(args, 'only');
|
|
52
|
+
const exceptPair = getOptionalKvPair(args, 'except');
|
|
53
|
+
if (onlyPair && exceptPair) {
|
|
54
|
+
throw new Error(`@rest on model ${modelName} cannot mix only and except`);
|
|
55
|
+
}
|
|
56
|
+
if (!onlyPair && !exceptPair) {
|
|
57
|
+
throw new Error(`@rest on model ${modelName} requires only, except, or false`);
|
|
58
|
+
}
|
|
59
|
+
if (onlyPair) {
|
|
60
|
+
return { operations: new Set(parseRestOperations(onlyPair.value, modelName, 'only')) };
|
|
61
|
+
}
|
|
62
|
+
const excluded = new Set(parseRestOperations(exceptPair.value, modelName, 'except'));
|
|
63
|
+
return {
|
|
64
|
+
operations: new Set(REST_OPERATIONS.filter((operation) => !excluded.has(operation))),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function parseRestOperations(value, modelName, fieldName) {
|
|
68
|
+
if (value.kind !== 'ArrayLiteral') {
|
|
69
|
+
throw new Error(`@rest ${fieldName} on model ${modelName} must be an array of operations`);
|
|
70
|
+
}
|
|
71
|
+
return value.elements.map((element) => parseRestOperation(element, modelName));
|
|
72
|
+
}
|
|
73
|
+
function parseRestOperation(value, modelName) {
|
|
74
|
+
if (value.kind !== 'Identifier') {
|
|
75
|
+
throw new Error(`@rest operation on model ${modelName} must be an identifier`);
|
|
76
|
+
}
|
|
77
|
+
const raw = value.name;
|
|
78
|
+
const httpHint = HTTP_VERB_HINTS[raw.toUpperCase()];
|
|
79
|
+
if (httpHint && raw === raw.toUpperCase()) {
|
|
80
|
+
throw new Error(`Unknown @rest operation "${raw}" on model ${modelName}; use ${httpHint} instead of HTTP verb "${raw}"`);
|
|
81
|
+
}
|
|
82
|
+
const operation = raw.toLowerCase();
|
|
83
|
+
if (isRestOperation(operation)) {
|
|
84
|
+
return operation;
|
|
85
|
+
}
|
|
86
|
+
if (httpHint) {
|
|
87
|
+
throw new Error(`Unknown @rest operation "${raw}" on model ${modelName}; use ${httpHint} instead of HTTP verb "${raw}"`);
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`Unknown @rest operation "${raw}" on model ${modelName}; expected one of ${REST_OPERATIONS.join(', ')}`);
|
|
90
|
+
}
|
|
91
|
+
function isRestOperation(value) {
|
|
92
|
+
return REST_OPERATIONS.includes(value);
|
|
93
|
+
}
|
package/dist/cli/generate.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare function generateSql(schemaPath?: string): Promise<string>;
|
|
2
2
|
export declare function generateClient(schemaPath?: string): Promise<void>;
|
|
3
3
|
export declare function generateApi(schemaPath?: string): Promise<void>;
|
|
4
|
+
export declare function syncGeneratedRouteFiles(routesDir: string, routes: Map<string, string>): Promise<void>;
|
|
4
5
|
export declare function generateAll(schemaPath?: string): Promise<void>;
|
package/dist/cli/generate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parse } from '../schema-dsl/index.js';
|
|
4
4
|
import { generateApiFiles } from '../api-generator/index.js';
|
|
@@ -38,10 +38,17 @@ export async function generateApi(schemaPath) {
|
|
|
38
38
|
await writeFile(path.join(schemasDir, 'validation.ts'), files.validation, 'utf8');
|
|
39
39
|
await writeFile(path.join(outputDir, 'openapi.ts'), files.openapiTs, 'utf8');
|
|
40
40
|
await writeFile(path.join(outputDir, 'openapi.json'), files.openapiJson, 'utf8');
|
|
41
|
-
|
|
41
|
+
await syncGeneratedRouteFiles(routesDir, files.routes);
|
|
42
|
+
console.log(`Generated API files in ${outputDir}`);
|
|
43
|
+
}
|
|
44
|
+
export async function syncGeneratedRouteFiles(routesDir, routes) {
|
|
45
|
+
for (const [fileName, content] of routes) {
|
|
42
46
|
await writeFile(path.join(routesDir, fileName), content, 'utf8');
|
|
43
47
|
}
|
|
44
|
-
|
|
48
|
+
const existing = await readdir(routesDir);
|
|
49
|
+
await Promise.all(existing
|
|
50
|
+
.filter((fileName) => fileName.endsWith('.ts') && !routes.has(fileName))
|
|
51
|
+
.map((fileName) => unlink(path.join(routesDir, fileName))));
|
|
45
52
|
}
|
|
46
53
|
export async function generateAll(schemaPath) {
|
|
47
54
|
const resolvedSchemaPath = resolveSchemaPath(schemaPath);
|
|
@@ -77,7 +77,8 @@ models {
|
|
|
77
77
|
|
|
78
78
|
orders: Order[]
|
|
79
79
|
|
|
80
|
-
@
|
|
80
|
+
@rest(except: [create, update, delete])
|
|
81
|
+
@policy(role: USER, allow: [select], where: "id = {{auth.user.id}}")
|
|
81
82
|
@policy(role: ADMIN, allow: all)
|
|
82
83
|
|
|
83
84
|
@@index(fields: [role])
|
|
@@ -93,6 +94,7 @@ models {
|
|
|
93
94
|
**Key concepts:**
|
|
94
95
|
|
|
95
96
|
- **Relations:** Put `@relation(fields: [...], references: [...])` on the FK-owning side. The inverse side is inferred.
|
|
97
|
+
- **REST surface:** `@rest(only: [...])`, `@rest(except: [...])`, or `@rest(false)` controls which CRUD handlers are generated (`list`/`get`/`create`/`update`/`delete`).
|
|
96
98
|
- **Policies:** `@policy(role: ..., allow: [select|insert|update|delete|all], where: "...")` — `where` supports `{{auth.user.id}}`.
|
|
97
99
|
- **Validation:** `@regex(...)`, `@range(min: ..., max: ...)` flow into generated Zod schemas.
|
|
98
100
|
- **Indexes / triggers:** `@@index(...)`, `@@trigger { timing, event, level, execute: """...""" }`.
|
|
@@ -225,8 +227,9 @@ Also available: `ForeignKeyConstraintError`, `DatabaseError`.
|
|
|
225
227
|
| Location | `src/routes/**/*.ts` |
|
|
226
228
|
| Export | `export default router` (`Hono<AppEnv>`) |
|
|
227
229
|
| Mount path | File path relative to `src/routes/` |
|
|
230
|
+
| Same-path overlay | `src/routes/users.ts` merges into `generated/routes/users.ts` |
|
|
228
231
|
|
|
229
|
-
`src/routes/health.ts` → `GET /health
|
|
232
|
+
`src/routes/health.ts` → `GET /health` (standalone). When the file name matches a model route (`users.ts`), it is imported into the generated model router after remaining `@rest` handlers. Regenerate after adding files.
|
|
230
233
|
|
|
231
234
|
```typescript
|
|
232
235
|
import { Hono } from 'hono';
|
|
@@ -276,6 +279,7 @@ schematic-pg hooks:add [--model X] # scaffold src/hooks/{Model}.ts
|
|
|
276
279
|
- Unauthenticated requests default to `{ role: 'PUBLIC' }`.
|
|
277
280
|
- `@policy` `allow` maps to HTTP: GET→select, POST→insert, PUT→update, DELETE→delete.
|
|
278
281
|
- Row-level `where` is injected on read/update/delete; POST checks permission only.
|
|
282
|
+
- `@rest` controls which handlers exist; disabled methods return `404` and are omitted from OpenAPI.
|
|
279
283
|
|
|
280
284
|
## Generated Outputs (Read-Only)
|
|
281
285
|
|
package/dist/cli/templates.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const AGENTS_TEMPLATE: string;
|
|
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 @regex(pattern: \"^[\\w.-]+@[\\w.-]+\\.\\w+$\", message: \"Invalid email address\")\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
|
|
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 @regex(pattern: \"^[\\w.-]+@[\\w.-]+\\.\\w+$\", message: \"Invalid email address\")\n name: VARCHAR(150)?\n role: UserRole @default(USER)\n passwordHash: VARCHAR(255)? @omit @unfilterable\n createdAt: TIMESTAMP @default(now())\n\n @rest(except: [create, update, delete])\n @policy(role: USER, allow: [select], 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
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";
|
package/dist/cli/templates.js
CHANGED
|
@@ -21,7 +21,8 @@ models {
|
|
|
21
21
|
passwordHash: VARCHAR(255)? @omit @unfilterable
|
|
22
22
|
createdAt: TIMESTAMP @default(now())
|
|
23
23
|
|
|
24
|
-
@
|
|
24
|
+
@rest(except: [create, update, delete])
|
|
25
|
+
@policy(role: USER, allow: [select], where: "id = {{auth.user.id}}")
|
|
25
26
|
@policy(role: ADMIN, allow: all)
|
|
26
27
|
}
|
|
27
28
|
}
|