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.
- package/README.md +256 -11
- 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/utils/include-query.d.ts +6 -0
- package/dist/api/utils/include-query.js +75 -0
- package/dist/api/utils/read-query.d.ts +12 -0
- package/dist/api/utils/read-query.js +10 -0
- package/dist/api/utils/response-shape.d.ts +2 -0
- package/dist/api/utils/response-shape.js +28 -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 +4 -2
- package/dist/api-generator/route-generator.js +132 -82
- package/dist/api-generator/utils/api-fields.d.ts +6 -0
- package/dist/api-generator/utils/api-fields.js +32 -0
- package/dist/api-generator/zod-schema-generator.d.ts +3 -0
- package/dist/api-generator/zod-schema-generator.js +66 -8
- 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 +2 -1
- package/dist/cli/templates.js +37 -1
- package/dist/cli.js +10 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- 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.
|
|
102
|
+
' if (data.include === undefined) {',
|
|
71
103
|
' return;',
|
|
72
104
|
' }',
|
|
73
|
-
|
|
74
|
-
'
|
|
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:
|
|
79
|
-
' path: [\'
|
|
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
|
|
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
|
@@ -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
|
|
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;
|
package/dist/cli/templates.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/constants.d.ts
CHANGED
package/dist/constants.js
CHANGED
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",
|