create-projx 1.7.0 → 1.7.2
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 +13 -35
- package/dist/{baseline-FHOZNS4D.js → baseline-ZPPJKHBN.js} +2 -2
- package/dist/{chunk-HAT7D4G2.js → chunk-FQPOK3QZ.js} +10 -3
- package/dist/{chunk-IMZKHDIL.js → chunk-XAYCVTHL.js} +15 -18
- package/dist/index.js +166 -385
- package/dist/{utils-BZGSJ7XZ.js → utils-MC7VKL2U.js} +1 -1
- package/package.json +2 -3
- package/src/templates/README.md.ejs +1 -1
- package/src/templates/ci.yml.ejs +14 -15
- package/src/templates/pre-commit.ejs +13 -1
- package/src/addons/orms/drizzle/express/src/app.ts +0 -81
- package/src/addons/orms/drizzle/express/src/modules/_base/auto-routes.ts +0 -278
- package/src/addons/orms/drizzle/express/src/modules/_base/index.ts +0 -20
- package/src/addons/orms/drizzle/express/src/server.ts +0 -32
- package/src/addons/orms/drizzle/express/tests/app.test.ts +0 -24
- package/src/addons/orms/drizzle/express/vitest.config.ts +0 -20
- package/src/addons/orms/drizzle/fastify/src/app.ts +0 -90
- package/src/addons/orms/drizzle/fastify/src/modules/_base/auto-routes.ts +0 -268
- package/src/addons/orms/drizzle/fastify/src/modules/_base/index.ts +0 -20
- package/src/addons/orms/drizzle/fastify/tests/modules/app.test.ts +0 -20
- package/src/addons/orms/drizzle/fastify/vitest.config.ts +0 -31
- package/src/addons/orms/drizzle/gen-entity/express-router.ts +0 -21
- package/src/addons/orms/drizzle/gen-entity/express-test.ts +0 -61
- package/src/addons/orms/drizzle/gen-entity/fastify-router.ts +0 -19
- package/src/addons/orms/drizzle/gen-entity/fastify-test.ts +0 -87
- package/src/addons/orms/drizzle/manifest.json +0 -52
- package/src/addons/orms/drizzle/shared/drizzle.config.ts +0 -12
- package/src/addons/orms/drizzle/shared/src/db/client.ts +0 -17
- package/src/addons/orms/drizzle/shared/src/db/schema.ts +0 -14
- package/src/addons/orms/drizzle/shared/src/modules/_base/query-engine.ts +0 -115
- package/src/addons/orms/drizzle/shared/src/modules/_base/registry.ts +0 -15
- package/src/addons/orms/sequelize/express/src/app.ts +0 -82
- package/src/addons/orms/sequelize/express/src/modules/_base/auto-routes.ts +0 -226
- package/src/addons/orms/sequelize/express/src/modules/_base/index.ts +0 -20
- package/src/addons/orms/sequelize/express/src/server.ts +0 -32
- package/src/addons/orms/sequelize/express/tests/app.test.ts +0 -24
- package/src/addons/orms/sequelize/express/vitest.config.ts +0 -20
- package/src/addons/orms/sequelize/fastify/src/app.ts +0 -83
- package/src/addons/orms/sequelize/fastify/src/modules/_base/auto-routes.ts +0 -216
- package/src/addons/orms/sequelize/fastify/src/modules/_base/index.ts +0 -20
- package/src/addons/orms/sequelize/fastify/tests/modules/app.test.ts +0 -20
- package/src/addons/orms/sequelize/fastify/vitest.config.ts +0 -31
- package/src/addons/orms/sequelize/gen-entity/express-router.ts +0 -17
- package/src/addons/orms/sequelize/gen-entity/express-test.ts +0 -65
- package/src/addons/orms/sequelize/gen-entity/fastify-router.ts +0 -19
- package/src/addons/orms/sequelize/gen-entity/fastify-test.ts +0 -89
- package/src/addons/orms/sequelize/gen-entity/model.ts +0 -21
- package/src/addons/orms/sequelize/manifest.json +0 -53
- package/src/addons/orms/sequelize/shared/scripts/db-sync.ts +0 -14
- package/src/addons/orms/sequelize/shared/src/db/client.ts +0 -19
- package/src/addons/orms/sequelize/shared/src/models/index.ts +0 -9
- package/src/addons/orms/sequelize/shared/src/modules/_base/query-engine.ts +0 -101
- package/src/addons/orms/sequelize/shared/src/modules/_base/registry.ts +0 -15
- package/src/addons/orms/typeorm/express/src/app.ts +0 -82
- package/src/addons/orms/typeorm/express/src/modules/_base/auto-routes.ts +0 -249
- package/src/addons/orms/typeorm/express/src/modules/_base/index.ts +0 -19
- package/src/addons/orms/typeorm/express/src/server.ts +0 -43
- package/src/addons/orms/typeorm/express/tests/app.test.ts +0 -24
- package/src/addons/orms/typeorm/express/vitest.config.ts +0 -20
- package/src/addons/orms/typeorm/fastify/src/app.ts +0 -86
- package/src/addons/orms/typeorm/fastify/src/modules/_base/auto-routes.ts +0 -239
- package/src/addons/orms/typeorm/fastify/src/modules/_base/index.ts +0 -19
- package/src/addons/orms/typeorm/fastify/tests/modules/app.test.ts +0 -20
- package/src/addons/orms/typeorm/fastify/vitest.config.ts +0 -31
- package/src/addons/orms/typeorm/gen-entity/entity.ts +0 -21
- package/src/addons/orms/typeorm/gen-entity/express-router.ts +0 -17
- package/src/addons/orms/typeorm/gen-entity/express-test.ts +0 -66
- package/src/addons/orms/typeorm/gen-entity/fastify-router.ts +0 -19
- package/src/addons/orms/typeorm/gen-entity/fastify-test.ts +0 -89
- package/src/addons/orms/typeorm/manifest.json +0 -53
- package/src/addons/orms/typeorm/shared/scripts/db-sync.ts +0 -14
- package/src/addons/orms/typeorm/shared/src/db/data-source.ts +0 -21
- package/src/addons/orms/typeorm/shared/src/entities/index.ts +0 -8
- package/src/addons/orms/typeorm/shared/src/modules/_base/query-engine.ts +0 -94
- package/src/addons/orms/typeorm/shared/src/modules/_base/registry.ts +0 -15
- package/src/addons/orms/typeorm/shared/tsconfig.json +0 -16
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
2
|
-
import { Pool } from 'pg';
|
|
3
|
-
import { config } from '../config.js';
|
|
4
|
-
import * as schema from './schema.js';
|
|
5
|
-
|
|
6
|
-
export const pool = new Pool({ connectionString: config.DATABASE_URL });
|
|
7
|
-
export const db = drizzle(pool, { schema });
|
|
8
|
-
|
|
9
|
-
export async function checkDatabase(): Promise<void> {
|
|
10
|
-
await pool.query('SELECT 1');
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export async function closeDatabase(): Promise<void> {
|
|
14
|
-
await pool.end();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export type DbClient = typeof db;
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
2
|
-
|
|
3
|
-
export const auditLogs = pgTable('audit_logs', {
|
|
4
|
-
id: uuid('id').primaryKey().defaultRandom(),
|
|
5
|
-
tableName: text('table_name').notNull(),
|
|
6
|
-
recordId: text('record_id').notNull(),
|
|
7
|
-
action: text('action').notNull(),
|
|
8
|
-
oldValue: jsonb('old_value'),
|
|
9
|
-
newValue: jsonb('new_value'),
|
|
10
|
-
performedBy: text('performed_by').notNull().default('system'),
|
|
11
|
-
createdAt: timestamp('created_at', { withTimezone: true })
|
|
12
|
-
.notNull()
|
|
13
|
-
.defaultNow(),
|
|
14
|
-
});
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { type SQL, and, asc, desc, eq, ilike, or } from 'drizzle-orm';
|
|
2
|
-
import type { PgTable } from 'drizzle-orm/pg-core';
|
|
3
|
-
|
|
4
|
-
export interface ParsedQuery {
|
|
5
|
-
page: number;
|
|
6
|
-
page_size: number;
|
|
7
|
-
order_by?: string;
|
|
8
|
-
search?: string;
|
|
9
|
-
filters: Record<string, string>;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const RESERVED = new Set(['page', 'page_size', 'order_by', 'search']);
|
|
13
|
-
|
|
14
|
-
export function parseRawQuery(rawQs: string): ParsedQuery {
|
|
15
|
-
const params = new URLSearchParams(rawQs);
|
|
16
|
-
const page = Math.max(1, Number(params.get('page')) || 1);
|
|
17
|
-
const page_size = Math.min(
|
|
18
|
-
100,
|
|
19
|
-
Math.max(1, Number(params.get('page_size')) || 10),
|
|
20
|
-
);
|
|
21
|
-
const filters: Record<string, string> = {};
|
|
22
|
-
for (const [key, value] of params.entries()) {
|
|
23
|
-
if (RESERVED.has(key)) continue;
|
|
24
|
-
filters[key] = value;
|
|
25
|
-
}
|
|
26
|
-
return {
|
|
27
|
-
page,
|
|
28
|
-
page_size,
|
|
29
|
-
order_by: params.get('order_by') ?? undefined,
|
|
30
|
-
search: params.get('search') ?? undefined,
|
|
31
|
-
filters,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function column(table: PgTable, key: string): unknown {
|
|
36
|
-
return (table as unknown as Record<string, unknown>)[key];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function buildWhere(
|
|
40
|
-
table: PgTable,
|
|
41
|
-
filters: Record<string, string>,
|
|
42
|
-
): SQL | undefined {
|
|
43
|
-
const clauses: SQL[] = [];
|
|
44
|
-
for (const [key, value] of Object.entries(filters)) {
|
|
45
|
-
const col = column(table, key);
|
|
46
|
-
if (!col) continue;
|
|
47
|
-
clauses.push(eq(col as Parameters<typeof eq>[0], value));
|
|
48
|
-
}
|
|
49
|
-
if (clauses.length === 0) return undefined;
|
|
50
|
-
if (clauses.length === 1) return clauses[0];
|
|
51
|
-
return and(...clauses);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function buildSearchWhere(
|
|
55
|
-
table: PgTable,
|
|
56
|
-
searchableFields: string[],
|
|
57
|
-
term: string | undefined,
|
|
58
|
-
): SQL | undefined {
|
|
59
|
-
if (!term) return undefined;
|
|
60
|
-
const trimmed = term.trim();
|
|
61
|
-
if (!trimmed) return undefined;
|
|
62
|
-
const pattern = `%${trimmed}%`;
|
|
63
|
-
const clauses: SQL[] = [];
|
|
64
|
-
for (const field of searchableFields) {
|
|
65
|
-
const col = column(table, field);
|
|
66
|
-
if (!col) continue;
|
|
67
|
-
clauses.push(ilike(col as Parameters<typeof ilike>[0], pattern));
|
|
68
|
-
}
|
|
69
|
-
if (clauses.length === 0) return undefined;
|
|
70
|
-
if (clauses.length === 1) return clauses[0];
|
|
71
|
-
return or(...clauses);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function buildOrderBy(
|
|
75
|
-
table: PgTable,
|
|
76
|
-
orderBy: string | undefined,
|
|
77
|
-
): SQL[] {
|
|
78
|
-
if (!orderBy) return [];
|
|
79
|
-
const out: SQL[] = [];
|
|
80
|
-
for (const raw of orderBy.split(',')) {
|
|
81
|
-
const term = raw.trim();
|
|
82
|
-
if (!term) continue;
|
|
83
|
-
const descOrder = term.startsWith('-');
|
|
84
|
-
const fieldName = descOrder ? term.slice(1) : term;
|
|
85
|
-
const col = column(table, fieldName);
|
|
86
|
-
if (!col) continue;
|
|
87
|
-
out.push((descOrder ? desc : asc)(col as Parameters<typeof asc>[0]));
|
|
88
|
-
}
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function combineWhere(a?: SQL, b?: SQL): SQL | undefined {
|
|
93
|
-
if (a && b) return and(a, b);
|
|
94
|
-
return a ?? b;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface PaginationMeta {
|
|
98
|
-
current_page: number;
|
|
99
|
-
page_size: number;
|
|
100
|
-
total_records: number;
|
|
101
|
-
total_pages: number;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function buildPagination(
|
|
105
|
-
page: number,
|
|
106
|
-
page_size: number,
|
|
107
|
-
total: number,
|
|
108
|
-
): PaginationMeta {
|
|
109
|
-
return {
|
|
110
|
-
current_page: page,
|
|
111
|
-
page_size,
|
|
112
|
-
total_records: total,
|
|
113
|
-
total_pages: Math.max(1, Math.ceil(total / page_size)),
|
|
114
|
-
};
|
|
115
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export interface RegisteredEntity {
|
|
2
|
-
name: string;
|
|
3
|
-
apiPrefix: string;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
const entityList: RegisteredEntity[] = [];
|
|
7
|
-
|
|
8
|
-
export function registerInRegistry(entry: RegisteredEntity): void {
|
|
9
|
-
if (entityList.some((existing) => existing.name === entry.name)) return;
|
|
10
|
-
entityList.push(entry);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function listEntities(): RegisteredEntity[] {
|
|
14
|
-
return [...entityList].sort((a, b) => a.name.localeCompare(b.name));
|
|
15
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import compression from 'compression';
|
|
3
|
-
import cors from 'cors';
|
|
4
|
-
import express, { type RequestHandler } from 'express';
|
|
5
|
-
import rateLimit from 'express-rate-limit';
|
|
6
|
-
import helmet from 'helmet';
|
|
7
|
-
import pinoHttp from 'pino-http';
|
|
8
|
-
import { allowedOrigins, config } from './config.js';
|
|
9
|
-
import { ApiError, errorHandler, notFoundHandler } from './errors.js';
|
|
10
|
-
import { checkDatabase, sequelize } from './db/client.js';
|
|
11
|
-
import './models/index.js';
|
|
12
|
-
import { listEntities } from './modules/_base/index.js';
|
|
13
|
-
// projx-anchor: entity-imports
|
|
14
|
-
|
|
15
|
-
const requestId: RequestHandler = (req, res, next) => {
|
|
16
|
-
const incoming = req.headers['x-request-id'];
|
|
17
|
-
const value =
|
|
18
|
-
typeof incoming === 'string' && incoming.trim()
|
|
19
|
-
? incoming
|
|
20
|
-
: crypto.randomUUID();
|
|
21
|
-
res.locals.requestId = value;
|
|
22
|
-
res.setHeader('x-request-id', value);
|
|
23
|
-
next();
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
function corsOrigin(
|
|
27
|
-
origin: string | undefined,
|
|
28
|
-
callback: (err: Error | null, allow?: boolean) => void,
|
|
29
|
-
): void {
|
|
30
|
-
const origins = allowedOrigins();
|
|
31
|
-
if (!origin || origins.includes('*') || origins.includes(origin)) {
|
|
32
|
-
callback(null, true);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
callback(new ApiError(403, 'Origin not allowed', 'origin_not_allowed'));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function buildApp(): express.Express {
|
|
39
|
-
const app = express();
|
|
40
|
-
|
|
41
|
-
app.disable('x-powered-by');
|
|
42
|
-
app.locals.sequelize = sequelize;
|
|
43
|
-
app.use(requestId);
|
|
44
|
-
app.use(pinoHttp({ level: config.LOG_LEVEL }));
|
|
45
|
-
app.use(helmet());
|
|
46
|
-
app.use(cors({ origin: corsOrigin, credentials: true }));
|
|
47
|
-
app.use(compression());
|
|
48
|
-
app.use(express.json({ limit: '1mb' }));
|
|
49
|
-
app.use(express.urlencoded({ extended: false, limit: '1mb' }));
|
|
50
|
-
app.use(
|
|
51
|
-
rateLimit({
|
|
52
|
-
windowMs: config.RATE_LIMIT_WINDOW_MS,
|
|
53
|
-
limit: config.RATE_LIMIT_MAX,
|
|
54
|
-
standardHeaders: 'draft-8',
|
|
55
|
-
legacyHeaders: false,
|
|
56
|
-
}),
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
app.get('/api/health', async (_req, res) => {
|
|
60
|
-
const checks: Record<string, string> = { app: 'ok' };
|
|
61
|
-
try {
|
|
62
|
-
await checkDatabase();
|
|
63
|
-
checks.database = 'ok';
|
|
64
|
-
} catch (e) {
|
|
65
|
-
checks.database = `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
66
|
-
res.status(503).json({ status: 'unhealthy', checks });
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
res.json({ status: 'healthy', checks });
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
app.get('/api/v1/_meta', (_req, res) => {
|
|
73
|
-
res.json({ entities: listEntities(), orm: 'sequelize' });
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// projx-anchor: entity-registrations
|
|
77
|
-
|
|
78
|
-
app.use(notFoundHandler);
|
|
79
|
-
app.use(errorHandler);
|
|
80
|
-
|
|
81
|
-
return app;
|
|
82
|
-
}
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
import express, {
|
|
2
|
-
type NextFunction,
|
|
3
|
-
type Request,
|
|
4
|
-
type Response,
|
|
5
|
-
} from 'express';
|
|
6
|
-
import type { Model, ModelStatic } from 'sequelize';
|
|
7
|
-
import { ApiError } from '../../errors.js';
|
|
8
|
-
import { registerInRegistry } from './registry.js';
|
|
9
|
-
import {
|
|
10
|
-
buildOrder,
|
|
11
|
-
buildPagination,
|
|
12
|
-
buildSearchWhere,
|
|
13
|
-
buildWhere,
|
|
14
|
-
combineWhere,
|
|
15
|
-
parseRawQuery,
|
|
16
|
-
} from './query-engine.js';
|
|
17
|
-
|
|
18
|
-
export type BeforeCreateHook = (
|
|
19
|
-
request: Request,
|
|
20
|
-
data: Record<string, unknown>,
|
|
21
|
-
) => void | Promise<void>;
|
|
22
|
-
export type AfterCreateHook = (
|
|
23
|
-
request: Request,
|
|
24
|
-
record: Record<string, unknown>,
|
|
25
|
-
) => void | Promise<void>;
|
|
26
|
-
export type BeforeUpdateHook = (
|
|
27
|
-
request: Request,
|
|
28
|
-
response: Response,
|
|
29
|
-
data: Record<string, unknown>,
|
|
30
|
-
) => void | Promise<void>;
|
|
31
|
-
export type AfterUpdateHook = (
|
|
32
|
-
request: Request,
|
|
33
|
-
before: Record<string, unknown>,
|
|
34
|
-
after: Record<string, unknown>,
|
|
35
|
-
) => void | Promise<void>;
|
|
36
|
-
export type BeforeDeleteHook = (
|
|
37
|
-
request: Request,
|
|
38
|
-
recordId: string,
|
|
39
|
-
) => void | Promise<void>;
|
|
40
|
-
|
|
41
|
-
export interface SequelizeEntityConfig {
|
|
42
|
-
name: string;
|
|
43
|
-
apiPrefix: string;
|
|
44
|
-
tag: string;
|
|
45
|
-
model: ModelStatic<Model>;
|
|
46
|
-
primaryKey?: string;
|
|
47
|
-
searchableFields?: string[];
|
|
48
|
-
readonly?: boolean;
|
|
49
|
-
bulkOperations?: boolean;
|
|
50
|
-
beforeCreate?: BeforeCreateHook;
|
|
51
|
-
afterCreate?: AfterCreateHook;
|
|
52
|
-
beforeUpdate?: BeforeUpdateHook;
|
|
53
|
-
afterUpdate?: AfterUpdateHook;
|
|
54
|
-
beforeDelete?: BeforeDeleteHook;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
type AsyncHandler = (
|
|
58
|
-
req: Request,
|
|
59
|
-
res: Response,
|
|
60
|
-
next: NextFunction,
|
|
61
|
-
) => Promise<void>;
|
|
62
|
-
|
|
63
|
-
function asyncHandler(handler: AsyncHandler) {
|
|
64
|
-
return (req: Request, res: Response, next: NextFunction): void => {
|
|
65
|
-
handler(req, res, next).catch(next);
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function attributes(model: ModelStatic<Model>): Set<string> {
|
|
70
|
-
return new Set(Object.keys(model.getAttributes()));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function registerEntityRoutes(
|
|
74
|
-
config: SequelizeEntityConfig,
|
|
75
|
-
): express.Router {
|
|
76
|
-
registerInRegistry({ name: config.name, apiPrefix: config.apiPrefix });
|
|
77
|
-
const router = express.Router();
|
|
78
|
-
const pk = config.primaryKey ?? 'id';
|
|
79
|
-
const attrs = attributes(config.model);
|
|
80
|
-
|
|
81
|
-
router.get(
|
|
82
|
-
'/',
|
|
83
|
-
asyncHandler(async (req, res) => {
|
|
84
|
-
const rawQs = req.originalUrl.split('?')[1] ?? '';
|
|
85
|
-
const query = parseRawQuery(rawQs);
|
|
86
|
-
const filterWhere = buildWhere(attrs, query.filters);
|
|
87
|
-
const searchWhere = buildSearchWhere(
|
|
88
|
-
config.searchableFields ?? [],
|
|
89
|
-
query.search,
|
|
90
|
-
);
|
|
91
|
-
const where = combineWhere(filterWhere, searchWhere);
|
|
92
|
-
const order = buildOrder(attrs, query.order_by);
|
|
93
|
-
const { rows, count } = await config.model.findAndCountAll({
|
|
94
|
-
where,
|
|
95
|
-
order,
|
|
96
|
-
limit: query.page_size,
|
|
97
|
-
offset: (query.page - 1) * query.page_size,
|
|
98
|
-
});
|
|
99
|
-
res.json({
|
|
100
|
-
data: rows.map((r) => r.toJSON()),
|
|
101
|
-
pagination: buildPagination(query.page, query.page_size, count),
|
|
102
|
-
});
|
|
103
|
-
}),
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
router.get(
|
|
107
|
-
'/:id',
|
|
108
|
-
asyncHandler(async (req, res) => {
|
|
109
|
-
const record = await config.model.findOne({
|
|
110
|
-
where: { [pk]: String(req.params.id) },
|
|
111
|
-
});
|
|
112
|
-
if (!record) throw new ApiError(404, 'Not found', 'not_found');
|
|
113
|
-
res.json(record.toJSON());
|
|
114
|
-
}),
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
if (config.readonly) return router;
|
|
118
|
-
|
|
119
|
-
router.post(
|
|
120
|
-
'/',
|
|
121
|
-
asyncHandler(async (req, res) => {
|
|
122
|
-
const data = req.body as Record<string, unknown>;
|
|
123
|
-
await config.beforeCreate?.(req, data);
|
|
124
|
-
const created = await config.model.create(data);
|
|
125
|
-
const record = created.toJSON() as Record<string, unknown>;
|
|
126
|
-
if (config.afterCreate) {
|
|
127
|
-
try {
|
|
128
|
-
await config.afterCreate(req, record);
|
|
129
|
-
} catch (err) {
|
|
130
|
-
req.log?.error?.(
|
|
131
|
-
{
|
|
132
|
-
err,
|
|
133
|
-
entity: config.name,
|
|
134
|
-
record_id: (record as { id?: string }).id,
|
|
135
|
-
},
|
|
136
|
-
'afterCreate hook failed (record persisted; hook is best-effort)',
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
res.status(201).json(record);
|
|
141
|
-
}),
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
router.patch(
|
|
145
|
-
'/:id',
|
|
146
|
-
asyncHandler(async (req, res) => {
|
|
147
|
-
const id = String(req.params.id);
|
|
148
|
-
const data = req.body as Record<string, unknown>;
|
|
149
|
-
if (!data || Object.keys(data).length === 0) {
|
|
150
|
-
throw new ApiError(400, 'Request body cannot be empty', 'empty_body');
|
|
151
|
-
}
|
|
152
|
-
if (config.beforeUpdate) {
|
|
153
|
-
await config.beforeUpdate(req, res, data);
|
|
154
|
-
if (res.headersSent) return;
|
|
155
|
-
}
|
|
156
|
-
const existing = await config.model.findOne({ where: { [pk]: id } });
|
|
157
|
-
if (!existing) throw new ApiError(404, 'Not found', 'not_found');
|
|
158
|
-
const before = existing.toJSON() as Record<string, unknown>;
|
|
159
|
-
await existing.update(data);
|
|
160
|
-
const after = existing.toJSON() as Record<string, unknown>;
|
|
161
|
-
if (config.afterUpdate) {
|
|
162
|
-
try {
|
|
163
|
-
await config.afterUpdate(req, before, after);
|
|
164
|
-
} catch (err) {
|
|
165
|
-
req.log?.error?.(
|
|
166
|
-
{ err, entity: config.name, record_id: id },
|
|
167
|
-
'afterUpdate hook failed (record persisted; hook is best-effort)',
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
res.json(after);
|
|
172
|
-
}),
|
|
173
|
-
);
|
|
174
|
-
|
|
175
|
-
router.delete(
|
|
176
|
-
'/:id',
|
|
177
|
-
asyncHandler(async (req, res) => {
|
|
178
|
-
const id = String(req.params.id);
|
|
179
|
-
if (config.beforeDelete) await config.beforeDelete(req, id);
|
|
180
|
-
const removed = await config.model.destroy({ where: { [pk]: id } });
|
|
181
|
-
if (removed === 0) throw new ApiError(404, 'Not found', 'not_found');
|
|
182
|
-
res.status(204).send();
|
|
183
|
-
}),
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
if (!config.bulkOperations) return router;
|
|
187
|
-
|
|
188
|
-
router.post(
|
|
189
|
-
'/bulk',
|
|
190
|
-
asyncHandler(async (req, res) => {
|
|
191
|
-
const { items } = req.body as { items: Record<string, unknown>[] };
|
|
192
|
-
if (!Array.isArray(items) || items.length === 0) {
|
|
193
|
-
throw new ApiError(
|
|
194
|
-
400,
|
|
195
|
-
'items must be a non-empty array',
|
|
196
|
-
'validation_error',
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
for (const item of items) {
|
|
200
|
-
await config.beforeCreate?.(req, item);
|
|
201
|
-
}
|
|
202
|
-
const rows = await config.model.bulkCreate(items);
|
|
203
|
-
res
|
|
204
|
-
.status(201)
|
|
205
|
-
.json({ data: rows.map((r) => r.toJSON()), count: rows.length });
|
|
206
|
-
}),
|
|
207
|
-
);
|
|
208
|
-
|
|
209
|
-
router.delete(
|
|
210
|
-
'/bulk',
|
|
211
|
-
asyncHandler(async (req, res) => {
|
|
212
|
-
const { ids } = req.body as { ids: string[] };
|
|
213
|
-
if (!Array.isArray(ids) || ids.length === 0) {
|
|
214
|
-
throw new ApiError(
|
|
215
|
-
400,
|
|
216
|
-
'ids must be a non-empty array',
|
|
217
|
-
'validation_error',
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
await config.model.destroy({ where: { [pk]: ids } });
|
|
221
|
-
res.status(204).send();
|
|
222
|
-
}),
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
return router;
|
|
226
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
export { registerEntityRoutes } from './auto-routes.js';
|
|
2
|
-
export type {
|
|
3
|
-
SequelizeEntityConfig,
|
|
4
|
-
BeforeCreateHook,
|
|
5
|
-
AfterCreateHook,
|
|
6
|
-
BeforeUpdateHook,
|
|
7
|
-
AfterUpdateHook,
|
|
8
|
-
BeforeDeleteHook,
|
|
9
|
-
} from './auto-routes.js';
|
|
10
|
-
export { listEntities, registerInRegistry } from './registry.js';
|
|
11
|
-
export type { RegisteredEntity } from './registry.js';
|
|
12
|
-
export {
|
|
13
|
-
buildOrder,
|
|
14
|
-
buildPagination,
|
|
15
|
-
buildSearchWhere,
|
|
16
|
-
buildWhere,
|
|
17
|
-
combineWhere,
|
|
18
|
-
parseRawQuery,
|
|
19
|
-
} from './query-engine.js';
|
|
20
|
-
export type { ParsedQuery, PaginationMeta } from './query-engine.js';
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { createServer } from 'node:http';
|
|
2
|
-
import { buildApp } from './app.js';
|
|
3
|
-
import { config } from './config.js';
|
|
4
|
-
import { closeDatabase } from './db/client.js';
|
|
5
|
-
|
|
6
|
-
const app = buildApp();
|
|
7
|
-
const server = createServer(app);
|
|
8
|
-
|
|
9
|
-
server.listen(config.PORT, config.HOST, () => {
|
|
10
|
-
console.log(`Express API listening on http://${config.HOST}:${config.PORT}`);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
function shutdown(signal: string): void {
|
|
14
|
-
console.log(`${signal} received, closing HTTP server`);
|
|
15
|
-
server.close((err) => {
|
|
16
|
-
closeDatabase()
|
|
17
|
-
.catch((closeErr: unknown) => {
|
|
18
|
-
console.error(closeErr);
|
|
19
|
-
})
|
|
20
|
-
.finally(() => {
|
|
21
|
-
if (err) {
|
|
22
|
-
console.error(err);
|
|
23
|
-
process.exit(1);
|
|
24
|
-
}
|
|
25
|
-
process.exit(0);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
setTimeout(() => process.exit(1), 10_000).unref();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
process.on('SIGTERM', shutdown);
|
|
32
|
-
process.on('SIGINT', shutdown);
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import request from 'supertest';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { buildApp } from '../src/app.js';
|
|
4
|
-
|
|
5
|
-
describe('Express Sequelize app', () => {
|
|
6
|
-
it('exposes empty generated metadata until entities are added', async () => {
|
|
7
|
-
const res = await request(buildApp()).get('/api/v1/_meta');
|
|
8
|
-
|
|
9
|
-
expect(res.status).toBe(200);
|
|
10
|
-
expect(res.body).toEqual({ entities: [], orm: 'sequelize' });
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('returns structured errors with request id', async () => {
|
|
14
|
-
const res = await request(buildApp())
|
|
15
|
-
.get('/missing')
|
|
16
|
-
.set('x-request-id', 'req-missing');
|
|
17
|
-
|
|
18
|
-
expect(res.status).toBe(404);
|
|
19
|
-
expect(res.body.error).toMatchObject({
|
|
20
|
-
code: 'not_found',
|
|
21
|
-
request_id: 'req-missing',
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
});
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from 'vitest/config';
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
test: {
|
|
5
|
-
globals: true,
|
|
6
|
-
environment: 'node',
|
|
7
|
-
include: ['tests/**/*.test.ts'],
|
|
8
|
-
coverage: {
|
|
9
|
-
provider: 'v8',
|
|
10
|
-
include: ['src/**/*.ts'],
|
|
11
|
-
exclude: ['src/server.ts', 'src/config.ts'],
|
|
12
|
-
thresholds: {
|
|
13
|
-
statements: 80,
|
|
14
|
-
branches: 80,
|
|
15
|
-
functions: 80,
|
|
16
|
-
lines: 80,
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
},
|
|
20
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
|
|
2
|
-
import cors from '@fastify/cors';
|
|
3
|
-
import helmet from '@fastify/helmet';
|
|
4
|
-
import rateLimit from '@fastify/rate-limit';
|
|
5
|
-
import { config } from './config.js';
|
|
6
|
-
import errorHandler from './plugins/error-handler.js';
|
|
7
|
-
import authPlugin from './plugins/auth.js';
|
|
8
|
-
import authzPlugin from './plugins/authz.js';
|
|
9
|
-
import requestIdPlugin from './plugins/request-id.js';
|
|
10
|
-
import swaggerPlugin from './plugins/swagger.js';
|
|
11
|
-
import { checkDatabase, closeDatabase, sequelize } from './db/client.js';
|
|
12
|
-
import './models/index.js';
|
|
13
|
-
import { listEntities } from './modules/_base/index.js';
|
|
14
|
-
// projx-anchor: entity-imports
|
|
15
|
-
|
|
16
|
-
export interface BuildAppOptions {
|
|
17
|
-
logger?: boolean | object;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function buildApp(
|
|
21
|
-
options: BuildAppOptions = {},
|
|
22
|
-
): Promise<FastifyInstance> {
|
|
23
|
-
const app = Fastify({
|
|
24
|
-
logger: options.logger ?? { level: config.LOG_LEVEL },
|
|
25
|
-
genReqId: (req) =>
|
|
26
|
-
(req.headers['x-request-id'] as string) || crypto.randomUUID(),
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
app.decorate('sequelize', sequelize);
|
|
30
|
-
await app.register(helmet, { contentSecurityPolicy: false });
|
|
31
|
-
await app.register(cors, {
|
|
32
|
-
origin: config.CORS_ALLOW_ORIGINS.split(',').map((o) => o.trim()),
|
|
33
|
-
credentials: true,
|
|
34
|
-
});
|
|
35
|
-
await app.register(rateLimit, {
|
|
36
|
-
max: config.RATE_LIMIT_MAX,
|
|
37
|
-
timeWindow: config.RATE_LIMIT_WINDOW,
|
|
38
|
-
keyGenerator: (request: FastifyRequest) =>
|
|
39
|
-
request.authUser?.sub ?? request.ip,
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
await app.register(swaggerPlugin);
|
|
43
|
-
await app.register(errorHandler);
|
|
44
|
-
await app.register(requestIdPlugin);
|
|
45
|
-
await app.register(authPlugin);
|
|
46
|
-
await app.register(authzPlugin);
|
|
47
|
-
|
|
48
|
-
app.get(
|
|
49
|
-
'/api/health',
|
|
50
|
-
{ config: { public: true }, schema: { tags: ['health'] } },
|
|
51
|
-
async (_request, reply) => {
|
|
52
|
-
const checks: Record<string, string> = { app: 'ok' };
|
|
53
|
-
try {
|
|
54
|
-
await checkDatabase();
|
|
55
|
-
checks.database = 'ok';
|
|
56
|
-
} catch (e) {
|
|
57
|
-
checks.database = `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
58
|
-
return reply.status(503).send({ status: 'unhealthy', checks });
|
|
59
|
-
}
|
|
60
|
-
return reply.send({ status: 'healthy', checks });
|
|
61
|
-
},
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
app.get(
|
|
65
|
-
'/api/v1/_meta',
|
|
66
|
-
{ config: { public: true }, schema: { tags: ['meta'] } },
|
|
67
|
-
async () => ({ entities: listEntities(), orm: 'sequelize' }),
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
// projx-anchor: entity-registrations
|
|
71
|
-
|
|
72
|
-
app.addHook('onClose', async () => {
|
|
73
|
-
await closeDatabase();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
return app;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
declare module 'fastify' {
|
|
80
|
-
interface FastifyInstance {
|
|
81
|
-
sequelize: typeof sequelize;
|
|
82
|
-
}
|
|
83
|
-
}
|