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.
@@ -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 mounts = getRouteMountEntries(this.schema);
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 customRoutesDir = this.options.customRoutesDir ?? DEFAULT_CUSTOM_ROUTES_DIR;
22
- const customMounts = discoverCustomRoutes(customRoutesDir);
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 = customMounts
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 includeAuthPaths = discoverCustomRoutes(customRoutesDir).some((entry) => entry.basePath === 'auth');
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
- return {
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
- [`${model.name}Create`]: {
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
- [`${model.name}Update`]: {
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
- [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'),
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
- 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'),
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
- paths[itemPath] = {
205
- get: {
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
- put: {
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
- delete: {
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
- constructor(model: Model, schema: Schema, modelsWithHooks?: ReadonlySet<string>);
7
- generate(): string;
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, modelsWithHooks?: ReadonlySet<string>): Map<string, string>;
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
- constructor(model, schema, modelsWithHooks = new Set()) {
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 clientKey = getClientExportName(this.model.name);
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 pathParams = primaryKey.fields.map((field) => `:${field}`).join('/');
23
- const whereFromParams = primaryKey.fields.map((field) => `${field}: params.${field}`).join(', ');
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 modelHasHooks = this.modelsWithHooks.has(this.model.name);
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 { validateJson, validateParam, validateQuery } from '${PACKAGE_NAME}/api/middleware/validate';`,
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, modelsWithHooks = new Set()) {
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, modelsWithHooks);
271
- files.set(generator.getRouteFileName(), generator.generate());
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
- export function getRouteMountEntries(schema) {
276
- return schema.models.map((model) => {
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
+ }
@@ -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>;
@@ -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
- for (const [fileName, content] of files.routes) {
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
- console.log(`Generated API files in ${outputDir}`);
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
- @policy(role: USER, allow: [select, update], where: "id = {{auth.user.id}}")
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`. Regenerate after adding files.
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
 
@@ -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, update], where: \"id = {{auth.user.id}}\")\n @policy(role: ADMIN, allow: all)\n }\n}\n";
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";
@@ -21,7 +21,8 @@ models {
21
21
  passwordHash: VARCHAR(255)? @omit @unfilterable
22
22
  createdAt: TIMESTAMP @default(now())
23
23
 
24
- @policy(role: USER, allow: [select, update], where: "id = {{auth.user.id}}")
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Single-file backend framework for PostgreSQL and Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",