schematic-pg 0.1.7 → 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.
@@ -7,9 +7,11 @@ import { hasPolicies } from './utils/policy.js';
7
7
  export class RouteGenerator {
8
8
  model;
9
9
  schema;
10
- constructor(model, schema) {
10
+ modelsWithHooks;
11
+ constructor(model, schema, modelsWithHooks = new Set()) {
11
12
  this.model = model;
12
13
  this.schema = schema;
14
+ this.modelsWithHooks = modelsWithHooks;
13
15
  }
14
16
  generate() {
15
17
  const clientKey = getClientExportName(this.model.name);
@@ -23,6 +25,7 @@ export class RouteGenerator {
23
25
  const listQuerySchemaName = `${this.model.name}ListQuerySchema`;
24
26
  const getQuerySchemaName = `${this.model.name}GetQuerySchema`;
25
27
  const modelHasPolicies = hasPolicies(this.model);
28
+ const modelHasHooks = this.modelsWithHooks.has(this.model.name);
26
29
  const constantPrefix = toModelConstantPrefix(this.model.name);
27
30
  return [
28
31
  '// Auto-generated by RouteGenerator. Do not edit manually.',
@@ -39,6 +42,11 @@ export class RouteGenerator {
39
42
  `import { assertPolicy, mergeWhere, resolvePolicyWhere } from '${PACKAGE_NAME}/api/auth/policy';`,
40
43
  ]
41
44
  : []),
45
+ ...(modelHasHooks
46
+ ? [
47
+ `import { cancelledResponse, createHookContext, runAfterHooks, runBeforeHooks } from '${PACKAGE_NAME}/api/hooks';`,
48
+ ]
49
+ : []),
42
50
  `import {`,
43
51
  ` ${this.model.name}CreateSchema,`,
44
52
  ` ${this.model.name}UpdateSchema,`,
@@ -59,11 +67,11 @@ export class RouteGenerator {
59
67
  '',
60
68
  ...this.generateGetRoute(clientKey, pathParams, paramSchemaName, getQuerySchemaName, whereFromParams, modelHasPolicies, constantPrefix),
61
69
  '',
62
- ...this.generateCreateRoute(clientKey, modelHasPolicies, constantPrefix),
70
+ ...this.generateCreateRoute(clientKey, modelHasPolicies, modelHasHooks, constantPrefix),
63
71
  '',
64
- ...this.generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix),
72
+ ...this.generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix),
65
73
  '',
66
- ...this.generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix),
74
+ ...this.generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix),
67
75
  '',
68
76
  'export default router;',
69
77
  '',
@@ -166,75 +174,88 @@ export class RouteGenerator {
166
174
  '});',
167
175
  ];
168
176
  }
