weifuwu 0.25.2 → 0.27.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 +291 -2489
- package/ai/provider.ts +129 -0
- package/ai/stream.ts +63 -0
- package/cli.ts +55 -257
- package/core/cookie.ts +114 -0
- package/core/env.ts +142 -0
- package/core/logger.ts +72 -0
- package/core/router.ts +795 -0
- package/core/serve.ts +294 -0
- package/core/sse.ts +85 -0
- package/core/trace.ts +146 -0
- package/graphql.ts +267 -0
- package/hub.ts +133 -0
- package/index.ts +71 -0
- package/mailer.ts +81 -0
- package/middleware/compress.ts +103 -0
- package/middleware/cors.ts +81 -0
- package/middleware/csrf.ts +112 -0
- package/middleware/flash.ts +144 -0
- package/middleware/health.ts +44 -0
- package/middleware/helmet.ts +98 -0
- package/middleware/i18n.ts +175 -0
- package/middleware/rate-limit.ts +167 -0
- package/middleware/request-id.ts +60 -0
- package/middleware/static.ts +149 -0
- package/middleware/theme.ts +84 -0
- package/middleware/upload.ts +168 -0
- package/middleware/validate.ts +186 -0
- package/package.json +14 -36
- package/postgres/client.ts +132 -0
- package/postgres/index.ts +4 -0
- package/postgres/module.ts +37 -0
- package/postgres/schema/columns.ts +186 -0
- package/postgres/schema/index.ts +36 -0
- package/postgres/schema/sql.ts +39 -0
- package/postgres/schema/table.ts +548 -0
- package/postgres/schema/where.ts +99 -0
- package/postgres/types.ts +48 -0
- package/queue/cron.ts +90 -0
- package/queue/index.ts +654 -0
- package/queue/types.ts +60 -0
- package/redis/client.ts +24 -0
- package/{dist/redis/index.d.ts → redis/index.ts} +2 -2
- package/redis/types.ts +28 -0
- package/types.ts +78 -0
- package/cli/template/app.ts +0 -22
- package/cli/template/index.ts +0 -10
- package/cli/template/locales/en.json +0 -13
- package/cli/template/locales/zh-CN.json +0 -13
- package/cli/template/locales/zh-TW.json +0 -13
- package/cli/template/locales/zh.json +0 -13
- package/cli/template/ui/app/globals.css +0 -2
- package/cli/template/ui/app/layout.tsx +0 -15
- package/cli/template/ui/app/page.tsx +0 -124
- package/cli/template/ui/components/Greeting.tsx +0 -3
- package/dist/agent/client.d.ts +0 -2
- package/dist/agent/index.d.ts +0 -2
- package/dist/agent/rest.d.ts +0 -14
- package/dist/agent/run.d.ts +0 -19
- package/dist/agent/types.d.ts +0 -55
- package/dist/ai/provider.d.ts +0 -45
- package/dist/ai/utils.d.ts +0 -5
- package/dist/ai/workflow.d.ts +0 -17
- package/dist/ai-sdk.d.ts +0 -2
- package/dist/ai.d.ts +0 -13
- package/dist/analytics.d.ts +0 -45
- package/dist/auth.d.ts +0 -22
- package/dist/cache.d.ts +0 -74
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -302
- package/dist/client-locale.d.ts +0 -25
- package/dist/client-pref.d.ts +0 -3
- package/dist/client-router.d.ts +0 -300
- package/dist/client-state.d.ts +0 -22
- package/dist/client-theme.d.ts +0 -36
- package/dist/compile.d.ts +0 -15
- package/dist/compress.d.ts +0 -20
- package/dist/cookie.d.ts +0 -36
- package/dist/cors.d.ts +0 -25
- package/dist/cron-utils.d.ts +0 -73
- package/dist/csrf.d.ts +0 -47
- package/dist/deploy/config.d.ts +0 -2
- package/dist/deploy/gateway.d.ts +0 -2
- package/dist/deploy/index.d.ts +0 -4
- package/dist/deploy/manager.d.ts +0 -16
- package/dist/deploy/process.d.ts +0 -14
- package/dist/deploy/types.d.ts +0 -53
- package/dist/env.d.ts +0 -69
- package/dist/error-boundary.d.ts +0 -2
- package/dist/flash.d.ts +0 -90
- package/dist/fts.d.ts +0 -36
- package/dist/graphql.d.ts +0 -16
- package/dist/head.d.ts +0 -6
- package/dist/health.d.ts +0 -24
- package/dist/helmet.d.ts +0 -33
- package/dist/html-shell.d.ts +0 -1
- package/dist/hub.d.ts +0 -37
- package/dist/i18n.d.ts +0 -39
- package/dist/iii/client.d.ts +0 -2
- package/dist/iii/index.d.ts +0 -4
- package/dist/iii/register-worker.d.ts +0 -9
- package/dist/iii/rest.d.ts +0 -3
- package/dist/iii/stream.d.ts +0 -82
- package/dist/iii/types.d.ts +0 -121
- package/dist/iii/worker.d.ts +0 -2
- package/dist/iii/ws.d.ts +0 -22
- package/dist/index.d.ts +0 -101
- package/dist/index.js +0 -12752
- package/dist/kb/index.d.ts +0 -3
- package/dist/kb/types.d.ts +0 -72
- package/dist/layout.d.ts +0 -2
- package/dist/live.d.ts +0 -7
- package/dist/logdb/client.d.ts +0 -2
- package/dist/logdb/index.d.ts +0 -2
- package/dist/logdb/rest.d.ts +0 -5
- package/dist/logdb/types.d.ts +0 -27
- package/dist/logger.d.ts +0 -16
- package/dist/mailer.d.ts +0 -51
- package/dist/mcp.d.ts +0 -34
- package/dist/messager/agent.d.ts +0 -11
- package/dist/messager/client.d.ts +0 -2
- package/dist/messager/index.d.ts +0 -2
- package/dist/messager/rest.d.ts +0 -15
- package/dist/messager/types.d.ts +0 -57
- package/dist/messager/ws.d.ts +0 -14
- package/dist/module-server.d.ts +0 -9
- package/dist/not-found.d.ts +0 -2
- package/dist/notifier/client.d.ts +0 -2
- package/dist/notifier/index.d.ts +0 -2
- package/dist/notifier/types.d.ts +0 -105
- package/dist/opencode/client.d.ts +0 -2
- package/dist/opencode/index.d.ts +0 -2
- package/dist/opencode/permissions.d.ts +0 -5
- package/dist/opencode/prompt.d.ts +0 -8
- package/dist/opencode/rest.d.ts +0 -16
- package/dist/opencode/run.d.ts +0 -13
- package/dist/opencode/session.d.ts +0 -26
- package/dist/opencode/skills.d.ts +0 -4
- package/dist/opencode/tools/bash.d.ts +0 -6
- package/dist/opencode/tools/edit.d.ts +0 -19
- package/dist/opencode/tools/glob.d.ts +0 -9
- package/dist/opencode/tools/grep.d.ts +0 -17
- package/dist/opencode/tools/index.d.ts +0 -12
- package/dist/opencode/tools/question.d.ts +0 -5
- package/dist/opencode/tools/read.d.ts +0 -16
- package/dist/opencode/tools/skill.d.ts +0 -18
- package/dist/opencode/tools/web.d.ts +0 -18
- package/dist/opencode/tools/write.d.ts +0 -13
- package/dist/opencode/types.d.ts +0 -90
- package/dist/opencode/ws.d.ts +0 -21
- package/dist/permissions.d.ts +0 -51
- package/dist/postgres/client.d.ts +0 -4
- package/dist/postgres/index.d.ts +0 -4
- package/dist/postgres/module.d.ts +0 -17
- package/dist/postgres/schema/columns.d.ts +0 -99
- package/dist/postgres/schema/index.d.ts +0 -6
- package/dist/postgres/schema/sql.d.ts +0 -22
- package/dist/postgres/schema/table.d.ts +0 -141
- package/dist/postgres/schema/where.d.ts +0 -29
- package/dist/postgres/types.d.ts +0 -50
- package/dist/queue/index.d.ts +0 -2
- package/dist/queue/types.d.ts +0 -62
- package/dist/rate-limit.d.ts +0 -45
- package/dist/react.d.ts +0 -14
- package/dist/react.js +0 -751
- package/dist/redis/client.d.ts +0 -2
- package/dist/redis/types.d.ts +0 -18
- package/dist/request-id.d.ts +0 -40
- package/dist/router.d.ts +0 -73
- package/dist/s3.d.ts +0 -68
- package/dist/seo.d.ts +0 -104
- package/dist/serve.d.ts +0 -38
- package/dist/server-registry.d.ts +0 -10
- package/dist/session.d.ts +0 -117
- package/dist/sse.d.ts +0 -47
- package/dist/ssr-entries.d.ts +0 -4
- package/dist/ssr.d.ts +0 -11
- package/dist/static.d.ts +0 -23
- package/dist/stream.d.ts +0 -24
- package/dist/tailwind.d.ts +0 -15
- package/dist/tenant/client.d.ts +0 -2
- package/dist/tenant/graphql.d.ts +0 -3
- package/dist/tenant/index.d.ts +0 -2
- package/dist/tenant/rest.d.ts +0 -3
- package/dist/tenant/schema.d.ts +0 -5
- package/dist/tenant/types.d.ts +0 -48
- package/dist/tenant/utils.d.ts +0 -9
- package/dist/test-utils.d.ts +0 -194
- package/dist/theme.d.ts +0 -31
- package/dist/trace.d.ts +0 -95
- package/dist/tsx-context.d.ts +0 -32
- package/dist/types.d.ts +0 -47
- package/dist/upload.d.ts +0 -55
- package/dist/use-action.d.ts +0 -42
- package/dist/use-agent-stream.d.ts +0 -49
- package/dist/use-flash-message.d.ts +0 -17
- package/dist/use-websocket.d.ts +0 -42
- package/dist/user/client.d.ts +0 -30
- package/dist/user/index.d.ts +0 -2
- package/dist/user/oauth-login.d.ts +0 -21
- package/dist/user/oauth2.d.ts +0 -31
- package/dist/user/types.d.ts +0 -178
- package/dist/validate.d.ts +0 -32
- package/dist/vendor.d.ts +0 -7
- package/dist/webhook.d.ts +0 -79
- package/opencode/ui/app/globals.css +0 -1
- package/opencode/ui/app/layout.tsx +0 -13
- package/opencode/ui/app/page.tsx +0 -523
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { ZodSchema } from 'zod'
|
|
2
|
+
import type { Middleware } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
// Augment Context with parsed property (shared with upload)
|
|
5
|
+
declare module '../types.ts' {
|
|
6
|
+
interface Context {
|
|
7
|
+
parsed: Record<string, unknown>
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Validation middleware — a {@link Middleware} that injects `ctx.parsed` with validated data. */
|
|
12
|
+
export type ValidateModule = Middleware
|
|
13
|
+
|
|
14
|
+
export interface ValidationSchemas {
|
|
15
|
+
body?: ZodSchema
|
|
16
|
+
query?: ZodSchema
|
|
17
|
+
params?: ZodSchema
|
|
18
|
+
headers?: ZodSchema
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse application/x-www-form-urlencoded body string into Record<string, string>.
|
|
23
|
+
* Duplicate keys become comma-joined string (most common HTML form behavior).
|
|
24
|
+
*/
|
|
25
|
+
function parseFormBody(text: string): Record<string, string> {
|
|
26
|
+
const params = new URLSearchParams(text)
|
|
27
|
+
const result: Record<string, string> = {}
|
|
28
|
+
for (const [key, value] of params) {
|
|
29
|
+
// Collapse duplicates: last value wins (matches standard server behavior)
|
|
30
|
+
result[key] = value
|
|
31
|
+
}
|
|
32
|
+
return result
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Parse body text based on content-type. Returns parsed value or raw string.
|
|
36
|
+
// Rules:
|
|
37
|
+
// - application/x-www-form-urlencoded => Record<string, string> via URLSearchParams
|
|
38
|
+
// - application/json, text/*, vendor+json, or any non-form/non-multipart => try JSON.parse
|
|
39
|
+
// - multipart/form-data => raw string (handled by upload())
|
|
40
|
+
// - fallback => raw string
|
|
41
|
+
function parseBody(text: string, ct: string): unknown {
|
|
42
|
+
if (ct.includes('application/x-www-form-urlencoded')) {
|
|
43
|
+
return parseFormBody(text)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Try JSON parse when:
|
|
47
|
+
// - Content-Type explicitly indicates JSON (application/json, *+json)
|
|
48
|
+
// - Content-Type is text/*
|
|
49
|
+
// - Content-Type is not multipart and not urlencoded (catch-all for API types)
|
|
50
|
+
const isExplicitJson =
|
|
51
|
+
ct.includes('application/json') ||
|
|
52
|
+
ct.includes('+json') ||
|
|
53
|
+
ct.includes('text/') ||
|
|
54
|
+
ct.includes('*/json')
|
|
55
|
+
const isNotSpecialMultipart =
|
|
56
|
+
!ct.includes('multipart/form-data') && !ct.includes('application/x-www-form-urlencoded')
|
|
57
|
+
|
|
58
|
+
if (isExplicitJson || isNotSpecialMultipart) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(text)
|
|
61
|
+
} catch {
|
|
62
|
+
// keep raw string
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return text
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Request validation middleware using Zod schemas.
|
|
71
|
+
*
|
|
72
|
+
* Validates `params`, `query`, `body`, and/or `headers` against schemas.
|
|
73
|
+
* Returns 422 with error details on mismatch.
|
|
74
|
+
* Injects `ctx.parsed` with validated-and-transformed values.
|
|
75
|
+
*
|
|
76
|
+
* ```ts
|
|
77
|
+
* import { z } from 'zod'
|
|
78
|
+
*
|
|
79
|
+
* app.get('/users/:id', validate({
|
|
80
|
+
* params: z.object({ id: z.string() }),
|
|
81
|
+
* query: z.object({ include: z.string().optional() }),
|
|
82
|
+
* }), handler)
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function validate(schemas?: ValidationSchemas): Middleware {
|
|
86
|
+
const mw: Middleware = async (req, ctx, next) => {
|
|
87
|
+
const parsed: Record<string, unknown> = {}
|
|
88
|
+
const issues: { path: string[]; message: string }[] = []
|
|
89
|
+
|
|
90
|
+
if (schemas?.params) {
|
|
91
|
+
const result = schemas.params.safeParse(ctx.params)
|
|
92
|
+
if (result.success) {
|
|
93
|
+
parsed.params = result.data
|
|
94
|
+
} else {
|
|
95
|
+
issues.push(
|
|
96
|
+
...result.error.issues.map((i) => ({
|
|
97
|
+
path: ['params', ...i.path.map(String)],
|
|
98
|
+
message: i.message,
|
|
99
|
+
})),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (schemas?.query) {
|
|
105
|
+
const result = schemas.query.safeParse(ctx.query)
|
|
106
|
+
if (result.success) {
|
|
107
|
+
parsed.query = result.data
|
|
108
|
+
} else {
|
|
109
|
+
issues.push(
|
|
110
|
+
...result.error.issues.map((i) => ({
|
|
111
|
+
path: ['query', ...i.path.map(String)],
|
|
112
|
+
message: i.message,
|
|
113
|
+
})),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (schemas?.headers) {
|
|
119
|
+
const rawHeaders: Record<string, string> = {}
|
|
120
|
+
req.headers.forEach((v, k) => {
|
|
121
|
+
rawHeaders[k] = v
|
|
122
|
+
})
|
|
123
|
+
const result = schemas.headers.safeParse(rawHeaders)
|
|
124
|
+
if (result.success) {
|
|
125
|
+
parsed.headers = result.data
|
|
126
|
+
} else {
|
|
127
|
+
issues.push(
|
|
128
|
+
...result.error.issues.map((i) => ({
|
|
129
|
+
path: ['headers', ...i.path.map(String)],
|
|
130
|
+
message: i.message,
|
|
131
|
+
})),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Always attempt body parsing for non-GET/HEAD methods
|
|
137
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
138
|
+
const ct = req.headers.get('content-type') ?? ''
|
|
139
|
+
const isForm = ct.includes('application/x-www-form-urlencoded')
|
|
140
|
+
|
|
141
|
+
// Parse body if: schema asks for it, OR it's a form (no schema needed)
|
|
142
|
+
if (schemas?.body || isForm) {
|
|
143
|
+
if (req.body === null) {
|
|
144
|
+
if (schemas?.body) {
|
|
145
|
+
issues.push({ path: ['body'], message: 'Request body is required' })
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
const bodyText = await req.text()
|
|
149
|
+
if (!bodyText) {
|
|
150
|
+
if (schemas?.body) {
|
|
151
|
+
issues.push({ path: ['body'], message: 'Request body is required' })
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
const bodyValue = parseBody(bodyText, ct)
|
|
155
|
+
if (schemas?.body) {
|
|
156
|
+
const result = schemas.body.safeParse(bodyValue)
|
|
157
|
+
if (result.success) {
|
|
158
|
+
parsed.body = result.data
|
|
159
|
+
} else {
|
|
160
|
+
issues.push(
|
|
161
|
+
...result.error.issues.map((i) => ({
|
|
162
|
+
path: ['body', ...i.path.map(String)],
|
|
163
|
+
message: i.message,
|
|
164
|
+
})),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
// No schema: still populate ctx.parsed.body with parsed value
|
|
169
|
+
// (for form-urlencoded, this is a Record<string, string>)
|
|
170
|
+
parsed.body = bodyValue as Record<string, string>
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (issues.length > 0) {
|
|
178
|
+
return Response.json({ error: 'Validation failed', issues }, { status: 400 })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
ctx.parsed = { ...ctx.parsed, ...parsed }
|
|
182
|
+
return next(req, ctx)
|
|
183
|
+
}
|
|
184
|
+
mw.__meta = { injects: ['parsed'], depends: [] }
|
|
185
|
+
return mw
|
|
186
|
+
}
|
package/package.json
CHANGED
|
@@ -1,63 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weifuwu",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Web-standard HTTP
|
|
6
|
-
"main": "./dist/index.js",
|
|
7
|
-
"types": "./dist/index.d.ts",
|
|
4
|
+
"version": "0.27.0",
|
|
5
|
+
"description": "Web-standard HTTP microframework for Node.js — (req, ctx) => Response",
|
|
8
6
|
"exports": {
|
|
9
|
-
".": "./
|
|
10
|
-
"./react": "./dist/react.js"
|
|
7
|
+
".": "./index.ts"
|
|
11
8
|
},
|
|
12
9
|
"bin": {
|
|
13
|
-
"weifuwu": "
|
|
10
|
+
"weifuwu": "./cli.ts"
|
|
14
11
|
},
|
|
15
12
|
"files": [
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
13
|
+
"*.ts",
|
|
14
|
+
"core/*.ts",
|
|
15
|
+
"middleware/*.ts",
|
|
16
|
+
"ai/*.ts",
|
|
17
|
+
"postgres/*.ts",
|
|
18
|
+
"postgres/**/*.ts",
|
|
19
|
+
"queue/*.ts",
|
|
20
|
+
"redis/*.ts"
|
|
22
21
|
],
|
|
23
22
|
"scripts": {
|
|
24
|
-
"dev": "cd cli/template && NODE_ENV=development node --watch index.ts",
|
|
25
|
-
"start": "cd cli/template && NODE_ENV=production node index.ts",
|
|
26
|
-
"build": "esbuild index.ts --bundle --format=esm --platform=node --outfile=dist/index.js --packages=external --define:__WFW_BUNDLED__=true && esbuild cli.ts --bundle --format=esm --platform=node --outfile=dist/cli.js --packages=external && esbuild react.ts --bundle --format=esm --outfile=dist/react.js --external:react --external:react-dom",
|
|
27
|
-
"prepublishOnly": "npm run build && tsc --emitDeclarationOnly --outdir dist",
|
|
28
23
|
"typecheck": "tsc --noEmit",
|
|
29
24
|
"format": "prettier --write .",
|
|
30
25
|
"format:check": "prettier --check .",
|
|
31
|
-
"lint": "eslint --ext .ts
|
|
26
|
+
"lint": "eslint --ext .ts .",
|
|
32
27
|
"test": "node --test 'test/**/*.test.ts'",
|
|
33
28
|
"test:coverage": "node --experimental-test-coverage --test 'test/**/*.test.ts'",
|
|
34
|
-
"test:typecheck": "npx tsc -p tsconfig.test.json --noEmit",
|
|
35
29
|
"test:quick": "bash scripts/test-quick.sh",
|
|
36
|
-
"test:ci": "node --test --test-force-exit --test-timeout=60000 'test/**/*.test.ts'",
|
|
37
30
|
"prepare": "husky"
|
|
38
31
|
},
|
|
39
32
|
"dependencies": {
|
|
40
33
|
"@ai-sdk/openai": "^3.0.66",
|
|
41
|
-
"@aws-sdk/client-s3": "^3.1068.0",
|
|
42
|
-
"@aws-sdk/s3-request-presigner": "^3.1068.0",
|
|
43
34
|
"@graphql-tools/schema": "^10",
|
|
44
|
-
"@tailwindcss/postcss": "^4",
|
|
45
35
|
"ai": "^6",
|
|
46
|
-
"chokidar": "^5.0.0",
|
|
47
|
-
"esbuild": "^0.28.0",
|
|
48
36
|
"graphql": "^16",
|
|
49
37
|
"ioredis": "^5.11.0",
|
|
50
|
-
"jsonwebtoken": "^9.0.3",
|
|
51
38
|
"nodemailer": "^8.0.10",
|
|
52
39
|
"postgres": "^3.4.9",
|
|
53
|
-
"react": "^19",
|
|
54
|
-
"react-dom": "^19",
|
|
55
40
|
"ws": "^8",
|
|
56
|
-
"yaml": "^2.9.0",
|
|
57
41
|
"zod": "^4.4.3"
|
|
58
42
|
},
|
|
59
43
|
"lint-staged": {
|
|
60
|
-
"*.
|
|
44
|
+
"*.ts": [
|
|
61
45
|
"prettier --write",
|
|
62
46
|
"eslint --fix"
|
|
63
47
|
],
|
|
@@ -67,20 +51,14 @@
|
|
|
67
51
|
},
|
|
68
52
|
"devDependencies": {
|
|
69
53
|
"@eslint/js": "^10.0.1",
|
|
70
|
-
"@types/jsonwebtoken": "^9.0.9",
|
|
71
54
|
"@types/node": "^25.9.3",
|
|
72
55
|
"@types/nodemailer": "^6.4.17",
|
|
73
|
-
"@types/react": "^19.2.17",
|
|
74
|
-
"@types/react-dom": "^19.2.3",
|
|
75
56
|
"@types/ws": "^8.18.1",
|
|
76
57
|
"eslint": "^10.5.0",
|
|
77
58
|
"globals": "^17.6.0",
|
|
78
|
-
"happy-dom": "^20.10.3",
|
|
79
59
|
"husky": "^9.1.7",
|
|
80
60
|
"lint-staged": "^17.0.7",
|
|
81
|
-
"postcss": "^8.5.3",
|
|
82
61
|
"prettier": "^3.8.4",
|
|
83
|
-
"tailwindcss": "^4.0.0",
|
|
84
62
|
"typescript-eslint": "^8.61.0"
|
|
85
63
|
}
|
|
86
64
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import postgresFactory from 'postgres'
|
|
3
|
+
import type { Context, Handler } from '../types.ts'
|
|
4
|
+
import type { PostgresOptions, PostgresClient } from './types.ts'
|
|
5
|
+
import { BoundTable, Table } from './schema/table.ts'
|
|
6
|
+
import type { ColumnBuilder } from './schema/columns.ts'
|
|
7
|
+
|
|
8
|
+
/** Migration tracking table name. Created automatically on first migrate(). */
|
|
9
|
+
export const MIGRATIONS_TABLE = '_weifuwu_migrations'
|
|
10
|
+
|
|
11
|
+
/** PostgreSQL error codes that are safe to retry. */
|
|
12
|
+
const RETRYABLE_CODES = new Set(['40P01', '40001'])
|
|
13
|
+
|
|
14
|
+
function isRetryable(err: unknown): boolean {
|
|
15
|
+
return err instanceof Error && 'code' in err && RETRYABLE_CODES.has((err as any).code)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function postgres(opts?: string | PostgresOptions): PostgresClient {
|
|
19
|
+
const options: PostgresOptions = typeof opts === 'string' ? { connection: opts } : (opts ?? {})
|
|
20
|
+
|
|
21
|
+
const connection = options.connection ?? process.env.DATABASE_URL
|
|
22
|
+
if (!connection) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
'postgres: DATABASE_URL is not set. Pass a connection string or set the DATABASE_URL environment variable.',
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const stmtTimeout = options.statementTimeout ?? 30_000
|
|
29
|
+
// Inject statement_timeout via connection options parameter.
|
|
30
|
+
// URL-encoded: SET statement_timeout = <ms>
|
|
31
|
+
let connStr = typeof connection === 'string' ? connection : ''
|
|
32
|
+
if (stmtTimeout > 0 && typeof connection === 'string') {
|
|
33
|
+
const sep = connStr.includes('?') ? '&' : '?'
|
|
34
|
+
connStr = `${connStr}${sep}options=-c%20statement_timeout%3D${stmtTimeout}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sql = postgresFactory(connStr as any, {
|
|
38
|
+
max: options.max,
|
|
39
|
+
ssl: options.ssl,
|
|
40
|
+
idle_timeout: options.idle_timeout,
|
|
41
|
+
connect_timeout: options.connect_timeout,
|
|
42
|
+
}) as any
|
|
43
|
+
|
|
44
|
+
if (options.signal) {
|
|
45
|
+
options.signal.addEventListener(
|
|
46
|
+
'abort',
|
|
47
|
+
() => {
|
|
48
|
+
sql.end()
|
|
49
|
+
},
|
|
50
|
+
{ once: true },
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const closeTimeout = options.closeTimeout ?? 5
|
|
55
|
+
|
|
56
|
+
// ── Connection pool tracking ────────────────────────────────────
|
|
57
|
+
const _active = 0
|
|
58
|
+
const _waiting = 0
|
|
59
|
+
const poolMax = options.max ?? 10
|
|
60
|
+
|
|
61
|
+
const mw = ((req: Request, ctx: Context, next: Handler) => {
|
|
62
|
+
ctx.sql = sql
|
|
63
|
+
return next(req, ctx)
|
|
64
|
+
}) as unknown as PostgresClient
|
|
65
|
+
mw.__meta = { injects: ['sql'], depends: [] }
|
|
66
|
+
|
|
67
|
+
mw.sql = sql
|
|
68
|
+
mw.table = ((
|
|
69
|
+
tableOrSchema: string | Table<any>,
|
|
70
|
+
builders?: Record<string, ColumnBuilder<unknown>>,
|
|
71
|
+
) => {
|
|
72
|
+
if (typeof tableOrSchema === 'string') {
|
|
73
|
+
return new BoundTable(sql, tableOrSchema, builders!)
|
|
74
|
+
}
|
|
75
|
+
return new BoundTable(sql, tableOrSchema.tableName, tableOrSchema.builders)
|
|
76
|
+
}) as any
|
|
77
|
+
|
|
78
|
+
mw.migrate = async () => {
|
|
79
|
+
await sql.unsafe(`
|
|
80
|
+
CREATE TABLE IF NOT EXISTS "${MIGRATIONS_TABLE}" (
|
|
81
|
+
name TEXT PRIMARY KEY,
|
|
82
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
83
|
+
)
|
|
84
|
+
`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
mw.markMigrated = async (moduleName: string) => {
|
|
88
|
+
await sql.unsafe(
|
|
89
|
+
`INSERT INTO "${MIGRATIONS_TABLE}" (name) VALUES ($1) ON CONFLICT DO NOTHING`,
|
|
90
|
+
[moduleName],
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
mw.isMigrated = async (moduleName: string): Promise<boolean> => {
|
|
95
|
+
const [row] = (await sql.unsafe(`SELECT 1 FROM "${MIGRATIONS_TABLE}" WHERE name = $1`, [
|
|
96
|
+
moduleName,
|
|
97
|
+
])) as any[]
|
|
98
|
+
return !!row
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Transaction with retry ──────────────────────────────────────
|
|
102
|
+
mw.transaction = (async (fn: any, retryOpts?: { maxRetries?: number }) => {
|
|
103
|
+
const maxRetries = retryOpts?.maxRetries ?? 3
|
|
104
|
+
|
|
105
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
106
|
+
try {
|
|
107
|
+
const result = await sql.begin(fn)
|
|
108
|
+
return result
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (attempt < maxRetries && isRetryable(err)) {
|
|
111
|
+
const delay = Math.min(100 * Math.pow(2, attempt - 1), 1000)
|
|
112
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
throw err
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Unreachable — last attempt throws above
|
|
119
|
+
throw new Error('transaction: max retries exceeded')
|
|
120
|
+
}) as any
|
|
121
|
+
|
|
122
|
+
mw.poolStats = () => ({
|
|
123
|
+
active: _active,
|
|
124
|
+
idle: poolMax - _active - _waiting,
|
|
125
|
+
waiting: _waiting,
|
|
126
|
+
max: poolMax,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
mw.close = () => sql.end({ timeout: closeTimeout })
|
|
130
|
+
|
|
131
|
+
return mw
|
|
132
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { PostgresClient } from './types.ts'
|
|
2
|
+
|
|
3
|
+
import type { SqlClient, Closeable } from '../types.ts'
|
|
4
|
+
import type { ColumnBuilder, BoundTable, Table } from './schema/index.ts'
|
|
5
|
+
|
|
6
|
+
export class PgModule implements Closeable {
|
|
7
|
+
protected sql: SqlClient
|
|
8
|
+
protected pg: PostgresClient
|
|
9
|
+
|
|
10
|
+
constructor(pg: PostgresClient) {
|
|
11
|
+
this.pg = pg
|
|
12
|
+
this.sql = pg.sql
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
table<R extends Record<string, unknown>>(
|
|
16
|
+
tableOrSchema: string | Table<R>,
|
|
17
|
+
builders?: { [K in keyof R]: ColumnBuilder<R[K]> },
|
|
18
|
+
): BoundTable<R> {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
return this.pg.table(tableOrSchema as any, builders as any)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async transaction<T>(
|
|
24
|
+
fn: (sql: SqlClient) => Promise<T>,
|
|
25
|
+
retryOpts?: { maxRetries?: number },
|
|
26
|
+
): Promise<T> {
|
|
27
|
+
return await this.pg.transaction(fn, retryOpts)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async migrate(): Promise<void> {
|
|
31
|
+
// override in subclasses
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async close(): Promise<void> {
|
|
35
|
+
await this.pg.close()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
import { SQL, sql } from './sql.ts'
|
|
3
|
+
|
|
4
|
+
/** Reference to another table's column (foreign key). */
|
|
5
|
+
export interface ColumnReference {
|
|
6
|
+
/** Referenced table name. */
|
|
7
|
+
table: string
|
|
8
|
+
/** Referenced column name (default: `'id'`). */
|
|
9
|
+
column: string
|
|
10
|
+
/** `ON DELETE` action (e.g. `'cascade'`, `'set null'`). */
|
|
11
|
+
onDelete?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fluent column builder for DDL generation.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* text('name').notNull().unique()
|
|
19
|
+
* integer('user_id').references('users')
|
|
20
|
+
* timestamptz('created_at').default(sql`NOW()`)
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
// @ts-nocheck - T is used at type level
|
|
24
|
+
export class ColumnBuilder<T> {
|
|
25
|
+
/** Column name. */
|
|
26
|
+
name: string
|
|
27
|
+
/** SQL type string (e.g. `'TEXT'`, `'INTEGER'`). */
|
|
28
|
+
sqlType: string
|
|
29
|
+
/** Whether this column is PRIMARY KEY. */
|
|
30
|
+
isPrimaryKey = false
|
|
31
|
+
/** Whether this column allows NULL. */
|
|
32
|
+
isNullable = true
|
|
33
|
+
/** Whether this column has a UNIQUE constraint. */
|
|
34
|
+
isUnique = false
|
|
35
|
+
/** Whether the value is auto-generated (e.g. SERIAL, UUID defaults). */
|
|
36
|
+
isAutoGenerate = false
|
|
37
|
+
/** DEFAULT expression as a raw SQL string. */
|
|
38
|
+
defaultExpr: string | null = null
|
|
39
|
+
/** Foreign key reference, if any. */
|
|
40
|
+
ref: ColumnReference | null = null
|
|
41
|
+
|
|
42
|
+
constructor(name: string, sqlType: string) {
|
|
43
|
+
this.name = name
|
|
44
|
+
this.sqlType = sqlType
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Mark as PRIMARY KEY (implies NOT NULL). */
|
|
48
|
+
primaryKey(): this {
|
|
49
|
+
this.isPrimaryKey = true
|
|
50
|
+
this.isNullable = false
|
|
51
|
+
return this
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Add NOT NULL constraint. */
|
|
55
|
+
notNull(): this {
|
|
56
|
+
this.isNullable = false
|
|
57
|
+
return this
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Allow NULL values (default). */
|
|
61
|
+
nullable(): this {
|
|
62
|
+
this.isNullable = true
|
|
63
|
+
return this
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Set a DEFAULT value. Accepts raw SQL, string, number, or boolean. */
|
|
67
|
+
default(expr: SQL | string | number | boolean): this {
|
|
68
|
+
if (expr instanceof SQL) {
|
|
69
|
+
this.defaultExpr = expr.toSQL()
|
|
70
|
+
} else if (typeof expr === 'string') {
|
|
71
|
+
this.defaultExpr = `'${expr.replace(/'/g, "''")}'`
|
|
72
|
+
} else {
|
|
73
|
+
this.defaultExpr = String(expr)
|
|
74
|
+
}
|
|
75
|
+
return this
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Add UNIQUE constraint. */
|
|
79
|
+
unique(): this {
|
|
80
|
+
this.isUnique = true
|
|
81
|
+
return this
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Add FOREIGN KEY reference to another table. */
|
|
85
|
+
references(table: string, column = 'id', onDelete?: string): this {
|
|
86
|
+
this.ref = { table, column, onDelete }
|
|
87
|
+
return this
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function col<T>(name: string, sqlType: string): ColumnBuilder<T> {
|
|
92
|
+
return new ColumnBuilder<T>(name, sqlType)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Auto-incrementing integer primary key (`SERIAL`). */
|
|
96
|
+
export function serial(name: string) {
|
|
97
|
+
const c = col<number>(name, 'SERIAL')
|
|
98
|
+
c.isAutoGenerate = true
|
|
99
|
+
return c
|
|
100
|
+
}
|
|
101
|
+
/** UUID column. */
|
|
102
|
+
export function uuid(name: string) {
|
|
103
|
+
return col<string>(name, 'UUID')
|
|
104
|
+
}
|
|
105
|
+
/** TEXT column. */
|
|
106
|
+
export function text(name: string) {
|
|
107
|
+
return col<string>(name, 'TEXT')
|
|
108
|
+
}
|
|
109
|
+
/** INTEGER column. */
|
|
110
|
+
export function integer(name: string) {
|
|
111
|
+
return col<number>(name, 'INTEGER')
|
|
112
|
+
}
|
|
113
|
+
/** BOOLEAN column (exported as `boolean`). */
|
|
114
|
+
export function boolean_(name: string) {
|
|
115
|
+
return col<boolean>(name, 'BOOLEAN')
|
|
116
|
+
}
|
|
117
|
+
export { boolean_ as boolean }
|
|
118
|
+
/** TIMESTAMPTZ column (timestamp with time zone). */
|
|
119
|
+
export function timestamptz(name: string) {
|
|
120
|
+
return col<string>(name, 'TIMESTAMPTZ')
|
|
121
|
+
}
|
|
122
|
+
/** JSONB column (stores arbitrary JSON data). */
|
|
123
|
+
export function jsonb<T = unknown>(name: string) {
|
|
124
|
+
return col<T>(name, 'JSONB')
|
|
125
|
+
}
|
|
126
|
+
/** TEXT[] column (PostgreSQL array of text). */
|
|
127
|
+
export function textArray(name: string) {
|
|
128
|
+
return col<string[]>(name, 'TEXT[]')
|
|
129
|
+
}
|
|
130
|
+
/** Vector column for pgvector (embedding storage). Requires `dimensions`. */
|
|
131
|
+
export function vector(name: string, dims: number) {
|
|
132
|
+
return col<number[]>(name, `vector(${dims})`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface PartitionByDef {
|
|
136
|
+
type: 'RANGE' | 'LIST' | 'HASH'
|
|
137
|
+
column: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function partitionBy(type: 'range' | 'list' | 'hash', column: string): PartitionByDef {
|
|
141
|
+
return { type: type.toUpperCase() as 'RANGE' | 'LIST' | 'HASH', column }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create a pair of `created_at` / `updated_at` timestamp columns
|
|
146
|
+
* that default to `NOW()` and are NOT NULL.
|
|
147
|
+
*
|
|
148
|
+
* ```ts
|
|
149
|
+
* pgTable('users', {
|
|
150
|
+
* id: serial('id').primaryKey(),
|
|
151
|
+
* name: text('name'),
|
|
152
|
+
* ...timestamps(),
|
|
153
|
+
* })
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export function timestamps() {
|
|
157
|
+
return {
|
|
158
|
+
created_at: timestamptz('created_at')
|
|
159
|
+
.notNull()
|
|
160
|
+
.default(sql`NOW()`),
|
|
161
|
+
updated_at: timestamptz('updated_at')
|
|
162
|
+
.notNull()
|
|
163
|
+
.default(sql`NOW()`),
|
|
164
|
+
} as const
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Convert a ColumnBuilder into a DDL column definition string.
|
|
169
|
+
*
|
|
170
|
+
* ```ts
|
|
171
|
+
* toDDL(text('name').notNull())
|
|
172
|
+
* // '"name" TEXT NOT NULL'
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
export function toDDL(col: ColumnBuilder<unknown>): string {
|
|
176
|
+
const parts = [`"${col.name}"`, col.sqlType]
|
|
177
|
+
if (col.isPrimaryKey) parts.push('PRIMARY KEY')
|
|
178
|
+
if (!col.isPrimaryKey && !col.isNullable) parts.push('NOT NULL')
|
|
179
|
+
if (col.isUnique) parts.push('UNIQUE')
|
|
180
|
+
if (col.defaultExpr) parts.push(`DEFAULT ${col.defaultExpr}`)
|
|
181
|
+
if (col.ref) {
|
|
182
|
+
parts.push(`REFERENCES "${col.ref.table}"("${col.ref.column}")`)
|
|
183
|
+
if (col.ref.onDelete) parts.push(`ON DELETE ${col.ref.onDelete.toUpperCase()}`)
|
|
184
|
+
}
|
|
185
|
+
return parts.join(' ')
|
|
186
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export { sql, SQL } from './sql.ts'
|
|
2
|
+
export {
|
|
3
|
+
ColumnBuilder,
|
|
4
|
+
serial,
|
|
5
|
+
uuid,
|
|
6
|
+
text,
|
|
7
|
+
integer,
|
|
8
|
+
boolean as boolean,
|
|
9
|
+
boolean_,
|
|
10
|
+
timestamptz,
|
|
11
|
+
jsonb,
|
|
12
|
+
textArray,
|
|
13
|
+
vector,
|
|
14
|
+
toDDL,
|
|
15
|
+
partitionBy,
|
|
16
|
+
timestamps,
|
|
17
|
+
} from './columns.ts'
|
|
18
|
+
export type { PartitionByDef } from './columns.ts'
|
|
19
|
+
export { pgTable, Table, BoundTable } from './table.ts'
|
|
20
|
+
export type { IndexOptions, FindOptions, CreateOptions } from './table.ts'
|
|
21
|
+
export {
|
|
22
|
+
eq,
|
|
23
|
+
ne,
|
|
24
|
+
gt,
|
|
25
|
+
gte,
|
|
26
|
+
lt,
|
|
27
|
+
lte,
|
|
28
|
+
isNull,
|
|
29
|
+
isNotNull,
|
|
30
|
+
like,
|
|
31
|
+
contains,
|
|
32
|
+
in_,
|
|
33
|
+
and,
|
|
34
|
+
or,
|
|
35
|
+
not,
|
|
36
|
+
} from './where.ts'
|