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 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] # Apply DDL from schema + write .schema-state snapshot
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` is the recommended first-time setup. Use `db:diff` / `db:migrate` when evolving an existing database.
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,7 @@
1
+ import type { Hono } from 'hono';
2
+ /**
3
+ * Mounts public API docs routes:
4
+ * - GET /openapi.json — OpenAPI document
5
+ * - GET /docs — Scalar API reference UI (CDN)
6
+ */
7
+ export declare function mountApiDocs(app: Hono, document: object): void;
@@ -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
  '',
@@ -5,6 +5,8 @@ export interface GeneratedApiFiles {
5
5
  validation: string;
6
6
  hooks: string;
7
7
  routes: Map<string, string>;
8
+ openapiTs: string;
9
+ openapiJson: string;
8
10
  }
9
11
  export interface GenerateApiFilesOptions {
10
12
  customRoutesDir?: string;
@@ -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 appOptions = options?.customRoutesDir
10
- ? { customRoutesDir: options.customRoutesDir }
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
+ }
@@ -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 # first-time DDL apply
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
@@ -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/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\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\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";
@@ -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/data
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] Apply DDL and snapshot schema state
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
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Single-file backend framework for PostgreSQL and Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",