schematic-pg 0.1.6 → 0.1.8

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.
Files changed (45) hide show
  1. package/README.md +256 -11
  2. package/dist/api/hooks/define.d.ts +10 -0
  3. package/dist/api/hooks/define.js +3 -0
  4. package/dist/api/hooks/index.d.ts +4 -0
  5. package/dist/api/hooks/index.js +2 -0
  6. package/dist/api/hooks/registry.d.ts +8 -0
  7. package/dist/api/hooks/registry.js +94 -0
  8. package/dist/api/hooks/types.d.ts +48 -0
  9. package/dist/api/hooks/types.js +1 -0
  10. package/dist/api/utils/include-query.d.ts +6 -0
  11. package/dist/api/utils/include-query.js +75 -0
  12. package/dist/api/utils/read-query.d.ts +12 -0
  13. package/dist/api/utils/read-query.js +10 -0
  14. package/dist/api/utils/response-shape.d.ts +2 -0
  15. package/dist/api/utils/response-shape.js +28 -0
  16. package/dist/api-generator/app-generator.js +3 -0
  17. package/dist/api-generator/hook-scanner.d.ts +11 -0
  18. package/dist/api-generator/hook-scanner.js +36 -0
  19. package/dist/api-generator/hooks-generator.d.ts +2 -0
  20. package/dist/api-generator/hooks-generator.js +25 -0
  21. package/dist/api-generator/index.d.ts +2 -0
  22. package/dist/api-generator/index.js +7 -1
  23. package/dist/api-generator/route-generator.d.ts +4 -2
  24. package/dist/api-generator/route-generator.js +132 -82
  25. package/dist/api-generator/utils/api-fields.d.ts +6 -0
  26. package/dist/api-generator/utils/api-fields.js +32 -0
  27. package/dist/api-generator/zod-schema-generator.d.ts +3 -0
  28. package/dist/api-generator/zod-schema-generator.js +66 -8
  29. package/dist/cli/dev.js +5 -36
  30. package/dist/cli/generate.js +1 -0
  31. package/dist/cli/hooks.d.ts +6 -0
  32. package/dist/cli/hooks.js +85 -0
  33. package/dist/cli/init.js +6 -1
  34. package/dist/cli/paths.d.ts +1 -0
  35. package/dist/cli/paths.js +1 -0
  36. package/dist/cli/server.d.ts +5 -0
  37. package/dist/cli/server.js +60 -0
  38. package/dist/cli/start.d.ts +7 -0
  39. package/dist/cli/start.js +35 -0
  40. package/dist/cli/templates.d.ts +2 -1
  41. package/dist/cli/templates.js +37 -1
  42. package/dist/cli.js +10 -0
  43. package/dist/constants.d.ts +1 -0
  44. package/dist/constants.js +1 -0
  45. package/package.json +7 -1
@@ -1,5 +1,6 @@
1
+ import { PACKAGE_NAME } from '../constants.js';
1
2
  import { fieldHasAttribute, getFieldAttribute, getModelNames, getOptionalKvPair, getPrimaryKey, getStoredFields, } from '../sql-generator/utils/ast-helpers.js';
2
- import { getFilterableFields, getOmittedFields, getSortableFieldNames, toModelConstantPrefix, } from './utils/api-fields.js';
3
+ import { buildIncludableRelationTree, buildRelationTargets, getFilterableFields, getOmittedFields, getSortableFieldNames, toModelConstantPrefix, } from './utils/api-fields.js';
3
4
  import { buildFilterFieldMeta, queryParamKey, toFilterZodType, } from './utils/filter-operators.js';
