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.
Files changed (208) hide show
  1. package/README.md +291 -2489
  2. package/ai/provider.ts +129 -0
  3. package/ai/stream.ts +63 -0
  4. package/cli.ts +55 -257
  5. package/core/cookie.ts +114 -0
  6. package/core/env.ts +142 -0
  7. package/core/logger.ts +72 -0
  8. package/core/router.ts +795 -0
  9. package/core/serve.ts +294 -0
  10. package/core/sse.ts +85 -0
  11. package/core/trace.ts +146 -0
  12. package/graphql.ts +267 -0
  13. package/hub.ts +133 -0
  14. package/index.ts +71 -0
  15. package/mailer.ts +81 -0
  16. package/middleware/compress.ts +103 -0
  17. package/middleware/cors.ts +81 -0
  18. package/middleware/csrf.ts +112 -0
  19. package/middleware/flash.ts +144 -0
  20. package/middleware/health.ts +44 -0
  21. package/middleware/helmet.ts +98 -0
  22. package/middleware/i18n.ts +175 -0
  23. package/middleware/rate-limit.ts +167 -0
  24. package/middleware/request-id.ts +60 -0
  25. package/middleware/static.ts +149 -0
  26. package/middleware/theme.ts +84 -0
  27. package/middleware/upload.ts +168 -0
  28. package/middleware/validate.ts +186 -0
  29. package/package.json +14 -36
  30. package/postgres/client.ts +132 -0
  31. package/postgres/index.ts +4 -0
  32. package/postgres/module.ts +37 -0
  33. package/postgres/schema/columns.ts +186 -0
  34. package/postgres/schema/index.ts +36 -0
  35. package/postgres/schema/sql.ts +39 -0
  36. package/postgres/schema/table.ts +548 -0
  37. package/postgres/schema/where.ts +99 -0
  38. package/postgres/types.ts +48 -0
  39. package/queue/cron.ts +90 -0
  40. package/queue/index.ts +654 -0
  41. package/queue/types.ts +60 -0
  42. package/redis/client.ts +24 -0
  43. package/{dist/redis/index.d.ts → redis/index.ts} +2 -2
  44. package/redis/types.ts +28 -0
  45. package/types.ts +78 -0
  46. package/cli/template/app.ts +0 -22
  47. package/cli/template/index.ts +0 -10
  48. package/cli/template/locales/en.json +0 -13
  49. package/cli/template/locales/zh-CN.json +0 -13
  50. package/cli/template/locales/zh-TW.json +0 -13
  51. package/cli/template/locales/zh.json +0 -13
  52. package/cli/template/ui/app/globals.css +0 -2
  53. package/cli/template/ui/app/layout.tsx +0 -15
  54. package/cli/template/ui/app/page.tsx +0 -124
  55. package/cli/template/ui/components/Greeting.tsx +0 -3
  56. package/dist/agent/client.d.ts +0 -2
  57. package/dist/agent/index.d.ts +0 -2
  58. package/dist/agent/rest.d.ts +0 -14
  59. package/dist/agent/run.d.ts +0 -19
  60. package/dist/agent/types.d.ts +0 -55
  61. package/dist/ai/provider.d.ts +0 -45
  62. package/dist/ai/utils.d.ts +0 -5
  63. package/dist/ai/workflow.d.ts +0 -17
  64. package/dist/ai-sdk.d.ts +0 -2
  65. package/dist/ai.d.ts +0 -13
  66. package/dist/analytics.d.ts +0 -45
  67. package/dist/auth.d.ts +0 -22
  68. package/dist/cache.d.ts +0 -74
  69. package/dist/cli.d.ts +0 -2
  70. package/dist/cli.js +0 -302
  71. package/dist/client-locale.d.ts +0 -25
  72. package/dist/client-pref.d.ts +0 -3
  73. package/dist/client-router.d.ts +0 -300
  74. package/dist/client-state.d.ts +0 -22
  75. package/dist/client-theme.d.ts +0 -36
  76. package/dist/compile.d.ts +0 -15
  77. package/dist/compress.d.ts +0 -20
  78. package/dist/cookie.d.ts +0 -36
  79. package/dist/cors.d.ts +0 -25
  80. package/dist/cron-utils.d.ts +0 -73
  81. package/dist/csrf.d.ts +0 -47
  82. package/dist/deploy/config.d.ts +0 -2
  83. package/dist/deploy/gateway.d.ts +0 -2
  84. package/dist/deploy/index.d.ts +0 -4
  85. package/dist/deploy/manager.d.ts +0 -16
  86. package/dist/deploy/process.d.ts +0 -14
  87. package/dist/deploy/types.d.ts +0 -53
  88. package/dist/env.d.ts +0 -69
  89. package/dist/error-boundary.d.ts +0 -2
  90. package/dist/flash.d.ts +0 -90
  91. package/dist/fts.d.ts +0 -36
  92. package/dist/graphql.d.ts +0 -16
  93. package/dist/head.d.ts +0 -6
  94. package/dist/health.d.ts +0 -24
  95. package/dist/helmet.d.ts +0 -33
  96. package/dist/html-shell.d.ts +0 -1
  97. package/dist/hub.d.ts +0 -37
  98. package/dist/i18n.d.ts +0 -39
  99. package/dist/iii/client.d.ts +0 -2
  100. package/dist/iii/index.d.ts +0 -4
  101. package/dist/iii/register-worker.d.ts +0 -9
  102. package/dist/iii/rest.d.ts +0 -3
  103. package/dist/iii/stream.d.ts +0 -82
  104. package/dist/iii/types.d.ts +0 -121
  105. package/dist/iii/worker.d.ts +0 -2
  106. package/dist/iii/ws.d.ts +0 -22
  107. package/dist/index.d.ts +0 -101
  108. package/dist/index.js +0 -12752
  109. package/dist/kb/index.d.ts +0 -3
  110. package/dist/kb/types.d.ts +0 -72
  111. package/dist/layout.d.ts +0 -2
  112. package/dist/live.d.ts +0 -7
  113. package/dist/logdb/client.d.ts +0 -2
  114. package/dist/logdb/index.d.ts +0 -2
  115. package/dist/logdb/rest.d.ts +0 -5
  116. package/dist/logdb/types.d.ts +0 -27
  117. package/dist/logger.d.ts +0 -16
  118. package/dist/mailer.d.ts +0 -51
  119. package/dist/mcp.d.ts +0 -34
  120. package/dist/messager/agent.d.ts +0 -11
  121. package/dist/messager/client.d.ts +0 -2
  122. package/dist/messager/index.d.ts +0 -2
  123. package/dist/messager/rest.d.ts +0 -15
  124. package/dist/messager/types.d.ts +0 -57
  125. package/dist/messager/ws.d.ts +0 -14
  126. package/dist/module-server.d.ts +0 -9
  127. package/dist/not-found.d.ts +0 -2
  128. package/dist/notifier/client.d.ts +0 -2
  129. package/dist/notifier/index.d.ts +0 -2
  130. package/dist/notifier/types.d.ts +0 -105
  131. package/dist/opencode/client.d.ts +0 -2
  132. package/dist/opencode/index.d.ts +0 -2
  133. package/dist/opencode/permissions.d.ts +0 -5
  134. package/dist/opencode/prompt.d.ts +0 -8
  135. package/dist/opencode/rest.d.ts +0 -16
  136. package/dist/opencode/run.d.ts +0 -13
  137. package/dist/opencode/session.d.ts +0 -26
  138. package/dist/opencode/skills.d.ts +0 -4
  139. package/dist/opencode/tools/bash.d.ts +0 -6
  140. package/dist/opencode/tools/edit.d.ts +0 -19
  141. package/dist/opencode/tools/glob.d.ts +0 -9
  142. package/dist/opencode/tools/grep.d.ts +0 -17
  143. package/dist/opencode/tools/index.d.ts +0 -12
  144. package/dist/opencode/tools/question.d.ts +0 -5
  145. package/dist/opencode/tools/read.d.ts +0 -16
  146. package/dist/opencode/tools/skill.d.ts +0 -18
  147. package/dist/opencode/tools/web.d.ts +0 -18
  148. package/dist/opencode/tools/write.d.ts +0 -13
  149. package/dist/opencode/types.d.ts +0 -90
  150. package/dist/opencode/ws.d.ts +0 -21
  151. package/dist/permissions.d.ts +0 -51
  152. package/dist/postgres/client.d.ts +0 -4
  153. package/dist/postgres/index.d.ts +0 -4
  154. package/dist/postgres/module.d.ts +0 -17
  155. package/dist/postgres/schema/columns.d.ts +0 -99
  156. package/dist/postgres/schema/index.d.ts +0 -6
  157. package/dist/postgres/schema/sql.d.ts +0 -22
  158. package/dist/postgres/schema/table.d.ts +0 -141
  159. package/dist/postgres/schema/where.d.ts +0 -29
  160. package/dist/postgres/types.d.ts +0 -50
  161. package/dist/queue/index.d.ts +0 -2
  162. package/dist/queue/types.d.ts +0 -62
  163. package/dist/rate-limit.d.ts +0 -45
  164. package/dist/react.d.ts +0 -14
  165. package/dist/react.js +0 -751
  166. package/dist/redis/client.d.ts +0 -2
  167. package/dist/redis/types.d.ts +0 -18
  168. package/dist/request-id.d.ts +0 -40
  169. package/dist/router.d.ts +0 -73
  170. package/dist/s3.d.ts +0 -68
  171. package/dist/seo.d.ts +0 -104
  172. package/dist/serve.d.ts +0 -38
  173. package/dist/server-registry.d.ts +0 -10
  174. package/dist/session.d.ts +0 -117
  175. package/dist/sse.d.ts +0 -47
  176. package/dist/ssr-entries.d.ts +0 -4
  177. package/dist/ssr.d.ts +0 -11
  178. package/dist/static.d.ts +0 -23
  179. package/dist/stream.d.ts +0 -24
  180. package/dist/tailwind.d.ts +0 -15
  181. package/dist/tenant/client.d.ts +0 -2
  182. package/dist/tenant/graphql.d.ts +0 -3
  183. package/dist/tenant/index.d.ts +0 -2
  184. package/dist/tenant/rest.d.ts +0 -3
  185. package/dist/tenant/schema.d.ts +0 -5
  186. package/dist/tenant/types.d.ts +0 -48
  187. package/dist/tenant/utils.d.ts +0 -9
  188. package/dist/test-utils.d.ts +0 -194
  189. package/dist/theme.d.ts +0 -31
  190. package/dist/trace.d.ts +0 -95
  191. package/dist/tsx-context.d.ts +0 -32
  192. package/dist/types.d.ts +0 -47
  193. package/dist/upload.d.ts +0 -55
  194. package/dist/use-action.d.ts +0 -42
  195. package/dist/use-agent-stream.d.ts +0 -49
  196. package/dist/use-flash-message.d.ts +0 -17
  197. package/dist/use-websocket.d.ts +0 -42
  198. package/dist/user/client.d.ts +0 -30
  199. package/dist/user/index.d.ts +0 -2
  200. package/dist/user/oauth-login.d.ts +0 -21
  201. package/dist/user/oauth2.d.ts +0 -31
  202. package/dist/user/types.d.ts +0 -178
  203. package/dist/validate.d.ts +0 -32
  204. package/dist/vendor.d.ts +0 -7
  205. package/dist/webhook.d.ts +0 -79
  206. package/opencode/ui/app/globals.css +0 -1
  207. package/opencode/ui/app/layout.tsx +0 -13
  208. 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.25.2",
5
- "description": "Web-standard HTTP framework for Node.js — (req, ctx) => Response",
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
- ".": "./dist/index.js",
10
- "./react": "./dist/react.js"
7
+ ".": "./index.ts"
11
8
  },
12
9
  "bin": {
13
- "weifuwu": "dist/cli.js"
10
+ "weifuwu": "./cli.ts"
14
11
  },
15
12
  "files": [
16
- "dist/",
17
- "cli.ts",
18
- "cli/template/",
19
- "opencode/ui/",
20
- "README.md",
21
- "LICENSE"
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,.tsx .",
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
- "*.{ts,tsx}": [
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,4 @@
1
+ export { postgres, MIGRATIONS_TABLE } from './client.ts'
2
+ export { PgModule } from './module.ts'
3
+ export type { PostgresOptions, PostgresClient, PostgresInjected } from './types.ts'
4
+ export * from './schema/index.ts'
@@ -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'