create-projx 1.6.4 → 1.7.0
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 +92 -19
- package/dist/{baseline-PZM4KJJW.js → baseline-FHOZNS4D.js} +2 -2
- package/dist/{chunk-6YRBHJ2V.js → chunk-HAT7D4G2.js} +25 -8
- package/dist/{chunk-XQ7FE4U3.js → chunk-IMZKHDIL.js} +161 -19
- package/dist/index.js +1499 -276
- package/dist/{utils-AVKSTHIF.js → utils-BZGSJ7XZ.js} +5 -1
- package/package.json +13 -7
- package/src/addons/orms/drizzle/express/src/app.ts +81 -0
- package/src/addons/orms/drizzle/express/src/modules/_base/auto-routes.ts +278 -0
- package/src/addons/orms/drizzle/express/src/modules/_base/index.ts +20 -0
- package/src/addons/orms/drizzle/express/src/server.ts +32 -0
- package/src/addons/orms/drizzle/express/tests/app.test.ts +24 -0
- package/src/addons/orms/drizzle/express/vitest.config.ts +20 -0
- package/src/addons/orms/drizzle/fastify/src/app.ts +90 -0
- package/src/addons/orms/drizzle/fastify/src/modules/_base/auto-routes.ts +268 -0
- package/src/addons/orms/drizzle/fastify/src/modules/_base/index.ts +20 -0
- package/src/addons/orms/drizzle/fastify/tests/modules/app.test.ts +20 -0
- package/src/addons/orms/drizzle/fastify/vitest.config.ts +31 -0
- package/src/addons/orms/drizzle/gen-entity/express-router.ts +21 -0
- package/src/addons/orms/drizzle/gen-entity/express-test.ts +61 -0
- package/src/addons/orms/drizzle/gen-entity/fastify-router.ts +19 -0
- package/src/addons/orms/drizzle/gen-entity/fastify-test.ts +87 -0
- package/src/addons/orms/drizzle/manifest.json +52 -0
- package/src/addons/orms/drizzle/shared/drizzle.config.ts +12 -0
- package/src/addons/orms/drizzle/shared/src/db/client.ts +17 -0
- package/src/addons/orms/drizzle/shared/src/db/schema.ts +14 -0
- package/src/addons/orms/drizzle/shared/src/modules/_base/query-engine.ts +115 -0
- package/src/addons/orms/drizzle/shared/src/modules/_base/registry.ts +15 -0
- package/src/addons/orms/sequelize/express/src/app.ts +82 -0
- package/src/addons/orms/sequelize/express/src/modules/_base/auto-routes.ts +226 -0
- package/src/addons/orms/sequelize/express/src/modules/_base/index.ts +20 -0
- package/src/addons/orms/sequelize/express/src/server.ts +32 -0
- package/src/addons/orms/sequelize/express/tests/app.test.ts +24 -0
- package/src/addons/orms/sequelize/express/vitest.config.ts +20 -0
- package/src/addons/orms/sequelize/fastify/src/app.ts +83 -0
- package/src/addons/orms/sequelize/fastify/src/modules/_base/auto-routes.ts +216 -0
- package/src/addons/orms/sequelize/fastify/src/modules/_base/index.ts +20 -0
- package/src/addons/orms/sequelize/fastify/tests/modules/app.test.ts +20 -0
- package/src/addons/orms/sequelize/fastify/vitest.config.ts +31 -0
- package/src/addons/orms/sequelize/gen-entity/express-router.ts +17 -0
- package/src/addons/orms/sequelize/gen-entity/express-test.ts +65 -0
- package/src/addons/orms/sequelize/gen-entity/fastify-router.ts +19 -0
- package/src/addons/orms/sequelize/gen-entity/fastify-test.ts +89 -0
- package/src/addons/orms/sequelize/gen-entity/model.ts +21 -0
- package/src/addons/orms/sequelize/manifest.json +53 -0
- package/src/addons/orms/sequelize/shared/scripts/db-sync.ts +14 -0
- package/src/addons/orms/sequelize/shared/src/db/client.ts +19 -0
- package/src/addons/orms/sequelize/shared/src/models/index.ts +9 -0
- package/src/addons/orms/sequelize/shared/src/modules/_base/query-engine.ts +101 -0
- package/src/addons/orms/sequelize/shared/src/modules/_base/registry.ts +15 -0
- package/src/addons/orms/typeorm/express/src/app.ts +82 -0
- package/src/addons/orms/typeorm/express/src/modules/_base/auto-routes.ts +249 -0
- package/src/addons/orms/typeorm/express/src/modules/_base/index.ts +19 -0
- package/src/addons/orms/typeorm/express/src/server.ts +43 -0
- package/src/addons/orms/typeorm/express/tests/app.test.ts +24 -0
- package/src/addons/orms/typeorm/express/vitest.config.ts +20 -0
- package/src/addons/orms/typeorm/fastify/src/app.ts +86 -0
- package/src/addons/orms/typeorm/fastify/src/modules/_base/auto-routes.ts +239 -0
- package/src/addons/orms/typeorm/fastify/src/modules/_base/index.ts +19 -0
- package/src/addons/orms/typeorm/fastify/tests/modules/app.test.ts +20 -0
- package/src/addons/orms/typeorm/fastify/vitest.config.ts +31 -0
- package/src/addons/orms/typeorm/gen-entity/entity.ts +21 -0
- package/src/addons/orms/typeorm/gen-entity/express-router.ts +17 -0
- package/src/addons/orms/typeorm/gen-entity/express-test.ts +66 -0
- package/src/addons/orms/typeorm/gen-entity/fastify-router.ts +19 -0
- package/src/addons/orms/typeorm/gen-entity/fastify-test.ts +89 -0
- package/src/addons/orms/typeorm/manifest.json +53 -0
- package/src/addons/orms/typeorm/shared/scripts/db-sync.ts +14 -0
- package/src/addons/orms/typeorm/shared/src/db/data-source.ts +21 -0
- package/src/addons/orms/typeorm/shared/src/entities/index.ts +8 -0
- package/src/addons/orms/typeorm/shared/src/modules/_base/query-engine.ts +94 -0
- package/src/addons/orms/typeorm/shared/src/modules/_base/registry.ts +15 -0
- package/src/addons/orms/typeorm/shared/tsconfig.json +16 -0
- package/src/templates/README.md.ejs +21 -4
- package/src/templates/ci.yml.ejs +167 -37
- package/src/templates/docker-compose.yml.ejs +72 -5
- package/src/templates/pre-commit.ejs +28 -4
- package/src/templates/setup.sh.ejs +95 -6
- package/src/templates/docker-compose.dev.yml.ejs +0 -189
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
DEFAULT_COMPONENT_SKIP_PATTERNS,
|
|
5
5
|
DEFAULT_ROOT_SKIP_PATTERNS,
|
|
6
6
|
EXCLUDE,
|
|
7
|
+
KNOWN_FEATURES,
|
|
8
|
+
ORM_PROVIDERS,
|
|
7
9
|
PACKAGE_MANAGERS,
|
|
8
10
|
REPO,
|
|
9
11
|
REPO_URL,
|
|
@@ -33,13 +35,15 @@ import {
|
|
|
33
35
|
upsertComponentMarker,
|
|
34
36
|
writeComponentMarker,
|
|
35
37
|
writeProjxConfig
|
|
36
|
-
} from "./chunk-
|
|
38
|
+
} from "./chunk-HAT7D4G2.js";
|
|
37
39
|
export {
|
|
38
40
|
COMPONENTS,
|
|
39
41
|
COMPONENT_MARKER,
|
|
40
42
|
DEFAULT_COMPONENT_SKIP_PATTERNS,
|
|
41
43
|
DEFAULT_ROOT_SKIP_PATTERNS,
|
|
42
44
|
EXCLUDE,
|
|
45
|
+
KNOWN_FEATURES,
|
|
46
|
+
ORM_PROVIDERS,
|
|
43
47
|
PACKAGE_MANAGERS,
|
|
44
48
|
REPO,
|
|
45
49
|
REPO_URL,
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-projx",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Scaffold production-grade fullstack projects in seconds. FastAPI, Fastify, React, Flutter, Terraform — with auth, database, CI/CD, E2E tests, and Docker. One command, ready to deploy.",
|
|
3
|
+
"version": "1.7.0",
|
|
4
|
+
"description": "Scaffold production-grade fullstack projects in seconds. FastAPI, Fastify, Express, React, Flutter, Terraform — with auth, database, CI/CD, E2E tests, and Docker. One command, ready to deploy.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-projx": "dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
11
|
-
"src/templates"
|
|
11
|
+
"src/templates",
|
|
12
|
+
"src/addons"
|
|
12
13
|
],
|
|
13
14
|
"keywords": [
|
|
14
15
|
"projx",
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
"fullstack",
|
|
21
22
|
"fastapi",
|
|
22
23
|
"fastify",
|
|
24
|
+
"express",
|
|
23
25
|
"react",
|
|
24
26
|
"flutter",
|
|
25
27
|
"terraform",
|
|
@@ -44,21 +46,25 @@
|
|
|
44
46
|
"node": ">=18"
|
|
45
47
|
},
|
|
46
48
|
"dependencies": {
|
|
47
|
-
"@clack/prompts": "^0
|
|
49
|
+
"@clack/prompts": "^1.2.0"
|
|
48
50
|
},
|
|
49
51
|
"devDependencies": {
|
|
50
52
|
"@eslint/js": "^10.0.1",
|
|
51
53
|
"@types/node": "^25.5.2",
|
|
54
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
52
55
|
"eslint": "^10.2.0",
|
|
56
|
+
"prettier": "^3.8.3",
|
|
53
57
|
"tsup": "^8",
|
|
54
58
|
"typescript": "^5",
|
|
55
|
-
"typescript-eslint": "^8.
|
|
56
|
-
"vitest": "^4.1.
|
|
59
|
+
"typescript-eslint": "^8.59.0",
|
|
60
|
+
"vitest": "^4.1.5"
|
|
57
61
|
},
|
|
58
62
|
"scripts": {
|
|
59
63
|
"build": "tsup src/index.ts --format esm --target node18 --clean",
|
|
60
64
|
"dev": "tsup src/index.ts --format esm --target node18 --watch",
|
|
61
|
-
"
|
|
65
|
+
"format": "prettier --write .",
|
|
66
|
+
"format:check": "prettier --check .",
|
|
67
|
+
"test": "vitest run --coverage",
|
|
62
68
|
"test:watch": "vitest"
|
|
63
69
|
}
|
|
64
70
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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, db } from './db/client.js';
|
|
11
|
+
import { listEntities } from './modules/_base/index.js';
|
|
12
|
+
// projx-anchor: entity-imports
|
|
13
|
+
|
|
14
|
+
const requestId: RequestHandler = (req, res, next) => {
|
|
15
|
+
const incoming = req.headers['x-request-id'];
|
|
16
|
+
const value =
|
|
17
|
+
typeof incoming === 'string' && incoming.trim()
|
|
18
|
+
? incoming
|
|
19
|
+
: crypto.randomUUID();
|
|
20
|
+
res.locals.requestId = value;
|
|
21
|
+
res.setHeader('x-request-id', value);
|
|
22
|
+
next();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function corsOrigin(
|
|
26
|
+
origin: string | undefined,
|
|
27
|
+
callback: (err: Error | null, allow?: boolean) => void,
|
|
28
|
+
): void {
|
|
29
|
+
const origins = allowedOrigins();
|
|
30
|
+
if (!origin || origins.includes('*') || origins.includes(origin)) {
|
|
31
|
+
callback(null, true);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
callback(new ApiError(403, 'Origin not allowed', 'origin_not_allowed'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildApp(): express.Express {
|
|
38
|
+
const app = express();
|
|
39
|
+
|
|
40
|
+
app.disable('x-powered-by');
|
|
41
|
+
app.locals.db = db;
|
|
42
|
+
app.use(requestId);
|
|
43
|
+
app.use(pinoHttp({ level: config.LOG_LEVEL }));
|
|
44
|
+
app.use(helmet());
|
|
45
|
+
app.use(cors({ origin: corsOrigin, credentials: true }));
|
|
46
|
+
app.use(compression());
|
|
47
|
+
app.use(express.json({ limit: '1mb' }));
|
|
48
|
+
app.use(express.urlencoded({ extended: false, limit: '1mb' }));
|
|
49
|
+
app.use(
|
|
50
|
+
rateLimit({
|
|
51
|
+
windowMs: config.RATE_LIMIT_WINDOW_MS,
|
|
52
|
+
limit: config.RATE_LIMIT_MAX,
|
|
53
|
+
standardHeaders: 'draft-8',
|
|
54
|
+
legacyHeaders: false,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
app.get('/api/health', async (_req, res) => {
|
|
59
|
+
const checks: Record<string, string> = { app: 'ok' };
|
|
60
|
+
try {
|
|
61
|
+
await checkDatabase();
|
|
62
|
+
checks.database = 'ok';
|
|
63
|
+
} catch (e) {
|
|
64
|
+
checks.database = `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
65
|
+
res.status(503).json({ status: 'unhealthy', checks });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
res.json({ status: 'healthy', checks });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
app.get('/api/v1/_meta', (_req, res) => {
|
|
72
|
+
res.json({ entities: listEntities(), orm: 'drizzle' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// projx-anchor: entity-registrations
|
|
76
|
+
|
|
77
|
+
app.use(notFoundHandler);
|
|
78
|
+
app.use(errorHandler);
|
|
79
|
+
|
|
80
|
+
return app;
|
|
81
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import express, {
|
|
2
|
+
type NextFunction,
|
|
3
|
+
type Request,
|
|
4
|
+
type Response,
|
|
5
|
+
} from 'express';
|
|
6
|
+
import { eq, inArray, sql } from 'drizzle-orm';
|
|
7
|
+
import type { PgTable } from 'drizzle-orm/pg-core';
|
|
8
|
+
import type { DbClient } from '../../db/client.js';
|
|
9
|
+
import { ApiError } from '../../errors.js';
|
|
10
|
+
import { registerInRegistry } from './registry.js';
|
|
11
|
+
import {
|
|
12
|
+
buildOrderBy,
|
|
13
|
+
buildPagination,
|
|
14
|
+
buildSearchWhere,
|
|
15
|
+
buildWhere,
|
|
16
|
+
combineWhere,
|
|
17
|
+
parseRawQuery,
|
|
18
|
+
} from './query-engine.js';
|
|
19
|
+
|
|
20
|
+
export type BeforeCreateHook = (
|
|
21
|
+
request: Request,
|
|
22
|
+
data: Record<string, unknown>,
|
|
23
|
+
) => void | Promise<void>;
|
|
24
|
+
export type AfterCreateHook = (
|
|
25
|
+
request: Request,
|
|
26
|
+
record: Record<string, unknown>,
|
|
27
|
+
) => void | Promise<void>;
|
|
28
|
+
export type BeforeUpdateHook = (
|
|
29
|
+
request: Request,
|
|
30
|
+
response: Response,
|
|
31
|
+
data: Record<string, unknown>,
|
|
32
|
+
) => void | Promise<void>;
|
|
33
|
+
export type AfterUpdateHook = (
|
|
34
|
+
request: Request,
|
|
35
|
+
before: Record<string, unknown>,
|
|
36
|
+
after: Record<string, unknown>,
|
|
37
|
+
) => void | Promise<void>;
|
|
38
|
+
export type BeforeDeleteHook = (
|
|
39
|
+
request: Request,
|
|
40
|
+
recordId: string,
|
|
41
|
+
) => void | Promise<void>;
|
|
42
|
+
|
|
43
|
+
export interface DrizzleEntityConfig {
|
|
44
|
+
name: string;
|
|
45
|
+
apiPrefix: string;
|
|
46
|
+
tag: string;
|
|
47
|
+
table: PgTable;
|
|
48
|
+
primaryKey?: string;
|
|
49
|
+
searchableFields?: string[];
|
|
50
|
+
readonly?: boolean;
|
|
51
|
+
bulkOperations?: boolean;
|
|
52
|
+
beforeCreate?: BeforeCreateHook;
|
|
53
|
+
afterCreate?: AfterCreateHook;
|
|
54
|
+
beforeUpdate?: BeforeUpdateHook;
|
|
55
|
+
afterUpdate?: AfterUpdateHook;
|
|
56
|
+
beforeDelete?: BeforeDeleteHook;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type AsyncHandler = (
|
|
60
|
+
req: Request,
|
|
61
|
+
res: Response,
|
|
62
|
+
next: NextFunction,
|
|
63
|
+
) => Promise<void>;
|
|
64
|
+
|
|
65
|
+
function asyncHandler(handler: AsyncHandler) {
|
|
66
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
67
|
+
handler(req, res, next).catch(next);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function column(table: PgTable, key: string): unknown {
|
|
72
|
+
return (table as unknown as Record<string, unknown>)[key];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function pkColumn(config: DrizzleEntityConfig): unknown {
|
|
76
|
+
const key = config.primaryKey ?? 'id';
|
|
77
|
+
const col = column(config.table, key);
|
|
78
|
+
if (!col) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Primary key column "${key}" not found on table ${config.name}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return col;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function registerEntityRoutes(
|
|
87
|
+
config: DrizzleEntityConfig,
|
|
88
|
+
db: DbClient,
|
|
89
|
+
): express.Router {
|
|
90
|
+
registerInRegistry({ name: config.name, apiPrefix: config.apiPrefix });
|
|
91
|
+
const router = express.Router();
|
|
92
|
+
const pk = pkColumn(config) as Parameters<typeof eq>[0];
|
|
93
|
+
|
|
94
|
+
router.get(
|
|
95
|
+
'/',
|
|
96
|
+
asyncHandler(async (req, res) => {
|
|
97
|
+
const rawQs = req.originalUrl.split('?')[1] ?? '';
|
|
98
|
+
const query = parseRawQuery(rawQs);
|
|
99
|
+
const filterWhere = buildWhere(config.table, query.filters);
|
|
100
|
+
const searchWhere = buildSearchWhere(
|
|
101
|
+
config.table,
|
|
102
|
+
config.searchableFields ?? [],
|
|
103
|
+
query.search,
|
|
104
|
+
);
|
|
105
|
+
const where = combineWhere(filterWhere, searchWhere);
|
|
106
|
+
const order = buildOrderBy(config.table, query.order_by);
|
|
107
|
+
const offset = (query.page - 1) * query.page_size;
|
|
108
|
+
|
|
109
|
+
const baseSelect = db.select().from(config.table);
|
|
110
|
+
const baseCount = db
|
|
111
|
+
.select({ count: sql<number>`count(*)::int` })
|
|
112
|
+
.from(config.table);
|
|
113
|
+
|
|
114
|
+
const rows = await (where
|
|
115
|
+
? baseSelect
|
|
116
|
+
.where(where)
|
|
117
|
+
.orderBy(...order)
|
|
118
|
+
.limit(query.page_size)
|
|
119
|
+
.offset(offset)
|
|
120
|
+
: baseSelect
|
|
121
|
+
.orderBy(...order)
|
|
122
|
+
.limit(query.page_size)
|
|
123
|
+
.offset(offset));
|
|
124
|
+
const [{ count }] = await (where ? baseCount.where(where) : baseCount);
|
|
125
|
+
|
|
126
|
+
res.json({
|
|
127
|
+
data: rows,
|
|
128
|
+
pagination: buildPagination(query.page, query.page_size, Number(count)),
|
|
129
|
+
});
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
router.get(
|
|
134
|
+
'/:id',
|
|
135
|
+
asyncHandler(async (req, res) => {
|
|
136
|
+
const [record] = await db
|
|
137
|
+
.select()
|
|
138
|
+
.from(config.table)
|
|
139
|
+
.where(eq(pk, String(req.params.id)))
|
|
140
|
+
.limit(1);
|
|
141
|
+
if (!record) throw new ApiError(404, 'Not found', 'not_found');
|
|
142
|
+
res.json(record);
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (config.readonly) return router;
|
|
147
|
+
|
|
148
|
+
router.post(
|
|
149
|
+
'/',
|
|
150
|
+
asyncHandler(async (req, res) => {
|
|
151
|
+
const data = req.body as Record<string, unknown>;
|
|
152
|
+
await config.beforeCreate?.(req, data);
|
|
153
|
+
const [record] = await db
|
|
154
|
+
.insert(config.table)
|
|
155
|
+
.values(data as never)
|
|
156
|
+
.returning();
|
|
157
|
+
if (config.afterCreate) {
|
|
158
|
+
try {
|
|
159
|
+
await config.afterCreate(req, record as Record<string, unknown>);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
req.log?.error?.(
|
|
162
|
+
{
|
|
163
|
+
err,
|
|
164
|
+
entity: config.name,
|
|
165
|
+
record_id: (record as { id?: string }).id,
|
|
166
|
+
},
|
|
167
|
+
'afterCreate hook failed (record persisted; hook is best-effort)',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
res.status(201).json(record);
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
router.patch(
|
|
176
|
+
'/:id',
|
|
177
|
+
asyncHandler(async (req, res) => {
|
|
178
|
+
const id = String(req.params.id);
|
|
179
|
+
const data = req.body as Record<string, unknown>;
|
|
180
|
+
if (!data || Object.keys(data).length === 0) {
|
|
181
|
+
throw new ApiError(400, 'Request body cannot be empty', 'empty_body');
|
|
182
|
+
}
|
|
183
|
+
if (config.beforeUpdate) {
|
|
184
|
+
await config.beforeUpdate(req, res, data);
|
|
185
|
+
if (res.headersSent) return;
|
|
186
|
+
}
|
|
187
|
+
let before: Record<string, unknown> | null = null;
|
|
188
|
+
if (config.afterUpdate) {
|
|
189
|
+
const [existing] = await db
|
|
190
|
+
.select()
|
|
191
|
+
.from(config.table)
|
|
192
|
+
.where(eq(pk, id))
|
|
193
|
+
.limit(1);
|
|
194
|
+
before = (existing as Record<string, unknown>) ?? null;
|
|
195
|
+
}
|
|
196
|
+
const [record] = await db
|
|
197
|
+
.update(config.table)
|
|
198
|
+
.set(data as never)
|
|
199
|
+
.where(eq(pk, id))
|
|
200
|
+
.returning();
|
|
201
|
+
if (!record) throw new ApiError(404, 'Not found', 'not_found');
|
|
202
|
+
if (config.afterUpdate && before) {
|
|
203
|
+
try {
|
|
204
|
+
await config.afterUpdate(
|
|
205
|
+
req,
|
|
206
|
+
before,
|
|
207
|
+
record as Record<string, unknown>,
|
|
208
|
+
);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
req.log?.error?.(
|
|
211
|
+
{ err, entity: config.name, record_id: id },
|
|
212
|
+
'afterUpdate hook failed (record persisted; hook is best-effort)',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
res.json(record);
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
router.delete(
|
|
221
|
+
'/:id',
|
|
222
|
+
asyncHandler(async (req, res) => {
|
|
223
|
+
const id = String(req.params.id);
|
|
224
|
+
if (config.beforeDelete) await config.beforeDelete(req, id);
|
|
225
|
+
const deleted = await db
|
|
226
|
+
.delete(config.table)
|
|
227
|
+
.where(eq(pk, id))
|
|
228
|
+
.returning();
|
|
229
|
+
if (deleted.length === 0)
|
|
230
|
+
throw new ApiError(404, 'Not found', 'not_found');
|
|
231
|
+
res.status(204).send();
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (!config.bulkOperations) return router;
|
|
236
|
+
|
|
237
|
+
router.post(
|
|
238
|
+
'/bulk',
|
|
239
|
+
asyncHandler(async (req, res) => {
|
|
240
|
+
const { items } = req.body as { items: Record<string, unknown>[] };
|
|
241
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
242
|
+
throw new ApiError(
|
|
243
|
+
400,
|
|
244
|
+
'items must be a non-empty array',
|
|
245
|
+
'validation_error',
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
for (const item of items) {
|
|
249
|
+
await config.beforeCreate?.(req, item);
|
|
250
|
+
}
|
|
251
|
+
const rows = await db
|
|
252
|
+
.insert(config.table)
|
|
253
|
+
.values(items as never)
|
|
254
|
+
.returning();
|
|
255
|
+
res.status(201).json({ data: rows, count: rows.length });
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
router.delete(
|
|
260
|
+
'/bulk',
|
|
261
|
+
asyncHandler(async (req, res) => {
|
|
262
|
+
const { ids } = req.body as { ids: string[] };
|
|
263
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
264
|
+
throw new ApiError(
|
|
265
|
+
400,
|
|
266
|
+
'ids must be a non-empty array',
|
|
267
|
+
'validation_error',
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
await db
|
|
271
|
+
.delete(config.table)
|
|
272
|
+
.where(inArray(pk as Parameters<typeof inArray>[0], ids));
|
|
273
|
+
res.status(204).send();
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return router;
|
|
278
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { registerEntityRoutes } from './auto-routes.js';
|
|
2
|
+
export type {
|
|
3
|
+
DrizzleEntityConfig,
|
|
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
|
+
buildOrderBy,
|
|
14
|
+
buildPagination,
|
|
15
|
+
buildSearchWhere,
|
|
16
|
+
buildWhere,
|
|
17
|
+
combineWhere,
|
|
18
|
+
parseRawQuery,
|
|
19
|
+
} from './query-engine.js';
|
|
20
|
+
export type { ParsedQuery, PaginationMeta } from './query-engine.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
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);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { buildApp } from '../src/app.js';
|
|
4
|
+
|
|
5
|
+
describe('Express Drizzle 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: 'drizzle' });
|
|
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
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
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, db } from './db/client.js';
|
|
12
|
+
import { listEntities } from './modules/_base/index.js';
|
|
13
|
+
// projx-anchor: entity-imports
|
|
14
|
+
|
|
15
|
+
export interface BuildAppOptions {
|
|
16
|
+
logger?: boolean | object;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function buildApp(
|
|
20
|
+
options: BuildAppOptions = {},
|
|
21
|
+
): Promise<FastifyInstance> {
|
|
22
|
+
const app = Fastify({
|
|
23
|
+
logger: options.logger ?? { level: config.LOG_LEVEL },
|
|
24
|
+
genReqId: (req) =>
|
|
25
|
+
(req.headers['x-request-id'] as string) || crypto.randomUUID(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
app.decorate('db', db);
|
|
29
|
+
await app.register(helmet, { contentSecurityPolicy: false });
|
|
30
|
+
await app.register(cors, {
|
|
31
|
+
origin: config.CORS_ALLOW_ORIGINS.split(',').map((o) => o.trim()),
|
|
32
|
+
credentials: true,
|
|
33
|
+
});
|
|
34
|
+
await app.register(rateLimit, {
|
|
35
|
+
max: config.RATE_LIMIT_MAX,
|
|
36
|
+
timeWindow: config.RATE_LIMIT_WINDOW,
|
|
37
|
+
keyGenerator: (request: FastifyRequest) =>
|
|
38
|
+
request.authUser?.sub ?? request.ip,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await app.register(swaggerPlugin);
|
|
42
|
+
await app.register(errorHandler);
|
|
43
|
+
await app.register(requestIdPlugin);
|
|
44
|
+
await app.register(authPlugin);
|
|
45
|
+
await app.register(authzPlugin);
|
|
46
|
+
|
|
47
|
+
app.get(
|
|
48
|
+
'/api/health',
|
|
49
|
+
{
|
|
50
|
+
config: { public: true },
|
|
51
|
+
schema: {
|
|
52
|
+
tags: ['health'],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
async (_request, reply) => {
|
|
56
|
+
const checks: Record<string, string> = { app: 'ok' };
|
|
57
|
+
try {
|
|
58
|
+
await checkDatabase();
|
|
59
|
+
checks.database = 'ok';
|
|
60
|
+
} catch (e) {
|
|
61
|
+
checks.database = `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
62
|
+
return reply.status(503).send({ status: 'unhealthy', checks });
|
|
63
|
+
}
|
|
64
|
+
return reply.send({ status: 'healthy', checks });
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
app.get(
|
|
69
|
+
'/api/v1/_meta',
|
|
70
|
+
{
|
|
71
|
+
config: { public: true },
|
|
72
|
+
schema: { tags: ['meta'] },
|
|
73
|
+
},
|
|
74
|
+
async () => ({ entities: listEntities(), orm: 'drizzle' }),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// projx-anchor: entity-registrations
|
|
78
|
+
|
|
79
|
+
app.addHook('onClose', async () => {
|
|
80
|
+
await closeDatabase();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return app;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
declare module 'fastify' {
|
|
87
|
+
interface FastifyInstance {
|
|
88
|
+
db: typeof db;
|
|
89
|
+
}
|
|
90
|
+
}
|