4
5
  export class ZodSchemaGenerator {
5
6
  schema;
@@ -12,9 +13,26 @@ export class ZodSchemaGenerator {
12
13
  return [
13
14
  '// Auto-generated by ZodSchemaGenerator. Do not edit manually.',
14
15
  "import { z } from 'zod';",
16
+ `import { validateIncludePaths } from '${PACKAGE_NAME}/api/utils/include-query';`,
15
17
  `import type {\n ${typeImports},\n} from '../db-types.js';`,
16
18
  '',
17
19
  ...modelBlocks,
20
+ this.generateGlobalMetadata(),
21
+ ].join('\n');
22
+ }
23
+ generateGlobalMetadata() {
24
+ const omitByModel = Object.fromEntries(this.schema.models.map((model) => [
25
+ model.name,
26
+ getOmittedFields(model, this.schema).map((field) => field.name),
27
+ ]));
28
+ const relationTargets = Object.fromEntries(this.schema.models.map((model) => [
29
+ model.name,
30
+ buildRelationTargets(model, this.schema),
31
+ ]));
32
+ return [
33
+ `export const API_OMIT_FIELDS_BY_MODEL = ${JSON.stringify(omitByModel, null, 2)} as const;`,
34
+ `export const API_RELATION_TARGETS = ${JSON.stringify(relationTargets, null, 2)} as const;`,
35
+ '',
18
36
  ].join('\n');
19
37
  }
20
38
  generateModelSchemas(model) {
@@ -49,39 +67,79 @@ export class ZodSchemaGenerator {
49
67
  const omittedFields = getOmittedFields(model, this.schema);
50
68
  const filterFieldMeta = filterableFields.map((field) => buildFilterFieldMeta(field, this.schema));
51
69
  const queryLines = filterableFields.flatMap((field) => this.generateListQueryFieldLines(field, filterFieldMeta.find((meta) => meta.name === field.name)));
52
- queryLines.push('limit: z.coerce.number().int().min(1).max(100).optional(),', 'offset: z.coerce.number().int().min(0).optional(),', 'sort: z.string().optional(),');
70
+ queryLines.push('limit: z.coerce.number().int().min(1).max(100).optional(),', 'offset: z.coerce.number().int().min(0).optional(),', 'sort: z.string().optional(),', 'include: z.string().optional(),');
53
71
  const sortableLiteral = sortableFields.map((field) => `'${field}'`).join(', ');
54
72
  const listQueryFieldsJson = JSON.stringify(filterFieldMeta, null, 2);
55
73
  const omitFieldNames = omittedFields.map((field) => field.name);
56
74
  const omitFieldsJson = JSON.stringify(omitFieldNames);
75
+ const includableRelationsJson = JSON.stringify(buildIncludableRelationTree(model, this.schema), null, 2);
57
76
  const responseType = omittedFields.length === 0
58
77
  ? `export type ${model.name}Response = ${model.name};`
59
78
  : `export type ${model.name}Response = Omit<${model.name}, ${omitFieldNames.map((name) => `'${name}'`).join(' | ')}>;`;
60
79
  return [
61
80
  `export const ${prefix}_SORTABLE_FIELDS = [${sortableLiteral}] as const;`,
62
81
  `export const ${prefix}_LIST_QUERY_FIELDS = ${listQueryFieldsJson} as const;`,
82
+ `export const ${prefix}_INCLUDABLE_RELATIONS = ${includableRelationsJson} as const;`,
63
83
  `export const ${prefix}_OMIT_FIELDS = ${omitFieldsJson} as const;`,
64
84
  responseType + '\n',
85
+ `export const ${model.name}GetQuerySchema = z`,
86
+ ' .object({',
87
+ ' include: z.string().optional(),',
88
+ ' })',
89
+ ...this.generateIncludeRefinement(prefix),
90
+ '\n',
65
91
  `export const ${model.name}ListQuerySchema = z`,
66
92
  ' .object({',
67
93
  ...queryLines.map((line) => ` ${line}`),
68
94
  ' })',
95
+ ...this.generateReadQueryRefinement(prefix),
96
+ '\n',
97
+ ];
98
+ }
99
+ generateIncludeRefinement(prefix) {
100
+ return [
69
101
  ' .superRefine((data, ctx) => {',
70
- ' if (data.sort === undefined) {',
102
+ ' if (data.include === undefined) {',
71
103
  ' return;',
72
104
  ' }',
73
- ' const descending = data.sort.startsWith(\'-\');',
74
- ' const field = descending ? data.sort.slice(1) : data.sort;',
75
- ` if (!(${prefix}_SORTABLE_FIELDS as readonly string[]).includes(field)) {`,
105
+ ` const includeError = validateIncludePaths(data.include, ${prefix}_INCLUDABLE_RELATIONS);`,
106
+ ' if (includeError) {',
76
107
  ' ctx.addIssue({',
77
108
  ' code: \'custom\',',
78
- ' message: `Invalid sort field "${field}"`,',
79
- ' path: [\'sort\'],',
109
+ ' message: includeError,',
110
+ ' path: [\'include\'],',
80
111
  ' });',
81
112
  ' }',
82
113
  ' });\n',
83
114
  ];
84
115
  }
116
+ generateReadQueryRefinement(prefix) {
117
+ return [
118
+ ' .superRefine((data, ctx) => {',
119
+ ' if (data.sort !== undefined) {',
120
+ ' const descending = data.sort.startsWith(\'-\');',
121
+ ' const field = descending ? data.sort.slice(1) : data.sort;',
122
+ ` if (!(${prefix}_SORTABLE_FIELDS as readonly string[]).includes(field)) {`,
123
+ ' ctx.addIssue({',
124
+ ' code: \'custom\',',
125
+ ' message: `Invalid sort field "${field}"`,',
126
+ ' path: [\'sort\'],',
127
+ ' });',
128
+ ' }',
129
+ ' }',
130
+ ' if (data.include !== undefined) {',
131
+ ` const includeError = validateIncludePaths(data.include, ${prefix}_INCLUDABLE_RELATIONS);`,
132
+ ' if (includeError) {',
133
+ ' ctx.addIssue({',
134
+ ' code: \'custom\',',
135
+ ' message: includeError,',
136
+ ' path: [\'include\'],',
137
+ ' });',
138
+ ' }',
139
+ ' }',
140
+ ' });\n',
141
+ ];
142
+ }
85
143
  generateListQueryFieldLines(field, meta) {
86
144
  const lines = [];
87
145
  for (const operator of meta.operators) {
package/dist/cli/dev.js CHANGED
@@ -1,11 +1,10 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { watch } from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { runDbBootstrap } from './db.js';
5
4
  import { generateAll } from './generate.js';
6
5
  import { DEFAULT_OUTPUT_DIR, resolveSchemaPath } from './paths.js';
6
+ import { startAppServer, stopAppServer, waitForAppServerExit } from './server.js';
7
7
  const WATCH_DEBOUNCE_MS = 300;
8
- const SERVER_STOP_TIMEOUT_MS = 5000;
9
8
  function parseDevArgs(args) {
10
9
  let schemaPath = resolveSchemaPath();
11
10
  let watchSchema = true;
@@ -29,36 +28,6 @@ function createDebouncer(fn, ms) {
29
28
  }, ms);
30
29
  };
31
30
  }
32
- function startServer(appPath) {
33
- return spawn(process.execPath, ['--import', 'tsx', appPath], {
34
- stdio: 'inherit',
35
- cwd: process.cwd(),
36
- });
37
- }
38
- function stopServer(serverProcess) {
39
- if (!serverProcess || serverProcess.exitCode !== null || serverProcess.killed) {
40
- return Promise.resolve();
41
- }
42
- return new Promise((resolve) => {
43
- serverProcess.once('exit', () => resolve());
44
- if (process.platform === 'win32') {
45
- serverProcess.kill();
46
- }
47
- else {
48
- serverProcess.kill('SIGTERM');
49
- }
50
- setTimeout(() => {
51
- if (serverProcess.exitCode === null && !serverProcess.killed) {
52
- serverProcess.kill('SIGKILL');
53
- }
54
- }, SERVER_STOP_TIMEOUT_MS);
55
- });
56
- }
57
- function waitForServerExit(serverProcess) {
58
- return new Promise((resolve) => {
59
- serverProcess.once('exit', () => resolve());
60
- });
61
- }
62
31
  export async function runDev(args = []) {
63
32
  const { schemaPath, watchSchema } = parseDevArgs(args);
64
33
  const appPath = path.resolve(DEFAULT_OUTPUT_DIR, 'app.ts');
@@ -74,8 +43,8 @@ export async function runDev(args = []) {
74
43
  try {
75
44
  await generateAll(schemaPath);
76
45
  await runDbBootstrap(schemaPath);
77
- await stopServer(serverProcess);
78
- serverProcess = startServer(appPath);
46
+ await stopAppServer(serverProcess);
47
+ serverProcess = startAppServer(appPath);
79
48
  serverProcess.on('exit', (code, signal) => {
80
49
  if (restarting || shuttingDown) {
81
50
  return;
@@ -119,7 +88,7 @@ export async function runDev(args = []) {
119
88
  return;
120
89
  }
121
90
  shuttingDown = true;
122
- await stopServer(serverProcess);
91
+ await stopAppServer(serverProcess);
123
92
  }
124
93
  process.once('SIGINT', () => {
125
94
  void shutdown().finally(() => {
@@ -134,7 +103,7 @@ export async function runDev(args = []) {
134
103
  await syncAndServe();
135
104
  if (!watchSchema) {
136
105
  if (serverProcess) {
137
- await waitForServerExit(serverProcess);
106
+ await waitForAppServerExit(serverProcess);
138
107
  }
139
108
  return;
140
109
  }
@@ -34,6 +34,7 @@ export async function generateApi(schemaPath) {
34
34
  await mkdir(schemasDir, { recursive: true });
35
35
  await writeFile(path.join(outputDir, 'app.ts'), files.app, 'utf8');
36
36
  await writeFile(path.join(outputDir, 'policies.ts'), files.policies, 'utf8');
37
+ await writeFile(path.join(outputDir, 'hooks.ts'), files.hooks, 'utf8');
37
38
  await writeFile(path.join(schemasDir, 'validation.ts'), files.validation, 'utf8');
38
39
  for (const [fileName, content] of files.routes) {
39
40
  await writeFile(path.join(routesDir, fileName), content, 'utf8');
@@ -0,0 +1,6 @@
1
+ export interface RunHooksAddOptions {
2
+ schemaPath?: string;
3
+ modelName?: string;
4
+ hooksDir?: string;
5
+ }
6
+ export declare function runHooksAdd(args: string[], options?: RunHooksAddOptions): Promise<string>;
@@ -0,0 +1,85 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { select } from '@inquirer/prompts';
5
+ import { discoverHooks } from '../api-generator/hook-scanner.js';
6
+ import { PACKAGE_NAME } from '../constants.js';
7
+ import { parse } from '../schema-dsl/index.js';
8
+ import { DEFAULT_HOOKS_DIR, resolveSchemaPath } from './paths.js';
9
+ import { createHookFileTemplate } from './templates.js';
10
+ function parseModelFlag(args) {
11
+ const modelIndex = args.indexOf('--model');
12
+ if (modelIndex === -1) {
13
+ return undefined;
14
+ }
15
+ const modelName = args[modelIndex + 1];
16
+ if (!modelName || modelName.startsWith('--')) {
17
+ throw new Error('Missing value for --model');
18
+ }
19
+ return modelName;
20
+ }
21
+ function resolveSchemaArg(args) {
22
+ const positional = [];
23
+ for (let index = 0; index < args.length; index += 1) {
24
+ const arg = args[index];
25
+ if (arg === '--model') {
26
+ index += 1;
27
+ continue;
28
+ }
29
+ if (!arg.startsWith('--')) {
30
+ positional.push(arg);
31
+ }
32
+ }
33
+ return positional[0];
34
+ }
35
+ function getHookFilePath(hooksDir, modelName) {
36
+ return path.join(hooksDir, `${modelName}.ts`);
37
+ }
38
+ function assertModelExists(schema, modelName) {
39
+ const model = schema.models.find((entry) => entry.name === modelName);
40
+ if (!model) {
41
+ throw new Error(`Model "${modelName}" was not found in schema`);
42
+ }
43
+ return model;
44
+ }
45
+ function assertHookFileDoesNotExist(hooksDir, modelName) {
46
+ const hookFilePath = getHookFilePath(hooksDir, modelName);
47
+ if (existsSync(hookFilePath)) {
48
+ throw new Error(`Hook file already exists: ${hookFilePath}`);
49
+ }
50
+ }
51
+ async function promptForModel(models, hooksDir, schema) {
52
+ const { modelsWithHooks } = discoverHooks(hooksDir, schema);
53
+ const availableModels = models
54
+ .map((model) => model.name)
55
+ .filter((modelName) => !modelsWithHooks.has(modelName))
56
+ .sort((left, right) => left.localeCompare(right));
57
+ if (availableModels.length === 0) {
58
+ throw new Error('All schema models already have hook files in src/hooks/');
59
+ }
60
+ return select({
61
+ message: 'Select a model to scaffold lifecycle hooks',
62
+ choices: availableModels.map((modelName) => ({
63
+ name: modelName,
64
+ value: modelName,
65
+ })),
66
+ });
67
+ }
68
+ export async function runHooksAdd(args, options = {}) {
69
+ const schemaPath = resolveSchemaPath(options.schemaPath ?? resolveSchemaArg(args));
70
+ const hooksDir = options.hooksDir ?? DEFAULT_HOOKS_DIR;
71
+ const source = await readFile(schemaPath, 'utf8');
72
+ const schema = parse(source);
73
+ if (schema.models.length === 0) {
74
+ throw new Error('Schema has no models');
75
+ }
76
+ const modelName = options.modelName ?? parseModelFlag(args) ?? (await promptForModel(schema.models, hooksDir, schema));
77
+ assertModelExists(schema, modelName);
78
+ assertHookFileDoesNotExist(hooksDir, modelName);
79
+ await mkdir(hooksDir, { recursive: true });
80
+ const hookFilePath = getHookFilePath(hooksDir, modelName);
81
+ await writeFile(hookFilePath, createHookFileTemplate(modelName), 'utf8');
82
+ console.log(`Created ${path.relative(process.cwd(), hookFilePath)}`);
83
+ console.log(`\nNext step: run \`${PACKAGE_NAME} generate:api\` to wire hooks into generated routes.`);
84
+ return hookFilePath;
85
+ }
package/dist/cli/init.js CHANGED
@@ -66,6 +66,7 @@ export async function runInit(args) {
66
66
  }
67
67
  await mkdir(targetDir, { recursive: true });
68
68
  await mkdir(path.join(targetDir, 'src/routes'), { recursive: true });
69
+ await mkdir(path.join(targetDir, 'src/hooks'), { recursive: true });
69
70
  for (const file of INIT_FILES) {
70
71
  const filePath = path.join(targetDir, file.relativePath);
71
72
  if (existsSync(filePath)) {
@@ -108,7 +109,11 @@ export async function runInit(args) {
108
109
  console.log(' docker compose up -d --wait');
109
110
  console.log(` npx ${PACKAGE_NAME} dev # generate + bootstrap + server + schema watch`);
110
111
  console.log('');
111
- console.log(' # split steps (dev already includes generate, bootstrap, and watch):');
112
+ console.log(' # production:');
113
+ console.log(` npx ${PACKAGE_NAME} generate`);
114
+ console.log(` npx ${PACKAGE_NAME} start # migrate DB + run server`);
115
+ console.log('');
116
+ console.log(' # split dev steps (dev already includes generate, bootstrap, and watch):');
112
117
  console.log(` npx ${PACKAGE_NAME} generate`);
113
118
  console.log(` npx ${PACKAGE_NAME} db:bootstrap`);
114
119
  console.log(` npx ${PACKAGE_NAME} dev --no-watch`);
@@ -1,5 +1,6 @@
1
1
  export declare const DEFAULT_SCHEMA_FILE = "app.schema";
2
2
  export declare const DEFAULT_OUTPUT_DIR = "generated";
3
3
  export declare const DEFAULT_CUSTOM_ROUTES_DIR: string;
4
+ export declare const DEFAULT_HOOKS_DIR: string;
4
5
  export declare function resolveSchemaPath(schemaArg?: string): string;
5
6
  export declare function resolveOutputDir(): string;
package/dist/cli/paths.js CHANGED
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  export const DEFAULT_SCHEMA_FILE = 'app.schema';
3
3
  export const DEFAULT_OUTPUT_DIR = 'generated';
4
4
  export const DEFAULT_CUSTOM_ROUTES_DIR = path.resolve('src/routes');
5
+ export const DEFAULT_HOOKS_DIR = path.resolve('src/hooks');
5
6
  export function resolveSchemaPath(schemaArg) {
6
7
  return path.resolve(schemaArg ?? DEFAULT_SCHEMA_FILE);
7
8
  }
@@ -0,0 +1,5 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export declare function startAppServer(appPath: string, env?: NodeJS.ProcessEnv): ChildProcess;
3
+ export declare function stopAppServer(serverProcess: ChildProcess | null): Promise<void>;
4
+ export declare function waitForAppServerExit(serverProcess: ChildProcess): Promise<void>;
5
+ export declare function runAppServerUntilExit(appPath: string, env?: NodeJS.ProcessEnv): Promise<number | null>;
@@ -0,0 +1,60 @@
1
+ import { spawn } from 'node:child_process';
2
+ const SERVER_STOP_TIMEOUT_MS = 5000;
3
+ export function startAppServer(appPath, env) {
4
+ return spawn(process.execPath, ['--import', 'tsx', appPath], {
5
+ stdio: 'inherit',
6
+ cwd: process.cwd(),
7
+ env: env ? { ...process.env, ...env } : process.env,
8
+ });
9
+ }
10
+ export function stopAppServer(serverProcess) {
11
+ if (!serverProcess || serverProcess.exitCode !== null || serverProcess.killed) {
12
+ return Promise.resolve();
13
+ }
14
+ return new Promise((resolve) => {
15
+ serverProcess.once('exit', () => resolve());
16
+ if (process.platform === 'win32') {
17
+ serverProcess.kill();
18
+ }
19
+ else {
20
+ serverProcess.kill('SIGTERM');
21
+ }
22
+ setTimeout(() => {
23
+ if (serverProcess.exitCode === null && !serverProcess.killed) {
24
+ serverProcess.kill('SIGKILL');
25
+ }
26
+ }, SERVER_STOP_TIMEOUT_MS);
27
+ });
28
+ }
29
+ export function waitForAppServerExit(serverProcess) {
30
+ return new Promise((resolve) => {
31
+ serverProcess.once('exit', () => resolve());
32
+ });
33
+ }
34
+ export async function runAppServerUntilExit(appPath, env) {
35
+ let serverProcess = null;
36
+ let shuttingDown = false;
37
+ async function shutdown() {
38
+ if (shuttingDown) {
39
+ return;
40
+ }
41
+ shuttingDown = true;
42
+ await stopAppServer(serverProcess);
43
+ }
44
+ process.once('SIGINT', () => {
45
+ void shutdown().finally(() => {
46
+ process.exit(process.exitCode ?? 0);
47
+ });
48
+ });
49
+ process.once('SIGTERM', () => {
50
+ void shutdown().finally(() => {
51
+ process.exit(process.exitCode ?? 0);
52
+ });
53
+ });
54
+ serverProcess = startAppServer(appPath, env);
55
+ return new Promise((resolve) => {
56
+ serverProcess.once('exit', (code) => {
57
+ resolve(code);
58
+ });
59
+ });
60
+ }
@@ -0,0 +1,7 @@
1
+ type StartOptions = {
2
+ schemaPath: string;
3
+ migrate: boolean;
4
+ };
5
+ export declare function parseStartArgs(args: string[]): StartOptions;
6
+ export declare function runStart(args?: string[]): Promise<void>;
7
+ export {};
@@ -0,0 +1,35 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { runDbMigrate } from './db.js';
4
+ import { DEFAULT_OUTPUT_DIR, resolveSchemaPath } from './paths.js';
5
+ import { runAppServerUntilExit } from './server.js';
6
+ import { waitForDatabase } from './wait-for-database.js';
7
+ export function parseStartArgs(args) {
8
+ let schemaPath = resolveSchemaPath();
9
+ let migrate = true;
10
+ for (const arg of args) {
11
+ if (arg === '--no-migrate') {
12
+ migrate = false;
13
+ continue;
14
+ }
15
+ if (!arg.startsWith('--')) {
16
+ schemaPath = resolveSchemaPath(arg);
17
+ }
18
+ }
19
+ return { schemaPath, migrate };
20
+ }
21
+ export async function runStart(args = []) {
22
+ const { schemaPath, migrate } = parseStartArgs(args);
23
+ const appPath = path.resolve(DEFAULT_OUTPUT_DIR, 'app.ts');
24
+ if (!existsSync(appPath)) {
25
+ throw new Error(`Missing ${appPath}. Run "schematic-pg generate" first to create the app entry point.`);
26
+ }
27
+ await waitForDatabase();
28
+ if (migrate) {
29
+ await runDbMigrate([schemaPath]);
30
+ }
31
+ const exitCode = await runAppServerUntilExit(appPath, { NODE_ENV: 'production' });
32
+ if (exitCode !== 0 && exitCode !== null) {
33
+ process.exitCode = exitCode;
34
+ }
35
+ }
@@ -1,8 +1,9 @@
1
1
  export declare const APP_SCHEMA_TEMPLATE = "extensions {\n\n}\n\nenums {\n\n}\n\nmodels {\n model User {\n id: UUID @id @default(gen_random_uuid())\n email: VARCHAR(255) @unique\n name: VARCHAR(150)\n createdAt: TIMESTAMP @default(now())\n }\n}\n";
2
2
  export declare const ENV_TEMPLATE = "DATABASE_URL=postgresql://postgrest:postgrest@localhost:5432/postgrest\nJWT_SECRET=\nJWT_ROLE_CLAIM=role\nJWT_USER_ID_CLAIM=sub\n";
3
3
  export declare const GITIGNORE_TEMPLATE = "node_modules/\ndist/\n.env\ndocker_data/\n.DS_Store\n*.log\nnpm-debug.log*\n";
4
- export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgis/postgis:16-3.4\n container_name: schematic-pg-postgres\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";
4
+ export declare const DOCKER_COMPOSE_TEMPLATE = "services:\n postgres:\n image: postgis/postgis:16-3.4\n container_name: schematic-pg\n restart: unless-stopped\n ports:\n - \"5432:5432\"\n environment:\n POSTGRES_USER: postgrest\n POSTGRES_PASSWORD: postgrest\n POSTGRES_DB: postgrest\n volumes:\n - ./docker_data/postgres:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U postgrest -d postgrest\"]\n interval: 5s\n timeout: 5s\n retries: 5\n";
5
5
  export declare const TSCONFIG_TEMPLATE = "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"strict\": true,\n \"esModuleInterop\": true,\n \"skipLibCheck\": true,\n \"outDir\": \"dist\",\n \"rootDir\": \".\"\n },\n \"include\": [\"generated/**/*\", \"src/**/*\"]\n}\n";
6
6
  export declare const MAKEFILE_TEMPLATE = ".PHONY: dev\n\ndev:\n\tdocker compose up -d --wait\n\tnpx schematic-pg dev\n";
7
7
  export declare const HEALTH_ROUTE_TEMPLATE = "import { Hono } from 'hono';\nimport type { AppEnv } from 'schematic-pg/api/types';\n\nconst router = new Hono<AppEnv>();\nrouter.get('/', (c) => c.json({ ok: true }));\nexport default router;\n";
8
8
  export declare function createPackageJsonTemplate(projectName: string): string;
9
+ export declare function createHookFileTemplate(modelName: string): string;
@@ -32,7 +32,7 @@ npm-debug.log*
32
32
  export const DOCKER_COMPOSE_TEMPLATE = `services:
33
33
  postgres:
34
34
  image: postgis/postgis:16-3.4
35
- container_name: schematic-pg-postgres
35
+ container_name: schematic-pg
36
36
  restart: unless-stopped
37
37
  ports:
38
38
  - "5432:5432"
@@ -83,6 +83,7 @@ export function createPackageJsonTemplate(projectName) {
83
83
  type: 'module',
84
84
  scripts: {
85
85
  dev: `${PACKAGE_NAME} dev`,
86
+ start: `${PACKAGE_NAME} start`,
86
87
  generate: `${PACKAGE_NAME} generate`,
87
88
  'db:bootstrap': `${PACKAGE_NAME} db:bootstrap`,
88
89
  'db:migrate': `${PACKAGE_NAME} db:migrate`,
@@ -101,3 +102,38 @@ export function createPackageJsonTemplate(projectName) {
101
102
  },
102
103
  }, null, 2);
103
104
  }
105
+ export function createHookFileTemplate(modelName) {
106
+ return `import { defineHooks } from '${PACKAGE_NAME}/api/hooks';
107
+ import type { ${modelName}, ${modelName}CreateInput, ${modelName}UpdateInput } from '../../generated/db-types.js';
108
+
109
+ export default defineHooks<${modelName}, ${modelName}CreateInput, ${modelName}UpdateInput>({
110
+ async beforeCreate(ctx, next) {
111
+ // ctx.data is the create payload (mutable). Call await next() to proceed.
112
+ // Cancel without calling next(): return ctx.abort(422, 'reason');
113
+ await next();
114
+ },
115
+
116
+ async afterCreate(ctx) {
117
+ // ctx.result is the created row. Use ctx.db / ctx.auth for side effects.
118
+ },
119
+
120
+ async beforeUpdate(ctx, next) {
121
+ // ctx.params — route params (e.g. id). ctx.data — update payload (mutable).
122
+ await next();
123
+ },
124
+
125
+ async afterUpdate(ctx) {
126
+ // ctx.result is the updated row.
127
+ },
128
+
129
+ async beforeDelete(ctx, next) {
130
+ // ctx.params — route params. No ctx.data on delete.
131
+ await next();
132
+ },
133
+
134
+ async afterDelete(ctx) {
135
+ // ctx.result is the deleted row.
136
+ },
137
+ });
138
+ `;
139
+ }
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { runDbBootstrap, runDbDiff, runDbMigrate, runDbPing } from './cli/db.js';
3
3
  import { runDev } from './cli/dev.js';
4
+ import { runStart } from './cli/start.js';
4
5
  import { generateAll, generateApi, generateClient, generateSql } from './cli/generate.js';
6
+ import { runHooksAdd } from './cli/hooks.js';
5
7
  import { runInit } from './cli/init.js';
6
8
  import { PACKAGE_NAME } from './constants.js';
7
9
  const USAGE = `Usage: ${PACKAGE_NAME} <command> [options]
@@ -12,7 +14,9 @@ Commands:
12
14
  generate:sql [schema] Generate SQL DDL to stdout
13
15
  generate:client [schema] Generate db client files
14
16
  generate:api [schema] Generate API files
17
+ hooks:add [schema] [--model ModelName] Scaffold lifecycle hooks for a model
15
18
  dev [schema] [--no-watch] Generate, bootstrap DB, start server, watch schema
19
+ start [schema] [--no-migrate] Run production server (migrate DB, no generate/watch)
16
20
  db:ping Test database connection
17
21
  db:bootstrap [schema] Apply DDL and snapshot schema state
18
22
  db:diff [schema] Show schema diff (--name <name> to write migration)
@@ -49,9 +53,15 @@ async function main() {
49
53
  case 'generate:api':
50
54
  await generateApi(schemaPath);
51
55
  break;
56
+ case 'hooks:add':
57
+ await runHooksAdd(args);
58
+ break;
52
59
  case 'dev':
53
60
  await runDev(args);
54
61
  break;
62
+ case 'start':
63
+ await runStart(args);
64
+ break;
55
65
  case 'db:ping':
56
66
  await runDbPing();
57
67
  break;
@@ -1,3 +1,4 @@
1
1
  export declare const PACKAGE_NAME = "schematic-pg";
2
2
  export declare const PACKAGE_VERSION: string;
3
3
  export declare const MAX_INCLUDE_DEPTH = 10;
4
+ export declare const MAX_INCLUDE_PATHS = 10;
package/dist/constants.js CHANGED
@@ -4,3 +4,4 @@ const { version } = require('../package.json');
4
4
  export const PACKAGE_NAME = 'schematic-pg';
5
5
  export const PACKAGE_VERSION = version;
6
6
  export const MAX_INCLUDE_DEPTH = 10;
7
+ export const MAX_INCLUDE_PATHS = 10;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Single-file backend framework for PostgreSQL and Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,6 +34,10 @@
34
34
  "types": "./dist/schema-dsl/index.d.ts",
35
35
  "import": "./dist/schema-dsl/index.js"
36
36
  },
37
+ "./api/hooks": {
38
+ "types": "./dist/api/hooks/index.d.ts",
39
+ "import": "./dist/api/hooks/index.js"
40
+ },
37
41
  "./api/*": {
38
42
  "types": "./dist/api/*.d.ts",
39
43
  "import": "./dist/api/*.js"
@@ -51,6 +55,7 @@
51
55
  "generate:client": "tsx src/cli.ts generate:client",
52
56
  "generate:api": "tsx src/cli.ts generate:api",
53
57
  "dev:api": "tsx src/cli.ts dev",
58
+ "start": "tsx src/cli.ts start",
54
59
  "setup:env": "test -f .env || cp .env.example .env",
55
60
  "db:ping": "tsx src/cli.ts db:ping",
56
61
  "db:bootstrap": "tsx src/cli.ts db:bootstrap",
@@ -69,6 +74,7 @@
69
74
  "dependencies": {
70
75
  "@hono/node-server": "^2.0.6",
71
76
  "@hono/zod-validator": "^0.8.0",
77
+ "@inquirer/prompts": "^8.5.2",
72
78
  "hono": "^4.12.27",
73
79
  "pg": "^8.22.0",
74
80
  "tsx": "^4.19.4",