169
- generateCreateRoute(clientKey, modelHasPolicies, constantPrefix) {
170
- if (!modelHasPolicies) {
171
- return [
172
- `router.post('/', validateJson(${this.model.name}CreateSchema), async (c) => {`,
173
- ' const db = c.get(\'db\');',
174
- ' const body = c.req.valid(\'json\');',
175
- ` const row = await db.${clientKey}.create(body);`,
176
- ` return ${this.mutationJsonRow('row', constantPrefix, 201)};`,
177
- '});',
178
- ];
179
- }
180
- return [
177
+ generateCreateRoute(clientKey, modelHasPolicies, modelHasHooks, constantPrefix) {
178
+ const lines = [
181
179
  `router.post('/', validateJson(${this.model.name}CreateSchema), async (c) => {`,
182
180
  ' const db = c.get(\'db\');',
183
- ' const auth = c.get(\'auth\');',
184
- ` assertPolicy('${this.model.name}', auth.role, 'insert');`,
185
- ' const body = c.req.valid(\'json\');',
186
- ` const row = await db.${clientKey}.create(body);`,
187
- ` return ${this.mutationJsonRow('row', constantPrefix, 201)};`,
188
- '});',
189
181
  ];
190
- }
191
- generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix) {
192
- if (!modelHasPolicies) {
193
- return [
194
- `router.put('/${pathParams}', validateParam(${paramSchemaName}), validateJson(${this.model.name}UpdateSchema), async (c) => {`,
195
- ' const db = c.get(\'db\');',
196
- ' const params = c.req.valid(\'param\');',
197
- ' const body = c.req.valid(\'json\');',
198
- ` const row = await db.${clientKey}.update({ where: { ${whereFromParams} }, data: body });`,
199
- ` return ${this.mutationJsonRow('row', constantPrefix)};`,
200
- '});',
201
- ];
182
+ if (modelHasPolicies || modelHasHooks) {
183
+ lines.push(' const auth = c.get(\'auth\');');
202
184
  }
203
- return [
185
+ if (modelHasPolicies) {
186
+ lines.push(` assertPolicy('${this.model.name}', auth.role, 'insert');`);
187
+ }
188
+ lines.push(' const body = c.req.valid(\'json\');');
189
+ if (modelHasHooks) {
190
+ lines.push(` const hookCtx = createHookContext({ c, db, auth, model: '${this.model.name}', operation: 'create', data: body });`, ` const gate = await runBeforeHooks('${this.model.name}', 'create', hookCtx);`, ' if (!gate.proceed) return gate.response ?? cancelledResponse(c);', ` const row = await db.${clientKey}.create(hookCtx.data);`, ' hookCtx.result = row;', ` await runAfterHooks('${this.model.name}', 'create', hookCtx);`, ` return ${this.mutationJsonRow('hookCtx.result', constantPrefix, 201)};`);
191
+ }
192
+ else {
193
+ lines.push(` const row = await db.${clientKey}.create(body);`, ` return ${this.mutationJsonRow('row', constantPrefix, 201)};`);
194
+ }
195
+ lines.push('});');
196
+ return lines;
197
+ }
198
+ generateUpdateRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix) {
199
+ const lines = [
204
200
  `router.put('/${pathParams}', validateParam(${paramSchemaName}), validateJson(${this.model.name}UpdateSchema), async (c) => {`,
205
201
  ' const db = c.get(\'db\');',
206
- ' const auth = c.get(\'auth\');',
207
- ` const policy = assertPolicy('${this.model.name}', auth.role, 'update');`,
208
- ' const policyWhere = resolvePolicyWhere(policy, auth);',
209
- ' const params = c.req.valid(\'param\');',
210
- ' const body = c.req.valid(\'json\');',
211
- ` const row = await db.${clientKey}.update({ where: mergeWhere({ ${whereFromParams} }, policyWhere), data: body });`,
212
- ` return ${this.mutationJsonRow('row', constantPrefix)};`,
213
- '});',
214
202
  ];
215
- }
216
- generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, constantPrefix) {
217
- if (!modelHasPolicies) {
218
- return [
219
- `router.delete('/${pathParams}', validateParam(${paramSchemaName}), async (c) => {`,
220
- ' const db = c.get(\'db\');',
221
- ' const params = c.req.valid(\'param\');',
222
- ` const row = await db.${clientKey}.delete({ ${whereFromParams} });`,
223
- ` return ${this.mutationJsonRow('row', constantPrefix)};`,
224
- '});',
225
- ];
203
+ if (modelHasPolicies || modelHasHooks) {
204
+ lines.push(' const auth = c.get(\'auth\');');
226
205
  }
227
- return [
206
+ if (modelHasPolicies) {
207
+ lines.push(` const policy = assertPolicy('${this.model.name}', auth.role, 'update');`, ' const policyWhere = resolvePolicyWhere(policy, auth);');
208
+ }
209
+ lines.push(' const params = c.req.valid(\'param\');', ' const body = c.req.valid(\'json\');');
210
+ if (modelHasHooks) {
211
+ lines.push(` const hookCtx = createHookContext({ c, db, auth, model: '${this.model.name}', operation: 'update', data: body, params });`, ` const gate = await runBeforeHooks('${this.model.name}', 'update', hookCtx);`, ' if (!gate.proceed) return gate.response ?? cancelledResponse(c);');
212
+ if (modelHasPolicies) {
213
+ lines.push(` const row = await db.${clientKey}.update({ where: mergeWhere({ ${whereFromParams} }, policyWhere), data: hookCtx.data });`);
214
+ }
215
+ else {
216
+ lines.push(` const row = await db.${clientKey}.update({ where: { ${whereFromParams} }, data: hookCtx.data });`);
217
+ }
218
+ lines.push(' hookCtx.result = row;', ` await runAfterHooks('${this.model.name}', 'update', hookCtx);`, ` return ${this.mutationJsonRow('hookCtx.result', constantPrefix)};`);
219
+ }
220
+ else if (modelHasPolicies) {
221
+ lines.push(` const row = await db.${clientKey}.update({ where: mergeWhere({ ${whereFromParams} }, policyWhere), data: body });`, ` return ${this.mutationJsonRow('row', constantPrefix)};`);
222
+ }
223
+ else {
224
+ lines.push(` const row = await db.${clientKey}.update({ where: { ${whereFromParams} }, data: body });`, ` return ${this.mutationJsonRow('row', constantPrefix)};`);
225
+ }
226
+ lines.push('});');
227
+ return lines;
228
+ }
229
+ generateDeleteRoute(clientKey, pathParams, paramSchemaName, whereFromParams, modelHasPolicies, modelHasHooks, constantPrefix) {
230
+ const lines = [
228
231
  `router.delete('/${pathParams}', validateParam(${paramSchemaName}), async (c) => {`,
229
232
  ' const db = c.get(\'db\');',
230
- ' const auth = c.get(\'auth\');',
231
- ` const policy = assertPolicy('${this.model.name}', auth.role, 'delete');`,
232
- ' const policyWhere = resolvePolicyWhere(policy, auth);',
233
- ' const params = c.req.valid(\'param\');',
234
- ` const row = await db.${clientKey}.delete(mergeWhere({ ${whereFromParams} }, policyWhere));`,
235
- ` return ${this.mutationJsonRow('row', constantPrefix)};`,
236
- '});',
237
233
  ];
234
+ if (modelHasPolicies || modelHasHooks) {
235
+ lines.push(' const auth = c.get(\'auth\');');
236
+ }
237
+ if (modelHasPolicies) {
238
+ lines.push(` const policy = assertPolicy('${this.model.name}', auth.role, 'delete');`, ' const policyWhere = resolvePolicyWhere(policy, auth);');
239
+ }
240
+ lines.push(' const params = c.req.valid(\'param\');');
241
+ if (modelHasHooks) {
242
+ lines.push(` const hookCtx = createHookContext({ c, db, auth, model: '${this.model.name}', operation: 'delete', params });`, ` const gate = await runBeforeHooks('${this.model.name}', 'delete', hookCtx);`, ' if (!gate.proceed) return gate.response ?? cancelledResponse(c);');
243
+ if (modelHasPolicies) {
244
+ lines.push(` const row = await db.${clientKey}.delete(mergeWhere({ ${whereFromParams} }, policyWhere));`);
245
+ }
246
+ else {
247
+ lines.push(` const row = await db.${clientKey}.delete({ ${whereFromParams} });`);
248
+ }
249
+ lines.push(' hookCtx.result = row;', ` await runAfterHooks('${this.model.name}', 'delete', hookCtx);`, ` return ${this.mutationJsonRow('hookCtx.result', constantPrefix)};`);
250
+ }
251
+ else if (modelHasPolicies) {
252
+ lines.push(` const row = await db.${clientKey}.delete(mergeWhere({ ${whereFromParams} }, policyWhere));`, ` return ${this.mutationJsonRow('row', constantPrefix)};`);
253
+ }
254
+ else {
255
+ lines.push(` const row = await db.${clientKey}.delete({ ${whereFromParams} });`, ` return ${this.mutationJsonRow('row', constantPrefix)};`);
256
+ }
257
+ lines.push('});');
258
+ return lines;
238
259
  }
239
260
  getRouteFileName() {
240
261
  return toRouteFileName(this.model.name);
@@ -243,10 +264,10 @@ export class RouteGenerator {
243
264
  return toRouteBasePath(this.model.name);
244
265
  }
245
266
  }
246
- export function generateRouteFiles(schema) {
267
+ export function generateRouteFiles(schema, modelsWithHooks = new Set()) {
247
268
  const files = new Map();
248
269
  for (const model of schema.models) {
249
- const generator = new RouteGenerator(model, schema);
270
+ const generator = new RouteGenerator(model, schema, modelsWithHooks);
250
271
  files.set(generator.getRouteFileName(), generator.generate());
251
272
  }
252
273
  return files;
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
+ }
@@ -6,3 +6,4 @@ export declare const TSCONFIG_TEMPLATE = "{\n \"compilerOptions\": {\n \"tar
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;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schematic-pg",
3
- "version": "0.1.7",
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",