@weifuwujs/db
v1.3.0
Published
weifuwu 数据面:声明集合 → REST/GraphQL/OpenAPI 三投影 + 参数化查询编译(Postgres-first,headless)
Readme
数据面(@weifuwujs/db)
状态:已发布(
@weifuwujs/[email protected],工作区 lockstep 版本)。路线与边界见plan/db.md。
声明一次集合,得到 REST + GraphQL + OpenAPI 三投影的数据面;SQL 全部参数化编译,Postgres-first。
import { col, defineDb } from '@weifuwujs/db'
import { createApp } from '@weifuwujs/weifuwu'
const db = defineDb({
collections: {
notes: {
publicRead: true,
fields: {
id: col.uuid({ primary: true, default: 'gen_random_uuid()' }),
title: col.text({ required: true }),
status: col.enum('notes_status', ['draft', 'published'] as const),
authorId: col.fk('users'),
createdAt: col.timestamptz({ auto: 'create' }),
},
},
},
})
const app = createApp().use(postgres()) // 注入 ctx.sql
app.mount(db.api(), { graphql: '/graphql', openapi: '/openapi.json' })最小可跑示例(零依赖,node 直接运行;test/batteries/readme-examples.test.ts 会真跑它):
import assert from 'node:assert/strict'
import { col, compileInsert, compileList, defineDb } from '@weifuwujs/db'
const db = defineDb({
collections: {
notes: {
fields: {
id: col.uuid({ primary: true }),
title: col.text({ required: true }),
status: col.enum('notes_status', ['draft', 'published'] as const),
},
},
},
})
// 编译层零依赖:不连数据库也能看到真实 SQL 与参数(值全部参数化)
const notes = db.model.collections[0]!
const insert = compileInsert(notes, { title: '第一条笔记', status: 'draft' })
assert.match(insert.text, /^insert into "notes"/)
assert.deepEqual(insert.values, ['第一条笔记', 'draft'])
const list = compileList(notes, { limit: 5 })
assert.match(list.text, /^select .+ from "notes"/)
assert.equal(list.values[0], 5)示例标注:
ts exec= 由测试真跑;ts fragment= 片段(依赖前后文,不独立运行);自足示例带包标签(如ts tasks),由测试编译。
真 HTTP 挂载 + 默认拒绝(3.1/3.2-α):
const db = defineDb({
sql: postgresExecutor(sql),
collections: {
notes: { publicRead: true, fields: { /* … */ } },
orders: { fields: { /* … */ } }, // 未开 publicRead:读 403
},
authorize: (ctx, { collection, action }) => // 写默认 403;钩子内接自己的鉴权
ctx.state?.user?.role === 'admin', // 例:仅 admin 可写
})
createApp().mount(db.api(), { graphql: '/graphql', openapi: '/openapi.json' })- REST 参数:
filter(JSON 串,含eq/neq/gt/gte/lt/lte/like/ilike/in/isNull/and/or)、sort=-qty,name、limit/offset、select=id,name;typedwhere/orderBy/fields仅 GraphQL;?where={…}直用未支持。 select只收窄 SQL 列;描述符输出会把未选字段补成null(api 对象输出语义,非本包行为)。
每个集合自动获得:
| 投影 | 形态 |
| --- | --- |
| REST | GET /notes(filter JSON + sort + limit/offset + select)、GET /notes/:id、POST /notes、PATCH /notes/:id、DELETE /notes/:id |
| GraphQL | notes(where, orderBy, limit, offset, fields)、notesById、createNotes、updateNotes、deleteNotes,Where/OrderBy 为按列生成的 typed input |
| OpenAPI | /openapi.json(3.1),可直接喂给 openapi-typescript 生成客户端类型 |
安全契约
- 标识符白名单:表名/列名只能来自
defineDb元数据;请求里的字段名必须命中白名单,SQL 编译输出全部双引号引用。 - 值全参数化:任何值只以
$n占位符进入 SQL;where操作符限定eq/neq/gt/gte/lt/lte/like/ilike/in/isNull/and/or。 - 分页安全带:
limit默认 50、上限 200;offset ≥ 0。 - 默认拒绝:未声明
publicRead的集合连读也 403;写一律 403,除非提供authorize钩子放行。 - 四包对照:
docs/authorization.md——db/rag/workflow/pi-agent 的默认拒绝矩阵、决策形状与组合边界(可执行契约)。 授权判定发生在 SQL 编译/执行之前(被拒请求不触达数据库)。 - 授权钩子:
authorize(ctx, { collection, action })(action ∈ read|create|update|delete|admin)返回boolean | { where, fields? }:true放行、false拒绝、{ where }放行并强制行级过滤——作为普通WhereInput参数化 AND 并入 list/byId/update/delete($n连续、零拼接),客户端filter/or与 GraphQL 均无法绕过;{ where }不能约束插入 → create 403;策略字段非法 → 400 且零 SQL。
字段级权限(行权 ≠ 列权)
三级授权:collection(公开与否)→ 行级 { where } → 字段级 fields,都在 SQL 编译/执行之前判定。
const users = defineDb({
sql: postgresExecutor(sql),
collections: {
users: {
fields: { id: col.uuid({ primary: true }), owner: col.text({ required: true }),
nickname: col.text(), passwordHash: col.text() /* 可空,否则无法裁剪 */ },
// 静态策略:未知字段名在 defineDb 时即抛 SqlError(不等到运行期)
fieldPolicy: { read: ['nickname'], write: ['nickname'] },
},
},
// 动态策略:策略可随用户变化;与静态策略取**交集**(只能收紧,不能放宽)
// 行约束 + 列约束:create 用 fields-only 形态({ where } 无法约束插入)
authorize: (ctx, { action }) => action === 'create'
? { fields: { write: ['nickname'] } }
: { where: { owner: { eq: ctx.user.id } }, fields: { read: ['nickname'] } },
})- allowlist 语义:缺省 = 该动作全部字段;声明后只有列出的字段可读写(漏列不会意外开放)。
- 策略形态:
{ where }(行约束,create 无法约束 → 403)、{ fields }(仅列约束,放行全部行,可用于 create)、两者同时给;{ where: null }仍是显式拒绝(不因带fields而放行)。 - 读:未显式指定字段 → SQL 投影裁剪到 allowlist;显式请求越权字段(REST
select=/GraphQL 选择集)→ 403(不静默丢弃); 未请求的受限字段在响应里是null(api 对象输出语义)。 - 必填字段恒可读(含主键):响应类型契约要求非空字段存在,
fieldPolicy只裁剪可空字段; 要隐藏NOT NULL列只能改列(可空)或收行权/不暴露集合。 - 写:create/update 输入含越权字段 → 403 且零 SQL(判定先于编译,行/列都不会动)。
- 静态文档 = 声明面,不含列权:
db.api()的 OpenAPI/客户端类型列全部声明字段(含策略未列出的), 列权只在运行期裁剪(越权请求 403、未请求的受限字段null)——门控流水线OPENAPI_CODEGEN_E2E=1对 src 与发布产物两态都断言这一点(见test/openapi-codegen.e2e.test.ts)。 - REST / GraphQL / 元数据
mount三路同语义:db.mount(app, { fieldPolicy: { 集合: {...} } })为 introspect/_wf_*反射集合补策略(未知集合/字段 → 抛错)。
运行时 API(本阶段)
defineDb(config)→{ model, api(), schema, mount };api()返回@weifuwujs/api描述符(静态),mount(app, opts)动态挂载(免重启)。config.sql或ctx.sql任一即可提供查询执行器;postgresExecutor(sql)把 postgres.js 客户端适配为事务化执行器。BigInt统一按十进制字符串输出(与内核约定一致)。
元数据与迁移(1.x)
const db = defineDb({ sql: postgresExecutor(sql), collections: { /* … */ } })
await db.schema.plan() // 差异预览:{ statements, destructive, unsupported }(零副作用)
await db.schema.apply() // 事务 + advisory lock + 锁内重读;写 _wf_* 元数据与 _wf_migrations 审计
await db.schema.apply({ actor: 'deploy@ci', allowDestructive: true }) // 破坏性变更需显式授权_wf_collections/_wf_fields/_wf_migrations三张受管表,首次apply()自动创建。- 幂等:
apply()后再次plan()为空;并发apply()由pg_advisory_xact_lock+ 锁内重读保证恰好执行一次。 - 默认拒绝破坏性变更(删列、改列类型、改必填/默认值/枚举值/外键):
plan()会给出预览与unsupported原因,apply()未授权时整体拒绝、一条 SQL 都不执行。 - 修复过的两个真库坑(都有回归测试):
to_regclass()在事务内复用陈旧 catalog 快照 → 探针改为直查pg_class;$n::jsonb传 JSON 文本会双编码 → 统一$n::text::jsonb。
接入已有表(1.3 introspection / adopt)
const { state, skipped } = await db.schema.introspect({ tables: ['orders'] }) // 只读反射
const report = await db.schema.adopt({ actor: 'ops', tables: ['orders'] }) // 只写元数据 + 审计
// report = { adopted: ['orders'], skipped: [{ table, reason }], version }- 零 DDL:adopt 不执行任何建表/改表语句;接入后
plan()为空(与声明模型等价),后续按模型演进走apply()。 - 接入范围:uuid/text/varchar/char/int2,4/int8/bool/timestamptz/json(b);numeric、数组、自定义 enum 类型等整表跳过并给原因。
- 单列主键才能接入;复合主键/无主键跳过。单列 UNIQUE(约束或唯一索引)→
unique; 单列col IN (...)/= ANY (ARRAY[...])的 CHECK → 枚举值。 - 默认值规范化:去类型转换后缀、
CURRENT_TIMESTAMP→now();其余原样。 - 「required=false + 有默认值」与「required=true + 有默认值」DDL 等价,元数据按 DB 真值(NOT NULL)记录—— 反射与声明因此可直接比较。
运行期建表免重启(3.4/3.5 Admin API + 动态 dispatcher)
const db = defineDb({ sql: postgresExecutor(sql), collections: {} })
const app = createApp().use(async (req, ctx, next) => {
ctx.admin = ctx.user?.role === 'admin' // 你自己的鉴权:注入到 ctx
return next(req, ctx)
})
db.mount(app, { basePath: '/api', adminPath: '/_db', graphql: '/graphql', openapi: '/openapi.json' })| Admin 端点 | 说明 |
| --- | --- |
| POST /_db/schema/preview | { name, publicRead?, fields } → { statements, destructive, unsupported }(零副作用)|
| POST /_db/collections | 同上 → 201 { collection, schemaVersion, applied };重复 → 409 |
| GET /_db/collections[/:name] | 清单 / 详情(include fields)|
| PATCH /_db/collections/:name | { fields?, dropFields?, actor? };破坏性需 ?allow-destructive=true,否则 409 + 预览 SQL |
| GET /_db/migrations | 审计:id / actor / statements / appliedAt |
| DELETE /_db/collections/:name | 409 UNSUPPORTED_CHANGE(v1 DDL 子集不含 drop table)|
- 默认不挂载:不传
adminPath即 404(不暴露存在性);每个 Admin 请求先过authorize(ctx, { collection: '_db', action: 'admin' }),未提供钩子 → 403。 - 免重启:数据面在请求期以
_wf_*元数据为真相源(为空退回声明模型),缓存键 =schemaVersion;Admin 建表后同进程立即/api/<新表>、GraphQL、OpenAPI 三投影可用。 - 字段 JSON:
{ type: uuid|text|int|bigint|bool|timestamptz|json|enum|fk, required?, unique?, primary?, auto?, default?, values?, target?, enumName? }。 - 稳定错误码:
INVALID_IDENTIFIER400 /COLLECTION_EXISTS409 /COLLECTION_NOT_FOUND404 /DESTRUCTIVE_REQUIRES_CONFIRM409 /UNSUPPORTED_CHANGE409。 - 动态模式 GraphQL/OpenAPI 端点用绝对路径(与静态
app.mount()语义一致)。
CLI(3.3 weifuwu-db)
npm i -D @weifuwujs/db # bin: weifuwu-db
cat > weifuwu-db.config.ts <<'TS'
import postgres from 'postgres'
import { col, defineDb, postgresExecutor } from '@weifuwujs/db'
export default defineDb({
sql: postgresExecutor(postgres(process.env.DATABASE_URL!)),
collections: { notes: { fields: { id: col.uuid({ primary: true }), title: col.text({ required: true }) } } },
})
TS
weifuwu-db migrate --dry-run # 预览 SQL(零执行)
weifuwu-db migrate --actor deploy@ci # 执行(事务 + 锁 + 审计)
weifuwu-db collections list # 受管集合
weifuwu-db collections create --spec new.json # { name, publicRead?, fields: {…} }
weifuwu-db adopt --dry-run --tables orders # 反射报告(零写入)
weifuwu-db adopt --tables orders --actor ops # 写入 adopt 元数据- 配置:
--config <file>(默认 cwd 下weifuwu-db.config.ts),默认导出defineDb(...)的产物;连接串由配置自己提供(示例用DATABASE_URL)。 - 退出码:
0成功(含无变更)/1错误 /2破坏性变更未确认(仅预览、零执行;加--allow-destructive执行)。 - CLI 只编排
schema.plan/apply/introspect/adopt与 Admin 的 spec 转换,不直连 SQL、不复制 DDL 逻辑。
尚未实现(按 plan/db.md 任务地图推进):3.1 codegen 客户端类型 e2e;4.x 守卫同步与发布。
