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.
- package/README.md +227 -8
- package/dist/api/hooks/define.d.ts +10 -0
- package/dist/api/hooks/define.js +3 -0
- package/dist/api/hooks/index.d.ts +4 -0
- package/dist/api/hooks/index.js +2 -0
- package/dist/api/hooks/registry.d.ts +8 -0
- package/dist/api/hooks/registry.js +94 -0
- package/dist/api/hooks/types.d.ts +48 -0
- package/dist/api/hooks/types.js +1 -0
- package/dist/api-generator/app-generator.js +3 -0
- package/dist/api-generator/hook-scanner.d.ts +11 -0
- package/dist/api-generator/hook-scanner.js +36 -0
- package/dist/api-generator/hooks-generator.d.ts +2 -0
- package/dist/api-generator/hooks-generator.js +25 -0
- package/dist/api-generator/index.d.ts +2 -0
- package/dist/api-generator/index.js +7 -1
- package/dist/api-generator/route-generator.d.ts +3 -2
- package/dist/api-generator/route-generator.js +85 -64
- package/dist/cli/dev.js +5 -36
- package/dist/cli/generate.js +1 -0
- package/dist/cli/hooks.d.ts +6 -0
- package/dist/cli/hooks.js +85 -0
- package/dist/cli/init.js +6 -1
- package/dist/cli/paths.d.ts +1 -0
- package/dist/cli/paths.js +1 -0
- package/dist/cli/server.d.ts +5 -0
- package/dist/cli/server.js +60 -0
- package/dist/cli/start.d.ts +7 -0
- package/dist/cli/start.js +35 -0
- package/dist/cli/templates.d.ts +1 -0
- package/dist/cli/templates.js +36 -0
- package/dist/cli.js +10 -0
- package/package.json +7 -1
|
@@ -7,9 +7,11 @@ import { hasPolicies } from './utils/policy.js';
|
|
|
7
7
|
export class RouteGenerator {
|
|
8
8
|
model;
|
|
9
9
|
schema;
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
serverProcess =
|
|
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
|
|
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
|
|
106
|
+
await waitForAppServerExit(serverProcess);
|
|
138
107
|
}
|
|
139
108
|
return;
|
|
140
109
|
}
|
package/dist/cli/generate.js
CHANGED
|
@@ -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,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(' #
|
|
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`);
|
package/dist/cli/paths.d.ts
CHANGED
|
@@ -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,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
|
+
}
|
package/dist/cli/templates.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/templates.js
CHANGED
|
@@ -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.
|
|
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",
|