schematic-pg 0.1.7 → 0.1.10
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 +237 -970
- package/dist/api/auth/jwt-crypto.d.ts +10 -0
- package/dist/api/auth/jwt-crypto.js +61 -0
- package/dist/api/auth/jwt-resolver.js +2 -27
- package/dist/api/auth/password/config.d.ts +32 -0
- package/dist/api/auth/password/config.js +35 -0
- package/dist/api/auth/password/errors.d.ts +8 -0
- package/dist/api/auth/password/errors.js +14 -0
- package/dist/api/auth/password/index.d.ts +3 -0
- package/dist/api/auth/password/index.js +3 -0
- package/dist/api/auth/password/password.d.ts +14 -0
- package/dist/api/auth/password/password.js +46 -0
- package/dist/api/auth/routes.d.ts +23 -0
- package/dist/api/auth/routes.js +118 -0
- package/dist/api/auth/token/config.d.ts +10 -0
- package/dist/api/auth/token/config.js +43 -0
- package/dist/api/auth/token/errors.d.ts +6 -0
- package/dist/api/auth/token/errors.js +12 -0
- package/dist/api/auth/token/index.d.ts +3 -0
- package/dist/api/auth/token/index.js +3 -0
- package/dist/api/auth/token/token.d.ts +11 -0
- package/dist/api/auth/token/token.js +31 -0
- 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/middleware/errors.js +11 -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 +9 -2
- 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/agents.md +290 -0
- package/dist/cli/templates.d.ts +6 -3
- package/dist/cli/templates.js +58 -6
- package/dist/cli/wait-for-database.js +1 -1
- package/dist/cli.js +10 -0
- package/dist/db/db-client-generator.js +20 -2
- package/dist/db/include/executor.d.ts +2 -2
- package/dist/db/include/executor.js +7 -7
- package/dist/db/include/json-agg.d.ts +2 -2
- package/dist/db/include/json-agg.js +4 -4
- package/dist/db/include/load.d.ts +3 -3
- package/dist/db/include/load.js +8 -8
- package/dist/db/index.d.ts +4 -0
- package/dist/db/index.js +2 -0
- package/dist/db/model-client.d.ts +2 -2
- package/dist/db/model-client.js +3 -3
- package/dist/db/queryable.d.ts +5 -0
- package/dist/db/queryable.js +1 -0
- package/dist/db/raw.d.ts +22 -0
- package/dist/db/raw.js +35 -0
- package/dist/db/transaction.d.ts +2 -0
- package/dist/db/transaction.js +24 -0
- package/dist/routes/auth.d.ts +3 -0
- package/dist/routes/auth.js +5 -0
- package/dist/types/generated-db.stub.d.ts +5 -1
- package/dist/types/generated-db.stub.js +8 -1
- package/package.json +11 -4
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const HOOK_CANCELLED_STATUS = 409;
|
|
2
|
+
const HOOK_CANCELLED_MESSAGE = 'Operation cancelled by lifecycle hook';
|
|
3
|
+
const BEFORE_HOOK_KEYS = {
|
|
4
|
+
create: 'beforeCreate',
|
|
5
|
+
update: 'beforeUpdate',
|
|
6
|
+
delete: 'beforeDelete',
|
|
7
|
+
};
|
|
8
|
+
const AFTER_HOOK_KEYS = {
|
|
9
|
+
create: 'afterCreate',
|
|
10
|
+
update: 'afterUpdate',
|
|
11
|
+
delete: 'afterDelete',
|
|
12
|
+
};
|
|
13
|
+
let hooks = {};
|
|
14
|
+
export function configureHooks(next) {
|
|
15
|
+
hooks = next;
|
|
16
|
+
}
|
|
17
|
+
export function createHookContext(init) {
|
|
18
|
+
return {
|
|
19
|
+
model: init.model,
|
|
20
|
+
operation: init.operation,
|
|
21
|
+
auth: init.auth,
|
|
22
|
+
params: init.params,
|
|
23
|
+
db: init.db,
|
|
24
|
+
c: init.c,
|
|
25
|
+
data: init.data ?? {},
|
|
26
|
+
result: init.result,
|
|
27
|
+
abort(status, message) {
|
|
28
|
+
return init.c.json({ error: message }, status);
|
|
29
|
+
},
|
|
30
|
+
json(body, status = 200) {
|
|
31
|
+
return init.c.json(body, status);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function cancelledResponse(c) {
|
|
36
|
+
return c.json({ error: HOOK_CANCELLED_MESSAGE }, HOOK_CANCELLED_STATUS);
|
|
37
|
+
}
|
|
38
|
+
export async function runBeforeHooks(model, operation, ctx) {
|
|
39
|
+
const modelHooks = hooks[model];
|
|
40
|
+
if (!modelHooks) {
|
|
41
|
+
return { proceed: true };
|
|
42
|
+
}
|
|
43
|
+
const hookDef = modelHooks[BEFORE_HOOK_KEYS[operation]];
|
|
44
|
+
if (!hookDef) {
|
|
45
|
+
return { proceed: true };
|
|
46
|
+
}
|
|
47
|
+
const hookList = normalizeHookList(hookDef);
|
|
48
|
+
let index = -1;
|
|
49
|
+
return dispatchBeforeHooks(hookList, ctx, 0, () => index, (nextIndex) => {
|
|
50
|
+
index = nextIndex;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export async function runAfterHooks(model, operation, ctx) {
|
|
54
|
+
const modelHooks = hooks[model];
|
|
55
|
+
if (!modelHooks) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const hookDef = modelHooks[AFTER_HOOK_KEYS[operation]];
|
|
59
|
+
if (!hookDef) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const hookList = normalizeHookList(hookDef);
|
|
63
|
+
for (const hook of hookList) {
|
|
64
|
+
await hook(ctx);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function normalizeHookList(hookDef) {
|
|
68
|
+
return Array.isArray(hookDef) ? hookDef : [hookDef];
|
|
69
|
+
}
|
|
70
|
+
async function dispatchBeforeHooks(hookList, ctx, currentIndex, getDispatchedIndex, setDispatchedIndex) {
|
|
71
|
+
if (currentIndex <= getDispatchedIndex()) {
|
|
72
|
+
throw new Error('next() called multiple times');
|
|
73
|
+
}
|
|
74
|
+
setDispatchedIndex(currentIndex);
|
|
75
|
+
if (currentIndex === hookList.length) {
|
|
76
|
+
return { proceed: true };
|
|
77
|
+
}
|
|
78
|
+
const hook = hookList[currentIndex];
|
|
79
|
+
let innerResult = { proceed: false };
|
|
80
|
+
let nextCalled = false;
|
|
81
|
+
const next = async () => {
|
|
82
|
+
nextCalled = true;
|
|
83
|
+
innerResult = await dispatchBeforeHooks(hookList, ctx, currentIndex + 1, getDispatchedIndex, setDispatchedIndex);
|
|
84
|
+
return innerResult;
|
|
85
|
+
};
|
|
86
|
+
const result = await hook(ctx, next);
|
|
87
|
+
if (result instanceof Response) {
|
|
88
|
+
return { proceed: false, response: result };
|
|
89
|
+
}
|
|
90
|
+
if (!nextCalled) {
|
|
91
|
+
return { proceed: false };
|
|
92
|
+
}
|
|
93
|
+
return innerResult;
|
|
94
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { DbClient } from 'generated/db.js';
|
|
3
|
+
import type { AuthContext } from '../auth/types.js';
|
|
4
|
+
import type { AppEnv } from '../types.js';
|
|
5
|
+
export type HookOperation = 'create' | 'update' | 'delete';
|
|
6
|
+
export interface HookContextBase {
|
|
7
|
+
model: string;
|
|
8
|
+
operation: HookOperation;
|
|
9
|
+
auth: AuthContext;
|
|
10
|
+
params?: Record<string, unknown>;
|
|
11
|
+
db: DbClient;
|
|
12
|
+
c: Context<AppEnv>;
|
|
13
|
+
abort: (status: number, message: string) => Response;
|
|
14
|
+
json: (body: unknown, status?: number) => Response;
|
|
15
|
+
}
|
|
16
|
+
export interface BeforeHookContext<TData = Record<string, unknown>> extends HookContextBase {
|
|
17
|
+
data: TData;
|
|
18
|
+
result?: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface AfterHookContext<TRow = Record<string, unknown>> extends HookContextBase {
|
|
21
|
+
result: TRow;
|
|
22
|
+
}
|
|
23
|
+
export type BeforeHookNext = () => Promise<BeforeHookResult>;
|
|
24
|
+
export type BeforeHook = (ctx: BeforeHookContext, next: BeforeHookNext) => Promise<Response | void>;
|
|
25
|
+
export type AfterHook = (ctx: AfterHookContext) => Promise<void>;
|
|
26
|
+
export interface ModelHooks {
|
|
27
|
+
beforeCreate?: BeforeHook | BeforeHook[];
|
|
28
|
+
afterCreate?: AfterHook | AfterHook[];
|
|
29
|
+
beforeUpdate?: BeforeHook | BeforeHook[];
|
|
30
|
+
afterUpdate?: AfterHook | AfterHook[];
|
|
31
|
+
beforeDelete?: BeforeHook | BeforeHook[];
|
|
32
|
+
afterDelete?: AfterHook | AfterHook[];
|
|
33
|
+
}
|
|
34
|
+
export type HookRegistry = Record<string, ModelHooks>;
|
|
35
|
+
export interface BeforeHookResult {
|
|
36
|
+
proceed: boolean;
|
|
37
|
+
response?: Response;
|
|
38
|
+
}
|
|
39
|
+
export interface CreateHookContextInput {
|
|
40
|
+
c: Context<AppEnv>;
|
|
41
|
+
db: DbClient;
|
|
42
|
+
auth: AuthContext;
|
|
43
|
+
model: string;
|
|
44
|
+
operation: HookOperation;
|
|
45
|
+
data?: Record<string, unknown>;
|
|
46
|
+
params?: Record<string, unknown>;
|
|
47
|
+
result?: unknown;
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { ForbiddenError, UnauthorizedError } from '../auth/errors.js';
|
|
2
|
+
import { InvalidPasswordInputError, MissingAuthPepperError, } from '../auth/password/errors.js';
|
|
3
|
+
import { InvalidTokenTtlError, MissingJwtSecretError } from '../auth/token/errors.js';
|
|
2
4
|
import { DatabaseError, ForeignKeyConstraintError, UniqueConstraintError, } from '../../db/errors.js';
|
|
3
5
|
export const handleError = (error, c) => {
|
|
4
6
|
if (error instanceof UnauthorizedError) {
|
|
@@ -13,6 +15,15 @@ export const handleError = (error, c) => {
|
|
|
13
15
|
if (error instanceof ForeignKeyConstraintError) {
|
|
14
16
|
return c.json({ error: error.message }, 400);
|
|
15
17
|
}
|
|
18
|
+
if (error instanceof InvalidPasswordInputError) {
|
|
19
|
+
return c.json({ error: error.message }, 400);
|
|
20
|
+
}
|
|
21
|
+
if (error instanceof InvalidTokenTtlError) {
|
|
22
|
+
return c.json({ error: error.message }, 500);
|
|
23
|
+
}
|
|
24
|
+
if (error instanceof MissingAuthPepperError || error instanceof MissingJwtSecretError) {
|
|
25
|
+
return c.json({ error: error.message }, 500);
|
|
26
|
+
}
|
|
16
27
|
if (error instanceof DatabaseError) {
|
|
17
28
|
return c.json({ error: error.message }, 500);
|
|
18
29
|
}
|
|
@@ -39,7 +39,9 @@ export class AppGenerator {
|
|
|
39
39
|
routeImports,
|
|
40
40
|
"import { createDbClient } from './db.js';",
|
|
41
41
|
"import { POLICIES } from './policies.js';",
|
|
42
|
+
"import { HOOKS } from './hooks.js';",
|
|
42
43
|
`import { configurePolicies } from '${PACKAGE_NAME}/api/auth/policy';`,
|
|
44
|
+
`import { configureHooks } from '${PACKAGE_NAME}/api/hooks';`,
|
|
43
45
|
`import { createAuthMiddleware } from '${PACKAGE_NAME}/api/auth/middleware';`,
|
|
44
46
|
`import { createJwtResolver } from '${PACKAGE_NAME}/api/auth/jwt-resolver';`,
|
|
45
47
|
`import type { AuthResolver } from '${PACKAGE_NAME}/api/auth/types';`,
|
|
@@ -48,6 +50,7 @@ export class AppGenerator {
|
|
|
48
50
|
`import type { AppEnv } from '${PACKAGE_NAME}/api/types';`,
|
|
49
51
|
'',
|
|
50
52
|
'configurePolicies(POLICIES);',
|
|
53
|
+
'configureHooks(HOOKS);',
|
|
51
54
|
'',
|
|
52
55
|
'export interface CreateAppOptions {',
|
|
53
56
|
' pool?: Pool;',
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Schema } from '../schema-dsl/ast.js';
|
|
2
|
+
export interface HookMountEntry {
|
|
3
|
+
modelName: string;
|
|
4
|
+
importName: string;
|
|
5
|
+
importPath: string;
|
|
6
|
+
}
|
|
7
|
+
export interface HookDiscoveryResult {
|
|
8
|
+
entries: HookMountEntry[];
|
|
9
|
+
modelsWithHooks: Set<string>;
|
|
10
|
+
}
|
|
11
|
+
export declare function discoverHooks(hooksDir: string, schema: Schema): HookDiscoveryResult;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
function isHookFile(filename) {
|
|
3
|
+
return (filename.endsWith('.ts') &&
|
|
4
|
+
!filename.endsWith('.test.ts') &&
|
|
5
|
+
!filename.endsWith('.d.ts') &&
|
|
6
|
+
!filename.startsWith('_'));
|
|
7
|
+
}
|
|
8
|
+
function toHookImportName(modelName) {
|
|
9
|
+
return `${modelName.charAt(0).toLowerCase()}${modelName.slice(1)}Hooks`;
|
|
10
|
+
}
|
|
11
|
+
export function discoverHooks(hooksDir, schema) {
|
|
12
|
+
if (!existsSync(hooksDir)) {
|
|
13
|
+
return { entries: [], modelsWithHooks: new Set() };
|
|
14
|
+
}
|
|
15
|
+
const modelNames = new Set(schema.models.map((model) => model.name));
|
|
16
|
+
const entries = [];
|
|
17
|
+
const modelsWithHooks = new Set();
|
|
18
|
+
for (const filename of readdirSync(hooksDir)) {
|
|
19
|
+
if (!isHookFile(filename)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const modelName = filename.replace(/\.ts$/, '');
|
|
23
|
+
if (!modelNames.has(modelName)) {
|
|
24
|
+
console.warn(`Skipping hook file "${filename}": no matching model in schema`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
entries.push({
|
|
28
|
+
modelName,
|
|
29
|
+
importName: toHookImportName(modelName),
|
|
30
|
+
importPath: `../src/hooks/${modelName}.js`,
|
|
31
|
+
});
|
|
32
|
+
modelsWithHooks.add(modelName);
|
|
33
|
+
}
|
|
34
|
+
entries.sort((left, right) => left.modelName.localeCompare(right.modelName));
|
|
35
|
+
return { entries, modelsWithHooks };
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function generateHooksFile(entries) {
|
|
2
|
+
if (entries.length === 0) {
|
|
3
|
+
return [
|
|
4
|
+
'// Auto-generated by HooksGenerator. Do not edit manually.',
|
|
5
|
+
'',
|
|
6
|
+
'export const HOOKS = {};',
|
|
7
|
+
'',
|
|
8
|
+
].join('\n');
|
|
9
|
+
}
|
|
10
|
+
const imports = entries
|
|
11
|
+
.map((entry) => `import ${entry.importName} from '${entry.importPath}';`)
|
|
12
|
+
.join('\n');
|
|
13
|
+
const registryEntries = entries
|
|
14
|
+
.map((entry) => ` ${entry.modelName}: ${entry.importName},`)
|
|
15
|
+
.join('\n');
|
|
16
|
+
return [
|
|
17
|
+
'// Auto-generated by HooksGenerator. Do not edit manually.',
|
|
18
|
+
imports,
|
|
19
|
+
'',
|
|
20
|
+
'export const HOOKS = {',
|
|
21
|
+
registryEntries,
|
|
22
|
+
'};',
|
|
23
|
+
'',
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
@@ -3,9 +3,11 @@ export interface GeneratedApiFiles {
|
|
|
3
3
|
app: string;
|
|
4
4
|
policies: string;
|
|
5
5
|
validation: string;
|
|
6
|
+
hooks: string;
|
|
6
7
|
routes: Map<string, string>;
|
|
7
8
|
}
|
|
8
9
|
export interface GenerateApiFilesOptions {
|
|
9
10
|
customRoutesDir?: string;
|
|
11
|
+
hooksDir?: string;
|
|
10
12
|
}
|
|
11
13
|
export declare function generateApiFiles(schema: Schema, options?: GenerateApiFilesOptions): GeneratedApiFiles;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { DEFAULT_HOOKS_DIR } from '../cli/paths.js';
|
|
1
2
|
import { generateAppFile } from './app-generator.js';
|
|
3
|
+
import { discoverHooks } from './hook-scanner.js';
|
|
4
|
+
import { generateHooksFile } from './hooks-generator.js';
|
|
2
5
|
import { generatePoliciesFile } from './policy-generator.js';
|
|
3
6
|
import { generateRouteFiles } from './route-generator.js';
|
|
4
7
|
import { generateValidationSchemas } from './zod-schema-generator.js';
|
|
@@ -6,10 +9,13 @@ export function generateApiFiles(schema, options) {
|
|
|
6
9
|
const appOptions = options?.customRoutesDir
|
|
7
10
|
? { customRoutesDir: options.customRoutesDir }
|
|
8
11
|
: undefined;
|
|
12
|
+
const hooksDir = options?.hooksDir ?? DEFAULT_HOOKS_DIR;
|
|
13
|
+
const { entries: hookEntries, modelsWithHooks } = discoverHooks(hooksDir, schema);
|
|
9
14
|
return {
|
|
10
15
|
app: generateAppFile(schema, appOptions),
|
|
11
16
|
policies: generatePoliciesFile(schema),
|
|
12
17
|
validation: generateValidationSchemas(schema),
|
|
13
|
-
|
|
18
|
+
hooks: generateHooksFile(hookEntries),
|
|
19
|
+
routes: generateRouteFiles(schema, modelsWithHooks),
|
|
14
20
|
};
|
|
15
21
|
}
|
|
@@ -2,7 +2,8 @@ import type { Model, Schema } from '../schema-dsl/ast.js';
|
|
|
2
2
|
export declare class RouteGenerator {
|
|
3
3
|
private readonly model;
|
|
4
4
|
private readonly schema;
|
|
5
|
-
|
|
5
|
+
private readonly modelsWithHooks;
|
|
6
|
+
constructor(model: Model, schema: Schema, modelsWithHooks?: ReadonlySet<string>);
|
|
6
7
|
generate(): string;
|
|
7
8
|
private jsonRow;
|
|
8
9
|
private jsonRows;
|
|
@@ -15,7 +16,7 @@ export declare class RouteGenerator {
|
|
|
15
16
|
getRouteFileName(): string;
|
|
16
17
|
getRouteBasePath(): string;
|
|
17
18
|
}
|
|
18
|
-
export declare function generateRouteFiles(schema: Schema): Map<string, string>;
|
|
19
|
+
export declare function generateRouteFiles(schema: Schema, modelsWithHooks?: ReadonlySet<string>): Map<string, string>;
|
|
19
20
|
export declare function getRouteMountEntries(schema: Schema): {
|
|
20
21
|
basePath: string;
|
|
21
22
|
fileName: string;
|
|
@@ -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');
|