weifuwu 0.63.0 → 0.64.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/docs/data.md ADDED
@@ -0,0 +1,240 @@
1
+ # 数据层 — postgres / redis(weifuwu)
2
+
3
+ 自研 PG v3 / RESP2 协议客户端,真实库测试(CS-04),故障恢复见各节。
4
+
5
+ > 本页为 weifuwu 官方文档拆分页 · [返回 README](../README.md)
6
+
7
+ ## postgres — PostgreSQL 客户端(自研)
8
+
9
+ > **自研 PG v3 协议**(零第三方依赖)——支持 SCRAM-SHA-256 认证、扩展查询(参数化)、类型映射(int8 超范围自动 string 防丢精度)、事务、连接池(acquire 超时防饿死)、schema 写前校验、statement_timeout 慢查询保护。
10
+
11
+ ```ts
12
+ import { postgres } from 'weifuwu'
13
+
14
+ // 注入 ctx.sql(懒连接池)
15
+ app.use(postgres())
16
+
17
+ // ① tagged template —— 插值自动参数化(防注入)
18
+ app.get('/users', async (req, ctx) => {
19
+ const users = await ctx.sql`SELECT * FROM users WHERE id = ${ctx.params.id}`
20
+ return Response.json(users)
21
+ })
22
+
23
+ // ② jsonb 对象直传——自动序列化,不再有双重编码/parseRow 样板
24
+ app.post('/decks', async (req, ctx) => {
25
+ const deck = await req.json()
26
+ await ctx.sql`INSERT INTO decks (title, deck_json) VALUES (${deck.title}, ${deck})`
27
+ // 读回来自动是对象:rows[0].deck_json === { slides: [...] }(不是字符串)
28
+ })
29
+
30
+ // ③ 事务(postgres.js 兼容 begin)
31
+ app.post('/transfer', async (req, ctx) => {
32
+ await ctx.sql.begin(async sql => {
33
+ await sql`UPDATE accounts SET balance = balance - 100 WHERE id = 1`
34
+ await sql`UPDATE accounts SET balance = balance + 100 WHERE id = 2`
35
+ })
36
+ })
37
+ ```
38
+
39
+ ### 类型映射(自动)
40
+
41
+ | 数据库类型 | 返回 JS 类型 |
42
+ |-----------|-------------|
43
+ | json / jsonb | `object`(自动 JSON.parse) |
44
+ | int2 / int4 / int8(安全范围内) | `number` |
45
+ | **int8(超出安全范围)** | **`string`**(防静默丢精度,金额/ID 关键) |
46
+ | float / numeric | `number` |
47
+ | boolean | `boolean` |
48
+ | text / varchar / uuid | `string` |
49
+ | **timestamptz** | **`Date`**(带时区,ISO 解析无本地时区魔法) |
50
+ | timestamp / date / interval | `string`(无时区语义——转 Date 按本地时区解析即时区魔法,诚实裁剪不转) |
51
+ | NULL | `null` |
52
+
53
+ ### 类型层(查询泛型 + schema 写前校验)
54
+
55
+ ```ts
56
+ // ① 查询结果泛型(编译期类型,无需手写 interface + 断言)
57
+ interface Deck { id: number; title: string; deck_json: { slides: unknown[] } }
58
+ const decks = await ctx.sql.query<Deck>('SELECT id, title, deck_json FROM decks')
59
+
60
+ // ② schema 注册 → insert 写前校验(脏数据源头拦截)
61
+ ctx.sql.register('decks', {
62
+ title: { type: 'text', required: true },
63
+ status: { type: 'enum', values: ['outline', 'ready'] },
64
+ deck_json: { type: 'jsonb' },
65
+ })
66
+ await ctx.sql.insert('decks', { title: 'x', status: 'INVALID' }) // → ValidationError
67
+ ```
68
+
69
+ ### 方法面
70
+
71
+ | 方法 | 说明 |
72
+ |------|------|
73
+ | `ctx.sql\`...\`` | tagged template → 参数化查询(插值=参数,表名需硬编码) |
74
+ | `ctx.sql.query<T>(sql, params?)` | 参数化查询 + 泛型 |
75
+ | `ctx.sql.unsafe(sql, params?)` | 原生 SQL(DDL / 动态表名) |
76
+ | `ctx.sql.begin(fn)` | 事务(回调收到 tagged template sql) |
77
+ | `ctx.sql.transaction(fn)` | 事务(回调收到 `{ query }`) |
78
+ | `ctx.sql.register(table, schema)` | 注册表结构(写前校验) |
79
+ | `ctx.sql.insert(table, row)` | schema 校验 + 参数化插入 |
80
+ | `ctx.sql.insertMany(table, rows[], { batchSize? })` | **批量插入**:多行 VALUES 单次往返(默认 500/批;所有行键必须一致) |
81
+ | `ctx.sql.update(table, set, where, { returning? })` | **参数化 UPDATE**:SET/WHERE 全部参数化,返回 `affectedRows` |
82
+ | `ctx.sql.delete(table, where)` | **参数化 DELETE**:WHERE 必填(防全表误删),返回 `affectedRows` |
83
+ | `ctx.sql\`...\` 内嵌片段` | 条件 SQL 片段(嵌套过滤,参数自动重编号) |
84
+ | `ctx.sql.close()` | 关闭连接池 |
85
+
86
+ ### 影响行数(affectedRows)
87
+
88
+ `INSERT / UPDATE / DELETE / MERGE` 的返回行数组带**非枚举** `affectedRows` 属性(不干扰 `deepEqual`/`JSON.stringify`):
89
+
90
+ ```ts
91
+ const r = await ctx.sql`UPDATE messages SET read = true WHERE id = ${id}`
92
+ if (r.affectedRows === 0) return new Response('not found', { status: 404 })
93
+ ```
94
+
95
+ ```ts
96
+ // 批量插入:100 行 1 次往返
97
+ await ctx.sql.insertMany('agent_logs', logs, { batchSize: 500 })
98
+ // 语义化更新/删除:WHERE 全参数化 + 返回影响行数
99
+ await ctx.sql.update('users', { role: 'admin' }, { id: userId })
100
+ await ctx.sql.delete('messages', { id: msgId })
101
+ ```
102
+
103
+ ### 条件片段(嵌套过滤)
104
+
105
+ ```ts
106
+ const status = req.query.status // 可能为空
107
+ const rows = await ctx.sql`
108
+ SELECT * FROM orders WHERE amount > ${100}
109
+ ${status ? ctx.sql`AND status = ${status}` : ctx.sql``}
110
+ `
111
+ // 空片段内联为空,参数自动重编号——同一 SQL 无论条件多少都安全参数化
112
+ ```
113
+
114
+ ### 选项
115
+
116
+ | 选项 | 类型 | 默认值 | 说明 |
117
+ |------|------|--------|------|
118
+ | `connection` | `string` | `DATABASE_URL` | 连接字符串 |
119
+ | `max`(或 `poolSize`) | `number` | `10` | 连接池大小 |
120
+ | `acquireTimeoutMs` | `number` | `30000` | 池全忙时 acquire 超时(防饿死,0=无限) |
121
+ | `statementTimeoutMs`(或 `statementTimeout`) | `number` | `0` | 语句超时(慢查询保护,0=禁用) |
122
+ | `idleTimeoutMs` | `number` | `0` | 空闲连接回收(超时未用关闭,容量收缩后自动重建;0=禁用) |
123
+ | `onQuery` | `(sql, durationMs, rowCount, traceId?) => void` | — | 查询观测钩子;第 4 参数为请求级 traceId(`x-trace-id` 头经 ALS 传播) |
124
+
125
+ ### 幂等迁移(内置)
126
+
127
+ `postgres()` 返回的中间件自带迁移跟踪(`_weifuwu_migrations` 表),模块启动时检查-执行-记录三步幂等:
128
+
129
+ ```ts
130
+ const db = postgres()
131
+ await db.migrate() // ① 建迁移跟踪表(幂等)
132
+
133
+ if (!(await db.isMigrated('users'))) { // ② 检查是否已迁移
134
+ await db.sql.unsafe(`CREATE TABLE users (...)`)
135
+ await db.markMigrated('users') // ③ 记录(幂等,重复调用无害)
136
+ }
137
+
138
+ app.use(db)
139
+ ```
140
+
141
+ > 多副本部署时天然安全:`markMigrated` 用 `ON CONFLICT DO NOTHING`,两个实例同时迁移也不会重复执行。
142
+
143
+ ### 错误映射(自动)
144
+
145
+ `ctx.sql` 查询错误自动映射为 `HttpError`,业务无需手写 catch:
146
+
147
+ | 错误码 | 含义 | HTTP |
148
+ |--------|------|------|
149
+ | `23505` | 唯一约束冲突 | **409** |
150
+ | `23503` / `23502` / `23514` | 外键 / 非空 / 检查约束 | **400** |
151
+ | `22P02` / `22003` | 类型 / 数值错误 | **400** |
152
+
153
+ > 未映射的错误码原样抛出(带 `code` 属性,如 `42P01` 表不存在)。
154
+
155
+ > **裁剪声明**:逻辑复制 / 大对象 / 显式游标 / 二进制 COPY 不支持(明确抛 `ProtocolError('unsupported')`,而非静默出错)。
156
+
157
+ ---
158
+
159
+ ## redis — Redis 客户端(自研)
160
+
161
+ > **自研 RESP2 协议**(零第三方依赖)——连接/重连(断线 pending 拒绝、指数退避)/离线队列/管道/Pub-Sub(订阅断线自动重放)+ 消除 ioredis 高频痛点(TTL 参数顺序、JSON 手动序列化、缓存样板)。**二进制安全**:`getBuffer(key)` 原样返回字节(缓存序列化 payload 不损坏)。
162
+
163
+ ```ts
164
+ import { redis } from 'weifuwu'
165
+
166
+ app.use(redis())
167
+
168
+ // ① TTL 安全 —— 直接传秒,不会写错
169
+ app.post('/cache/:key', async (req, ctx) => {
170
+ const { value } = await req.json()
171
+ await ctx.redis.set(ctx.params.key, value, 3600) // ioredis 要 set(k, v, 'EX', 3600)
172
+ })
173
+
174
+ // ② JSON 零样板 —— 自动序列化(AI 缓存场景)
175
+ app.get('/cache/:key', async (req, ctx) => {
176
+ const val = await ctx.redis.jsonGet(ctx.params.key) // 自动 JSON.parse
177
+ return Response.json(val ?? { miss: true })
178
+ })
179
+
180
+ // ③ 缓存便捷 —— 读-算-写一体,null 不缓存(防穿透)
181
+ app.get('/llm/:id', async (req, ctx) => {
182
+ const result = await ctx.redis.cache(`llm:${ctx.params.id}`, async () => {
183
+ return await generateLLM(ctx.params.id) // miss 才执行
184
+ }, 3600)
185
+ return Response.json(result)
186
+ })
187
+
188
+ // ④ Pub/Sub —— 发布用 ctx.redis,订阅用独立连接(回调式,断线自动重连恢复订阅)
189
+ app.post('/events', async (req, ctx) => {
190
+ await ctx.redis.publish('events', JSON.stringify({ type: 'deck.created' }))
191
+ })
192
+
193
+ const sub = ctx.redis.createSubscriber()
194
+ await sub.connect()
195
+ await sub.subscribe('events', (channel, message) => {
196
+ // 收到实时消息
197
+ })
198
+ await sub.psubscribe('jobs:*', (channel, message) => {
199
+ // 模式匹配订阅
200
+ })
201
+
202
+ // ⑤ 任意命令透传 + keyPrefix 隔离
203
+ await ctx.redis.command('LRANGE', 'list', '0', '-1')
204
+
205
+ app.use(redis({ keyPrefix: 'api:' })) // 之后所有 key 自动加前缀
206
+ await ctx.redis.set('user', 1) // 实际写入 'api:user'
207
+ ```
208
+
209
+ ### 方法面
210
+
211
+ | 方法 | 说明 |
212
+ |------|------|
213
+ | `get / set(key, val, ttl?) / del / incr / expire / ttl` | 基础命令(set 直接传秒) |
214
+ | `jsonGet / jsonSet(key, val, ttl?)` | JSON 自动序列化 |
215
+ | `cache(key, fn, ttl)` | 缓存读-算-写(null 不缓存防穿透) |
216
+ | `publish(channel, msg)` | Pub-Sub 发布 |
217
+ | `createSubscriber()` | 独立订阅连接(`subscribe`/`psubscribe` 回调式) |
218
+ | `hset / hget / hgetall / hdel` | hash 字段读写(`hgetall` → `Record`,缺失 `{}`) |
219
+ | `lpush / rpush / lpop / rpop / lrange` | list 队列操作(`lrange` 支持负数区间) |
220
+ | `sadd / srem / smembers` | set 成员操作(`sadd` 重复不加) |
221
+ | `zadd / zrange` | zset 有序集(score 升序) |
222
+ | `mget / mset / exists / setnx / incrby` | 批量读写 / 存在性 / 原子设值(锁基础)/ 增量 |
223
+ | `pipeline()` | 管道:批量命令一次往返(池级,key 自动加前缀) |
224
+ | `command(name, ...args)` | 底层命令透传 |
225
+ | `close()` | 关闭连接池 |
226
+
227
+ | 选项 | 类型 | 默认值 | 说明 |
228
+ |------|------|--------|------|
229
+ | `url` | `string` | `REDIS_URL` 环境变量 | 连接字符串 |
230
+ | `poolSize` | `number` | `5` | 连接池大小 |
231
+ | `keyPrefix` | `string` | `''` | 所有 key 自动加前缀(多应用隔离) |
232
+ | `commandTimeoutMs` | `number` | `0` | 命令超时(阻塞命令 resolve(null);防挂起。0=禁用) |
233
+ | `socketTimeoutMs` | `number` | `0` | socket 响应超时(僵尸连接自愈:pending 有命令且超时无数据 → 主动断开重连。0=禁用) |
234
+
235
+ > **连接健康**:断线自动剔除死连接并重建(池不萎缩);`CLIENT KILL`/网络抖动后服务自愈,命令不命中死连接。
236
+
237
+ > **裁剪声明**:集群(MOVED 路由)/ 哨兵 / 自动管道不支持(standalone 优先)。
238
+
239
+ ---
240
+
@@ -0,0 +1,27 @@
1
+ # 环境变量与开发命令
2
+
3
+ > 本页为 weifuwu 官方文档拆分页 · [返回 README](../README.md)
4
+
5
+ | 变量 | 用途 | 模块 |
6
+ |------|------|------|
7
+ | `DATABASE_URL` | PostgreSQL 连接字符串 | `postgres()` |
8
+ | `REDIS_URL` | Redis 连接字符串 | `redis()` |
9
+
10
+ ---
11
+
12
+ # 开发命令
13
+
14
+ ```bash
15
+ npm run build # 构建 dist/
16
+ npm run typecheck # TypeScript 类型检查
17
+ npm test # 运行 node --test
18
+ node scripts/release.mjs <version> # 发布
19
+ ```
20
+
21
+ ```bash
22
+ # 测试前启动依赖服务
23
+ docker compose up -d
24
+ ```
25
+
26
+ ---
27
+
@@ -0,0 +1,127 @@
1
+ # 组合场景示例
2
+
3
+ > 本页为 weifuwu 官方文档拆分页 · [返回 README](../README.md)
4
+
5
+ ## 登录表单
6
+
7
+ ```tsx
8
+ const LoginPage = (_init, ctx) => {
9
+ const $ = ctx.ui.$()
10
+ $.errors = {}
11
+ $.submitting = false
12
+
13
+ return (props) =>
14
+ h('div', { class: 'wf-stack', style: { maxWidth: 400, margin: '40px auto' } },
15
+ h(Card, { padding: 'lg' },
16
+ h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
17
+ h('h2', {}, '登录'),
18
+ h(Form, {
19
+ validation: {
20
+ email: [{ required: true, pattern: /@/, message: '请输入有效邮箱' }],
21
+ password: [{ required: true, minLength: 6, message: '密码至少6位' }],
22
+ },
23
+ onSubmit: async (values) => {
24
+ $.submitting = true
25
+ await ctx.api?.post('/login', values) // api 客户端由中间件注入 ctx.api
26
+ $.submitting = false
27
+ },
28
+ onError: (errors) => { $.errors = errors },
29
+ }, [
30
+ h(Field, { label: '邮箱', error: $.errors.email },
31
+ h(Input, { name: 'email', type: 'email', placeholder: 'name@example.com' })),
32
+ h(Field, { label: '密码', error: $.errors.password },
33
+ h(Input, { name: 'password', type: 'password' })),
34
+ h(Button, { type: 'submit', loading: $.submitting, block: true }, '登录'),
35
+ ])
36
+ )
37
+ )
38
+ )
39
+ }
40
+ ```
41
+
42
+ ## 数据列表 + 搜索
43
+
44
+ ```tsx
45
+ const UserList = (_init, ctx) => {
46
+ const $ = ctx.ui.$()
47
+ $.keyword = ''
48
+ $.sortKey = 'name'
49
+ $.sortOrder = 'asc'
50
+ const users = [
51
+ { id: 1, name: '张三', email: 'zhang@example.com', role: '管理员' },
52
+ { id: 2, name: '李四', email: 'li@example.com', role: '编辑' },
53
+ ]
54
+
55
+ return (props) => {
56
+ // 派生数据必须在 render 内计算(每次 render 读最新 $.keyword)
57
+ const filtered = users.filter(u =>
58
+ !$.keyword || u.name.includes($.keyword) || u.email.includes($.keyword)
59
+ )
60
+
61
+ return h('div', { class: 'wf-stack', style: { gap: 'var(--wf-space-md)' } },
62
+ h('div', { class: 'wf-row', style: { justifyContent: 'space-between', alignItems: 'center' } },
63
+ h(SearchInput, { placeholder: '搜索用户...', value: $.keyword, onInput: (e: Event) => { $.keyword = (e.target as HTMLInputElement).value } }),
64
+ h(Button, { variant: 'primary' }, '新建用户'),
65
+ ),
66
+ h(Table, {
67
+ columns: [
68
+ { key: 'id', label: 'ID', width: 60 },
69
+ { key: 'name', label: '姓名', sortable: true },
70
+ { key: 'email', label: '邮箱', sortable: true },
71
+ { key: 'role', label: '角色' },
72
+ ],
73
+ data: filtered,
74
+ sortKey: $.sortKey,
75
+ sortOrder: $.sortOrder,
76
+ onSort: (key, order) => { $.sortKey = key; $.sortOrder = order },
77
+ emptyText: '无匹配用户',
78
+ }),
79
+ h(Pagination, { total: filtered.length, page: 1, pageSize: 10, onChange: (p: number) => {} }),
80
+ )
81
+ }
82
+ }
83
+ ```
84
+
85
+ ## 消息提示
86
+
87
+ ```tsx
88
+ // 在任意组件中调用
89
+ let toastId = 0
90
+
91
+ function showToast(ctx: WfuiContext, type: ToastType, message: string) {
92
+ // 通过 ctx 管理 Toast 列表
93
+ const $ = ctx.ui.$()
94
+ $.toasts = $.toasts ?? []
95
+ const id = String(++toastId)
96
+ $.toasts = [...$.toasts, { id, type, message }]
97
+
98
+ // 自动消失
99
+ if (type !== 'error') {
100
+ setTimeout(() => {
101
+ $.toasts = $.toasts.filter((t: any) => t.id !== id)
102
+ }, 3000)
103
+ }
104
+ }
105
+
106
+ // 页面中使用
107
+ const App = (_init, ctx) => {
108
+ const $ = ctx.ui.$()
109
+ $.toasts = []
110
+
111
+ return (props) =>
112
+ h('div', {}, [
113
+ h(Button, {
114
+ onClick: () => showToast(ctx, 'success', '操作成功'),
115
+ }, '显示提示'),
116
+ h(Toast, {
117
+ toasts: $.toasts,
118
+ position: 'top-right',
119
+ max: 3,
120
+ onRemove: (id) => { $.toasts = $.toasts.filter((t: any) => t.id !== id) },
121
+ }),
122
+ ])
123
+ }
124
+ ```
125
+
126
+ ---
127
+