Initial Dever 0.0.1

This commit is contained in:
Codex committed 2026-11-03 03:32:07 +00:00
commit 81233d098c
1479 files changed
+163782

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
/target/
/rust/target/
core.[0-9]*
/build/documentation-*/
/config/setting.json
/.release-key.*/
/.agents/
/.codex/
**/__pycache__/
**/*.pyc
+36
View File
@@ -0,0 +1,36 @@
# Dever 核心开发
适用于整个仓库。这里维护编译器、运行库和标准库;Dever 应用开发使用 [随包 Skill](skills/SKILL.md)。
## 工作规则
- 首条回复和最终回复称呼用户为“主人”。先读适用规则、相关源码和测试,再修改;已读且未变的文件不重复读取。
- 落实用户已授权的工作,不用方案或记录代替实施。讨论方案时只读;安装、部署、重启、提交、推送和重写 Git 历史须在授权范围内。
- 在 `/dm` 工作时遵守根 AGENTS。构建和程序运行使用已有开发容器,不往宿主机安装环境;保留用户改动、数据和凭据。
- 开发阶段统一使用 `0.0.1`,没有生产数据,不做旧语法、接口、配置或数据的兼容层。公开行为改动须说明,并同步调用方、测试和文档。
## 实现规则
- 保留 Dever 语法与约束。固定路线:Dever → 具体类型 Rust → 官方 Cargo/rustc → 原生程序。不开新后端,不 fork rustc。
- 优先复用 Rust 标准库和已有成熟库。只实现 Dever 自身规则与必要适配,不重复造解析器、调度器、网络栈或数值算法。
- 不预建扩展点、占位层或没有调用方的抽象。函数用于消除真实重复或表达清楚的职责,不包装每次库调用。
- 不添加无依据的判空、类型猜测、备用实现或静默兜底。外部输入在所属入口验证,内部不重复检查已保证的条件;鉴权、数据隔离、协议和资源释放必须正确。
- 错误沿现有路径传播,保留原因,不用空值或默认成功掩盖失败。重试、锁、缓存和补偿只用于当前真实需求。
- 每次复制、扫描、分配、加锁或调度交接都要有用途。只读优先借用,能移动就移动,不用 clone 代替所有权分析。
- 普通调用生成直接函数调用,只有需要挂起的函数才生成异步调用。具体类型直接传递,集合直接遍历,不套动态值或中间容器。
- 环境地址、端口、路径和凭据走既有配置;身份及数据归属来自可信上下文。不按账号、租户、测试名称或固定答案写特例。协议常量和局部算法常量可留在代码中。
- Rust 代码写必要的中文注释,说明原因和语义差异。Dever 源码为 `.dever` 纯代码;函数用 lowerCamelCase,字段、局部值和目录用 snake_case。
- 沿用目录结构,只保留工具包和运行库两份 Cargo 清单。每目录最多八个手写 `.rs` 文件,通常每文件 100–300 行,接近 400 行审查,超过 500 行拆分;小模块可直接放在 mod.rs。
## 验证与交付
- 先比较生成代码、运行库调用和独立 Rust 实现,找到多余操作再改。保留已有优化,不为某个性能样例添加特殊路径。
- 性能对照使用相同语义、输入、精度、依赖和编译选项。共享 Dever 运行库的对照只检验生成层,不能证明运行库追平。
- 提前固定轮次,保留全部原始结果及源码、二进制对应关系。报告绝对耗时、修改前后变化和对 Rust 的差距,不挑选样本或隐瞒退化。
- 每个独立改动先做相关功能和性能验证,再继续下一项;每批结束检查相关矩阵。稳定慢 2% 及以上继续定位,约 1% 不为凑数字增加代码。
- 新增复杂度没有稳定收益就撤回。删除等价冗余且无退化可以保留,但不冒充提速。
- 复用现有检查和测试,不造自证逻辑、不削弱断言。已通过的检查仅在相关内容变化或证据不足时补验。
- 说明只写当前用法和必要规则,不写对话、开发日志或占位描述。修改代码同步相关说明;调用图由工具生成,见 [说明写法](DOCUMENTATION.md)。
- 交付前检查差异、错误处理、冗余、引用和临时文件。未执行、失败或未测项目如实说明,不以构建成功代替业务通过。
源码布局、构建和测试命令见 [rust/README.md](rust/README.md)。语言行为见 [LANGUAGE.md](LANGUAGE.md)。
+64
View File
@@ -0,0 +1,64 @@
# 源码与说明
`.dever` 放代码,每个领域使用以下阅读入口:
```text
module/manage/account/
├── README.md
├── doc/
│ ├── session.md
│ └── members.md
├── app/
│ ├── session.dever
│ └── members.dever
├── domain.dever
├── model.dever
└── link.md
```
README 用一段话介绍职责,并链接主题说明。doc 按功能组织;业务角色只创建实际需要的文件,小领域可以使用 `app.dever`。
## 函数说明
二级标题写完整符号名,正文说明代码不容易表达的业务含义:
```markdown
# 登录
## manage.account.login
校验登录名和密码,成功后创建会话并设置 Cookie。凭据错误返回未授权。
```
- 简单函数通常一两句即可;复杂功能再补规则、失败条件和副作用。
- 参数含义不清楚时解释参数,不重复列出已展示的类型和签名。
- 不写“操作参数”“业务值”等占位文字,不把同一段介绍复制给每个函数。
- 一个逻辑函数只写一处说明,多分句共用。类型也可用完整符号名绑定说明。
- 调用方、行号和调用关系由工具读取,不手工维护。
不确定符号名时执行 `dever inspect . --json`。说明标题失效或重复时,检查器会报错;文字是否符合实现仍须对照源码。
## 调用流程
```sh
dever inspect . --links
dever inspect . --links --check
```
第一条命令生成各领域的 `link.md`,第二条检查是否过期。流程按源码求值顺序展示入口、分支、调用和任务调度,点击节点可打开源码。
图展示代码可能执行的路径,不是一次请求的运行记录。Port 的实际 Adapter 由配置选择。完整调用关系保留在 link 中,README 和 doc 不再复制。
## 修改与阅读
先读领域 README、相关主题和调用流程,再读源码。修改实现时同步受影响的说明与测试,重新生成调用图。
```sh
dever fmt . --check
dever check .
dever test . --filter manage.account
dever inspect . --links
dever inspect . --links --check
```
VS Code 插件可在悬浮中显示签名、说明和调用摘要,并跳转源码及上下游调用,见 [插件说明](editor/vscode/README.md)。源码和说明保存后刷新索引,不需要在 Dever 源码里加说明注释。
+448
View File
@@ -0,0 +1,448 @@
# Dever 语言规范
Dever 使用静态类型、命名输出和互斥分句。应用由 API、CMD、Job 声明入口,编译为原生程序。
- 开始项目:[开发约定](skills/references/development.md)
- 源码格式:[源码与说明](DOCUMENTATION.md)
- AI 开发:[随包 Skill](skills/SKILL.md)
- 安装:[安装指引](skills/references/install.md)
本文代码块是语法片段,放入对应角色的 `.dever` 文件。完整项目见 [示例](skills/references/examples.md)。
## 项目与命名
```text
config/setting.json
module/<组件>/<领域>/<角色>.dever
test/<组件>/<领域>/<主题>.dever
data/
```
配置来自 `config/setting.json`。run 读取项目配置,独立程序读取可执行文件旁的 `config/setting.json`。
| 名称 | 格式 |
| --- | --- |
| 组件、领域、主题、字段、局部值 | ASCII lower_snake_case |
| 函数、SQL 方法、标准库方法 | ASCII lowerCamelCase |
| 类型、choice 分支 | UpperCamelCase |
源码使用 UTF-8 和 `.dever` 后缀,不写分号。说明放领域 README 和 doc,Markdown 不参与编译。词法支持 `#` 行注释,项目源码不写说明注释;`//` 表示整数除法,不支持块注释。
| 角色 | 职责与可见性 |
| --- | --- |
| app | 公开领域能力;可调用本领域 Domain、Model、Port 和其他领域 App |
| domain | 本领域业务规则;不能调用其他领域或执行外部 I/O |
| model | 字段、查询和迁移,只由本领域 App 操作 |
| api | 绑定本领域 App,或声明自动 REST |
| port | 外部操作的输入、输出和错误 |
| adapter | 实现 Port,可调用本文件函数和标准能力 |
| job | 绑定本领域 App 的持久任务 |
应用不声明 package、import、exposes、internal 或 main。App 函数按 `组件.领域.函数` 调用;同组件可省略组件,同领域使用 `app.函数`。跨领域只访问 App,公开参数和输出不能暴露私有类型。领域之间不得循环依赖。
角色可使用 `app/publish.dever` 等主题文件,主题名不进入调用名,目录允许一个文件。App/Domain/Port/Adapter/Job 的主文件与目录二选一;Model/API 主文件可与目录共存。只有 API 目录可递归,其路径参与 URL。目录不能命名为 `common/shared/utils/helper/base/service`。
## 类型与值
| 类型 | 语义 |
| --- | --- |
| Bool | true / false |
| Int | 有符号 64 位整数,检查溢出 |
| Decimal | decimal128,最多 34 位有效数字,银行家舍入 |
| Float | IEEE 754 binary64 |
| Text / Bytes | UTF-8 文本 / 字节序列 |
| Id / Uuid | 不透明标识 / UUID |
| DateTime / Date / Time / Duration | UTC 时间点、日期、日内时间、时长;毫秒精度 |
| Json | 不透明 JSON 值,保留数字文本 |
| Secret | 仅在规定的输入、密码和摘要操作中使用的敏感值 |
| T? | T 或 null |
| List<T> / Map<K,V> | 同类型列表 / 保留插入顺序的映射 |
整数字面量默认 Int,小数字面量默认 Decimal;明确 Float 上下文可接收小数。科学计数形式为 Float。Decimal 与 Float 不隐式混合。Text 支持反斜杠、引号、换行、回车、Tab 和 `\u{HEX}` 转义。
字段型 type:
```dever
type User {
name: Text
phone: Text?
}
rename(user: User, name: Text) (result: User) {
result = user
result.name = name
}
```
构造使用 `User { name = "Lin" phone = null }`,必须一次给全字段。字段型、choice、List、Map 使用值语义,修改局部副本不改变原值。
选择型 type:
```dever
type State {
Draft
Published
Rejected(reason: Text)
}
```
构造为 `State.Published` 或 `State.Rejected("原因")`,载荷按位置传入。
List 写为 `[1, 2]`,Map 写为 `{"name" = "Lin"}`。空集合需由上下文确定类型。Map 键允许 Text、Int、Bool、Id 和无载荷 choice。Map 相等比较包含插入顺序;List 按位置比较。Float 的 NaN 与自身不相等。
## 函数与分句
```dever
greet(name: Text) (message: Text) {
message = "Hello, " + name
}
```
函数格式为 `名称(输入) (命名输出)`,空输入或输出仍写 `()`。调用按位置传参;单输出直接得到值,多输出按名称访问。输出必须在正常结束前赋值。
`=` 可初始化或修改当前函数的参数、局部值和输出;变量类型保持不变,读取前须初始化。字段只能在完整值上读取或修改。
局部值首次声明可写 `input: CreateInput = dever.json.decode(payload)`,为解码、空集合或 null 提供具体类型;类型省略时仍由表达式推断。后续赋值只写 `input = ...`,不重复声明类型,也不改变原类型。
同名、同参数数量的分句须相邻,输出类型和错误声明一致。分句互不重叠并覆盖全部输入,没有先后优先级:
```dever
label(state: State.Published) (text: Text) {
text = "published"
}
label(state: State.Rejected(reason)) (text: Text) {
text = reason
}
label(state: other) (text: Text) {
text = "draft"
}
```
模式支持类型、choice 分支、null、常量、Int/Decimal 范围和 other。other 是该参数位置未被明确模式覆盖的补集;多个参数按全部组合检查。choice 载荷按位置绑定,不使用的载荷可写 `_`。
可空值用 `value: Text` 与 `value: null` 两个分句处理。范围可写 `score: Int >= 60 and < 90`。没有 if/else、switch/match、return、条件表达式、用户递归、lambda、运行时闭包或用户泛型。
## 运算与范围
| 运算 | 规则 |
| --- | --- |
| + - * | 同类数值;Int 可提升为 Decimal;Text 支持 + |
| / | Int/Int 和 Decimal 运算返回 Decimal;Float/Float 返回 Float |
| // % | 仅 Int,整数除法向零截断 |
| == != | 同类型且可比较的值 |
| < <= > >= | Int、Decimal、Float |
| and or not | Bool;and/or 短路 |
优先级依次为调用/字段、一元运算、乘除取余、加减、比较、and、or。同级从左到右。
字段、choice 载荷、函数及 handler 参数都可声明 Int、Decimal 范围:
```dever
type Port {
value: Int >= 1 and <= 65535
}
```
构造、赋值和返回都须满足范围。未知 Int 先经范围分句确认;不支持任意用户谓词。静态可知的数值故障在检查时拒绝,运行时故障报告源码位置并终止当前执行。
## 集合与 handler
使用具名函数处理集合,不提供 for/while、break/continue。
| 操作 | 行为 |
| --- | --- |
| each(handler, values[, context]) | 零输出时遍历;单输出时生成 List |
| filter(handler, values[, context]) | Bool handler 筛选 List |
| find(handler, values[, context]) | 返回 List 的首个匹配项或 null |
| sum(values) / sum(handler, values[, context]) | 数值 List 求和 |
| reduce(handler, values, initial) | handler 输入为元素、当前状态,返回下一状态 |
| reduceUntil(handler, values, initial) | 另输出 stop: Bool,处理当前项后停止 |
| parallelEach(handler, values, limit[, context]) | 并行执行零输出 handler,返回前收尾全部工作 |
| contains(values, target) | 判断 List 中是否存在相等项,直接返回 Bool |
List/Bytes 支持 each、reduce、reduceUntil;Bytes 元素是 0–255 的 Int。Stream/AsyncStream 的 each 只接受零输出 handler。filter/find/sum 只处理 List。除 parallelEach 外按顺序处理,空集合求和为零,reduce 返回初始状态。
context 最多一个,需要多项数据时用 record;reduce 的共享数据放 State。parallelEach 的非挂起并发数为 1–256,挂起任务容量为 1–65536,并受运行时容量限制。同步 Stream 不接受挂起 handler。
handler 是签名匹配的具名函数或 handler 参数,不是可存储、比较或返回的运行时值:
```dever
transformAll(
transform: handler(value: Int) (result: Text),
values: List<Int>
) (results: List<Text>) {
results = each(transform, values)
}
```
`append`、`put`、`remove` 返回新集合;`get`、`first`、`find` 返回可空值。entries 返回有序 MapEntry 列表。length 接受 List、Map、Text、Bytes,Text 按 Unicode 码点计数。
## 失败、纯函数与敏感值
choice 的错误分支显式标记 error:
```dever
type DeliveryError {
error Unavailable(message: Text)
}
```
普通调用返回成功值,失败自动向调用方传播。`fail(DeliveryError.Unavailable("原因"))` 主动失败;`result(call)` 捕获后交给覆盖所有结果的分句处理。
捕获按错误类型及变体身份匹配,不按消息或载荷形状猜测。结果 Choice 的 error 分支可包装原错误 Choice,实际构造的仍是原失败变体;原错误 Choice 含有成功分支不妨碍捕获。参数求值的失败继续向外传播。
错误必须传播、返回或明确恢复。读取消息、记录日志、覆盖变量不算处理;恢复函数写 `recover("具体原因")`,全部分句理由一致。失败义务跟随 record、choice 和集合,局部查找不能代表处理了其余错误。程序故障不属于可捕获业务失败。
`pure` 写在输出声明后,禁止 I/O、时钟、随机源、并发等效果;检查沿调用链和 handler 传播。pure 函数仍可能有数值故障。
private 字段只能由所属源码边界访问,含私有字段的值不能直接用于公开 wire 输出。Model 私有字段仅供同领域 App 操作,跨边界返回显式 View。
Secret 不可比较、打印、拼接、转为普通文本、作为 Model 列或输出。密码使用 dever.crypto;哈希和验证属于阻塞操作。测试源可用 `secret(Text)` 构造固定输入,生产源码没有此能力。
## 并发与资源
挂起由调用图推断,不写 async/await。普通调用等待完成,显式 run 才启动子任务。
| 操作 | 合同 |
| --- | --- |
| run(call) | 启动 Task |
| wait(task) | 等待并消费 Task |
| stop(task) | 请求取消并等待退出;仅接受零输出 Task |
| timeout(task, ms, fallback) | 到期先清理任务,再调用输出签名相同的零输入 fallback |
| race(task, task, ...) | 取首个完成结果,清理其余任务;输出签名须相同 |
| group(limit) / run(group, call) | 有界零输出任务组,满时等待;最终 wait/stop |
| parallel(pure_call) | 在线程池执行非挂起纯计算 |
| blocking(call) | 在线程池执行明确的非挂起阻塞调用 |
| channel(T, capacity) | 有界通道;send/receive 挂起,close 唤醒等待者 |
Task/Group 不能复制、嵌套、传参或逃逸,必须消费一次。异常路径也会收尾子任务;取消不撤销已经发生的外部副作用。程序故障不会变成 timeout 的正常 fallback。
Group、Channel 容量为 1–65536。Channel 关闭后仍可读完缓存,之后 receive 返回 null。Channel 元素不能为可空类型;需要传递空值时,用含可空字段的消息 Choice 区分消息与关闭。
跨线程或挂起点存活的普通值须可传递。挂起调用链中的同步文件、睡眠、密码计算等操作用 blocking;网络等待和 dever.task.sleep 自动挂起。
File、Socket、Listener、Stream、AsyncStream 等资源的副本共享状态。普通数据的值语义不适用于这些句柄。
- Stream 按需单次消费,别名共享读取位置;reduceUntil 停止后可继续读取同步 Stream。
- AsyncStream 的消费结束、提前停止、故障或取消会关闭流;别名不能继续消费。
- `stream(channel/list/bytes)` 创建 AsyncStream;`dever.task.ticks(ms)` 创建周期流。
- 关闭流释放其持有的资源别名,不关闭调用者另外持有的 File、Socket 或 Channel。
## API、身份与权限
API 文件只声明绑定:
```dever
get profile = app.profile
public post login = app.login
authenticated get current = app.current
long post generate = app.generate
cmd export = app.export
rest model
```
HTTP 支持 get/post/put/delete。URL 为组件、领域、API 主题路径和 action;CMD 为 `组件.领域.命令`。API 绑定本领域 App 的一个可序列化输出。
GET/DELETE 从 query 读取标量;POST/PUT 从 JSON 对象读取;CMD 接受单个 JSON 对象。未知、重复和缺少必填字段失败。成功包裹为 `{"code":0,"message":"ok","data":...}`,失败的 data 为 null。dever.api.Error 映射 400/401/403/404/409/429,其他内部失败返回安全的 500。
| 声明 | 访问要求 |
| --- | --- |
| 默认 HTTP | 可信身份与精确角色权限 |
| public HTTP | 匿名入口 |
| authenticated HTTP | 可信身份;对象所有权与业务权限由 App 检查 |
public/authenticated 不用于 CMD 或 REST。权限键为 `component.domain.site.action`,REST 的 action 为 read/create/replace/delete。HTTP 方法不进入权限键。
`sites.<key>.path` 匹配 API 父目录,每条接口须恰好匹配一个站点。例如 admin 匹配 api/admin/**,不匹配 api/admin.dever,也不改写 URL。
认证 Provider 绑定项目的 verify App。它接收 dever.auth.Claims,返回仅含 `id: Text`、`user_id: UserModelId?`、`tenant_id: TenantModelId?` 的身份 record。verify 校验账号、会话、租户和成员关系,只读 global Model,不返回权限列表。
身份读取使用 dever.auth 和 dever.site,ID 类型须匹配 verify。客户端参数不能建立身份。public、CMD、Job 和普通测试不能直接读取 HTTP 身份。
角色与权限由核心管理。授权读取允许受保护或 authenticated 入口;修改只允许普通受保护写入口。Owner 为受保护角色,不按特定用户 ID 判断。业务 capability 在可信租户和入口命名空间内注册,撤销同时清理 grants,重新注册不恢复授权。
HTTP 元数据用 dever.api 读取和设置。敏感头不能读作普通 Text;Cookie 登录/退出用 issueCookie/clearCookie。Cookie 写入及 Cookie 认证的写请求要求 Origin、Host 精确匹配站点 origin。响应元数据仅在处理与编码成功后发布。
Upload 仅用于 POST 的顶层 multipart 参数,不能复制、嵌套、输出或进入 CMD。显式关闭,或一次性传给本领域 Port,由 Adapter 使用 storage;失败和取消清理临时文件。
完整配置与调用边界见 [应用指引](skills/references/application.md)和 [签名索引](skills/references/signatures.md)。
## Model 与查询
主文件的记录名对应领域,主题文件对应主题名:
```dever
type Article {
title: Text(1, 200)
body: Text
published: Bool default false index
}
```
自动生成只读 id 和 created_at。每个 Model 的 id 是不同的名义类型,不能用 Int 或其他 Model 的 ID 替代。
字段支持存储标量、无载荷 choice、Model ID 及可空形式。Text/Bytes 可指定长度,Decimal(P,S) 指定精度;generated 用于非空 Uuid。default 为静态值,index/unique 可用于单字段或顶层复合索引。外键自动建立索引,不自动加载关联。结构化数据显式使用 Json。
Model 的 `Text` 字段写入时默认去除首尾 Unicode 空白,再检查长度;不处理内部空白。`raw` 保留原文,`lower`/`upper` 在去除首尾空白后转换大小写,三者互斥。可空字段的 null 保持不变,空串不转为 null。普通函数中的 Text、查询条件、读取结果和原生 SQL 不自动转换。
```dever
type Category {
name: Text(1, 80)
code: Text(1, 40) lower unique
description: Text?
content: Text raw
active: Bool default true
}
```
create、update、条件更新、createMany、upsert 和 REST 使用同一写入规则。default、Seed 在编译期按该规则处理。富文本、代码、签名原文等需要逐字保留的字段使用 raw;不会依据字段名称猜测策略。去除空白不负责 HTML 安全过滤。
同领域 App 使用 model.create/createMany/get/first/list/cursor/count/exists/stream/update/delete/upsert。多 Model 使用 model.topic.operation。查询块只出现在调用位置,不是可保存的动态 Map:
```dever
page = model.list({
where = published == true
order = [created_at.desc, id.desc]
page = 1
size = 20
})
```
where 支持布尔组合、比较、null、in、between、contains、startsWith、endsWith;结构静态确定,值参数绑定。list 返回 items/page/size/total/pages;cursor 返回 items/next/has_more,不做 count。max_page_size 限制结果规模。
update(id, values) 返回记录,不存在时失败;条件 update/delete 返回影响行数,必须明确 where。delete(id) 返回影响行数。关系用 with 显式加载,返回 Related 的 Unloaded/Loaded 状态;仅 first/list/cursor 支持一层关系。
### REST 字段
create/replace/search 表达式写在 Model 字段上:引用 input 时转换客户端值,否则由服务端提供并拒绝客户端同名输入。表达式只允许纯计算及指定的只读请求能力。search 最多一个,不能叫 page/size。
`private owner user_id: ... = dever.auth.userId()` 创建时绑定可信用户,全部读写 SQL 均包含 owner 条件。private 字段不出现在 REST 输出;没有 replace 来源时 PUT 保留其原值。
普通 CRUD 直接使用 rest model;关联编排、状态转换或不同输出合同由 App 实现。
### 事务
```dever
transaction publish(id: model.id) (article: model.Article) {
article = model.update(id, { published = true })
}
```
普通写 HTTP、CMD、Job 建立整体事务,成功编码后提交。嵌套 transaction 复用同一连接的事务;失败或取消回滚后传播原错误。GET 不写库或入队。
long 入口不建立整体事务,写库和入队必须进入显式 transaction。外部 I/O、Worker、等待、并发和密码计算放在事务外,写入依赖的状态在事务内重新确认。long 已提交阶段不会因后续失败撤销。
事务不能跨物理数据库或混用 global/tenant 存储,不暴露 begin/commit/rollback。数据库失败保留 dever.database.Error。
### 数据库、迁移与租户
Model 优先使用组件同名连接,否则 database.default;显式 `database report` 绑定固定连接,缺失时报错。SQLite/PostgreSQL 配置在 setting.json;PostgreSQL 明确选择 TLS system/disabled。
当前 Model 是目标 schema。空库建最终表;安全变更自动应用,收紧约束先检查数据。字段重命名用 `from old_name`;破坏性变化用命名 migrate。已执行迁移的内容与顺序不可修改。
migrate 可含 before/after SQL,每块提供 sqlite、postgres 和参数列表,只允许单条参数化 INSERT/UPDATE/DELETE。全新数据库不执行历史数据变换。seed 按唯一键补缺失记录,不覆盖既有数据;迁移原子性限于所属 Model。
复杂查询在 Model 声明 `sql name(inputs) (output)`,两种方言均为静态字符串;结果是具体 Model、私有 SQL record 或本领域 App View。只有可证明只读的 SQL 能用于 GET。
启用 tenant 后,global type 使用控制库,其余 Model/Job 使用租户库。只支持 database 隔离。租户库须先显式迁移;缺失、未就绪或 schema 过期时报错,不回退控制库。
运维入口为 `dever tenant migrate`、`dever tenant owner`、`dever tenant component enable|disable`。数据库权限和真实数据操作由部署侧管理。
## Job
```dever
database default
job store(input: app.Request) () retry(3) timeout(30000) {
app.store(input)
}
```
Job 输入为零个或一个本领域 App record,无输出,只绑定本领域 App。retry 为 1–100 次,包含首次执行;timeout 为 1–3600000 毫秒。定时声明为 `schedule name = "0 * * * *"`,使用 UTC。
App 使用 dever.job.enqueue/enqueueAt,传入任务、可选 payload 和幂等 key。key 为 1–1024 字节;同租户、任务身份和 key 的活跃任务复用 Id。入队与业务写入可共享事务。
任务至少执行一次,外部副作用须业务幂等。受保护 HTTP 入队保存可信身份与精确入口权限键,执行时重验;public/authenticated 不能入队角色权限绑定的任务。CMD、schedule、测试使用 System 身份。身份失效、坏 payload 或不兼容 schema 进入 blocked。
runtime.mode 为 api/worker/all。任务配置包括 workers、poll_ms、lease_ms 和重试间隔;SQLite 的 lease 必须长于 Job timeout,worker 连接池需留出监管连接。停止时先停止接收和 claim,再按 shutdown_ms 排空。
测试使用临时数据库和用例独立时钟,可用 dever.test.advanceClock、drainJobs 验证队列。
## Port、Adapter 与依赖
Port 声明输入、命名输出和明确失败集合:
```dever
send(message: Text) () fails app.DeliveryError
```
Adapter 用 `port.send(...) (...) { ... }` 实现。每个文件完整实现一个 Port;单实现自动绑定,多实现由 setting.json 的 adapter 配置选择。实现只能抛出 Port 声明的错误。Port 不接受 handler 或数值范围参数。
Adapter 可声明 setting,选中的实现从配置读取同类型字段。外部程序用 external command/exec/pip/npm/go;代码可信性、网络、文件和设备权限由运行环境负责,allow 声明不是 OS 沙箱。
| 方式 | 用途 |
| --- | --- |
| external command | 普通 ELF;argv/stdin 输入,code/stdout/stderr 输出,无 shell |
| external exec | 遵循 Port Worker 协议的程序 |
| external pip/npm/go | 由相应生态实现 Port,使用锁定资源 |
外部调用和 fake 用法见 [依赖](skills/references/libraries.md)、[外部库示例](skills/references/external-lib.md)。协议与运行资源见 [SDK](sdk/README.md)。
Dever Package 是 module 下的可复用组件,使用 `dever package add/update/remove/list/doctor/export` 管理。dever.lock 固定版本、来源和摘要,不能覆盖本地同名组件。已有锁通过 `dever lib install` 恢复资源;run/build 消费锁定资产。当前 CLI 不提供 Lib add/update 或 target add。
## 标准库与数据格式
标准库按包名直接调用,无需 import。功能目录见 [标准库](lib/README.md),参数和输出见 [签名索引](skills/references/signatures.md)。
### JSON 数据
API/CMD 根据参数和输出类型编解码。Decimal/Id/Uuid 使用字符串,ModelId 为整数,日期时间使用规范字符串,Duration 为整数毫秒。支持 List、可空、可公开传输的 record 和无载荷、无 error 分支的 Choice。此类 Choice 按声明的分支名编码为 JSON 字符串,也可作 query 或 multipart 标量;未知分支报错。带载荷或 error 分支的 Choice、Map、资源不用于普通 wire。缺少可空字段得到 null,未知、重复和缺少必填字段失败。
已有 Json 需要转换成具体类型时,使用 `dever.json.decode(value)`;目标类型由局部声明、命名输出、字段或参数类型确定。直接从已有原文解码,不转为 Text 或 Document。解码失败传播 `dever.json.JsonResult.Failed`。Text 不在此处清洗。
需要处理解码失败时,直接写 `decoded: DecodeResult = result(dever.json.decode(payload))`。DecodeResult 必须有一个含单个目标类型字段的成功分支,以及包装 dever.json.JsonResult 的 error 分支,例如 `Parsed(value: CreateInput)` 和 `error Invalid(cause: dever.json.JsonResult)`。不需要另写解码函数;无类型上下文时不会猜测目标类型。
单个布尔条件失败使用 `dever.api.require(condition, dever.api.Error.Invalid)`;false 传播指定错误,true 继续。该函数不代替业务状态分支和执行时的权限校验。
日期时间使用 RFC3339,输出 UTC,最多毫秒精度;Date 为 YYYY-MM-DD,Time 为 HH:MM:SS[.sss]。无效日期、非有限 Float、越界整数失败。
Json 提供 fromText/toText、object/array、propertyValue/setProperty 等操作;encode 按实参具体类型输出 Json。原始数字不经过 Float。Json 限制为 16 MiB、64 层、65536 个值。
需要遍历节点时使用 parse/stringify 和 Document:root 为根 ID,nodes 为连续节点列表,子 ID 小于父 ID。Number 保留原文,Object 保留键顺序。node/property/element 缺失返回 null;JSON null 是实际节点。Document 的解析深度上限为 256,stringify 检查手工节点引用。
### 文件、HTTP 与流
文件 create 只建新文件,read 可能短读。I/O 错误通过声明失败传播;流的终止错误最多产生一次,之后结束。TCP 支持同时读写,每个方向串行;部分写入后失败关闭连接,不重发整条消息。
HTTP 支持 HTTP/1、HTTP/2、TLS、连接池、流式响应、SSE 与 HTTP/1 WebSocket。普通应用使用 API 声明,底层服务和客户端放 Adapter。
- Limits 控制头、正文、期限和连接数;HTTP/2 另限制流数与接收窗口。
- serve/send 缓冲完整正文;serveLive/open/openStream 按块处理。
- 池和会话归创建它们的任务作用域,响应须消费或关闭后释放名额。
- TLS 校验证书和名称,不自动降级;HTTP/2 使用 ALPN 或明文 prior knowledge。
- HTTP 协议头和正文定界由引擎管理;应用不写 Content-Length/Transfer-Encoding/Upgrade。
- 关闭 Listener 停止接收并排空;stop 取消服务并等待清理。
- 不支持 CONNECT、非空 trailer、服务器推送、WebSocket-over-HTTP/2、WebSocket 子协议或压缩扩展。
## 检查、测试与构建
```sh
dever new my_app
dever fmt my_app --check
dever check my_app
dever test my_app --filter hello.greeting
dever run my_app -- hello.greeting.greet '{"name":"Dever"}'
dever build my_app --output my_app/program
```
check 检查源码、配置、类型规则和生产入口可达性,不启动应用。重复实现和业务合理性仍需代码审查。
测试入口把文件主题转成 lowerCamelCase,例如 account_login.dever 对应 accountLogin() (),可调用本领域 App/Domain。外部 Port 使用用例独立 fake;Model 使用临时 SQLite。筛选不跳过源码检查,每例独立进程,默认 60 秒;任一失败返回非零。
`dever api <root> --output <新文件>` 导出接口快照。已有 dever.api 时,check/run/build 检查基线;变更需审查新快照,工具不自动覆盖。
构建输出须是新文件。生成的程序可脱离开发包运行,仍需配套配置和系统运行库。
安装环境与交叉编译见 [安装说明](build/README.md)和 [编译器开发](rust/README.md)。
+57
View File
@@ -0,0 +1,57 @@
# Dever
Dever 用于开发 HTTP 服务和命令行程序。源码使用 `.dever`,编译成原生二进制,运行时不需要 Dever 或 Rust 开发工具。
当前版本:`0.0.1`。
## 安装
```sh
curl -fsSL https://git.dever.cc/dever/core/releases/download/v0.0.1/install | bash
```
支持 Linux x86_64、glibc 2.41 及以上,已在 Debian 13 验证。安装器会后台准备编译环境;准备期间可以创建项目、编辑和检查代码,首次测试、运行或构建会等待准备完成。
CI 安装可加 `--wait`。离线包和 VS Code 插件见 [发布页](https://git.dever.cc/dever/core/releases/tag/v0.0.1),详细步骤见 [安装说明](build/README.md)。
## 开始使用
```sh
dever new hello
dever check hello
dever test hello
dever run hello -- hello.greeting.greet '{"name":"Dever"}'
dever build hello --output ./hello-app
./hello-app hello.greeting.greet '{"name":"World"}'
```
`dever new` 生成源码、测试和说明。开发服务、数据库和后台任务可参考 [完整示例](examples/README.md)。
## 主要功能
- 静态类型、命名输出、函数分句和有序集合。
- HTTP/1、HTTP/2、TLS、SSE、WebSocket 和文件上传。
- Model、REST、SQLite/PostgreSQL、事务和租户数据库。
- 认证、角色权限、持久化 Job 和并发任务。
- Python、JavaScript、Go 外部组件。
- VS Code 悬浮说明、源码跳转和自动调用图。
## 文档
| 内容 | 入口 |
| --- | --- |
| 语法与语言规则 | [语言规范](LANGUAGE.md) |
| 项目结构与开发方法 | [开发指引](skills/references/development.md) |
| 源码说明与调用流程 | [说明写法](DOCUMENTATION.md) |
| 标准库 | [功能目录](lib/README.md) |
| 示例项目 | [CMS](examples/README.md)、[入门示例](skills/references/examples.md) |
| VS Code | [插件说明](editor/vscode/README.md) |
| AI 开发 | [Dever Skill](skills/SKILL.md) |
| 编译器开发 | [Rust 源码与测试](rust/README.md) |
| 性能测试 | [测试方法](test/performance/README.md) |
## 发布环境
安装包附 SHA-256,当前未签名。ARM64 和 ESP32 尚未完成设备验证。
生产容器只需放入业务二进制、配置和所需资源,不需要开发包。首次构建会编译依赖,后续复用缓存。
+64
View File
@@ -0,0 +1,64 @@
# 安装 Dever
支持 Linux x86_64、glibc 2.41 及以上,已在 Debian 13 验证。应用开发只需安装 Dever,编译环境由安装包管理。
## 在线安装
```sh
curl -fsSL https://git.dever.cc/dever/core/releases/download/v0.0.1/install | bash
```
默认安装到 `~/.dever`。重新打开终端,或执行安装器打印的命令加载 PATH。
安装器先提供 Dever 命令,再后台下载编译环境。准备期间可使用 `new/fmt/check`;首次 `run/test/build` 会等待准备完成。下载中断后再次安装或构建即可继续。首次构建还会编译依赖,后续使用缓存。
CI 需要等待完整环境就绪:
```sh
curl -fsSL https://git.dever.cc/dever/core/releases/download/v0.0.1/install | bash -s -- --wait
```
| 选项 | 用途 |
| --- | --- |
| `--prefix <目录>` | 指定安装目录 |
| `--no-modify-path` | 不修改终端启动文件 |
| `--wait` | 等待编译环境就绪 |
| `--release-url <地址>` | 使用自托管发行资源 |
需要 Bash、支持并行下载的 curl、tar、xz、sha256sum、flock、getconf 和 nohup。`RUSTUP_DIST_SERVER` 可指定受信任的工具链镜像。
同版本安装会复用已有文件。验证重新打包的 `0.0.1` 时使用新的 `--prefix` 目录。安装不覆盖项目、系统编译器或用户 Cargo 配置。
## 离线安装
从 [发布页](https://git.dever.cc/dever/core/releases/tag/v0.0.1) 下载完整开发包及校验文件,在可写目录解压:
```sh
sha256sum -c dever-0.0.1-linux-x86_64-development.tar.xz.sha256
tar -xf dever-0.0.1-linux-x86_64-development.tar.xz
export PATH="$PWD/dever/bin:$PATH"
dever new hello
dever test hello
dever run hello -- hello.greeting.greet '{"name":"Dever"}'
```
完整包包含编译工具和离线依赖,无需另外安装 Rust、Cargo 或 GCC。外部 Python/JavaScript/Go 组件仍需准备它们各自的运行资源。
## 缓存与排错
在线安装的共享工具在 `toolchains/`,依赖缓存在 `cache/cargo/`;离线包使用包内 `toolchain/`。项目编译缓存位于安装包的 `rust/target/`。
环境准备失败时,命令会给出 `prepare.log` 路径;构建错误给出 `build.log` 路径。失败返回非零状态,修复后重试原命令。
安装包附 SHA-256,当前未签名。包中保留第三方许可证和工具版本记录。
## 交付程序
```sh
dever build hello --output ./hello-app
./hello-app hello.greeting.greet '{"name":"Dever"}'
```
部署只携带业务二进制、配置和必要资源;目标系统仍须具备匹配的系统运行库。安装包可以搬到新目录,移动后可能需要重新编译缓存。
编译器作者组装安装包的方法见 [源码开发](../rust/README.md)。
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""在 Debian 13 开发容器内组装 Linux x86_64 完整离线开发包。"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tomllib
SOURCE = Path(__file__).resolve().parents[1]
TRIPLE = "x86_64-unknown-linux-gnu"
PACKAGES = (
"gcc-14-x86-64-linux-gnu", "cpp-14-x86-64-linux-gnu", "libgcc-14-dev",
"libc6-dev", "linux-libc-dev", "binutils-x86-64-linux-gnu", "libc6", "libgcc-s1",
)
def native_input(path):
"""只保留原生 C 构建所需工具;Rust LTO 使用包内 LLVM,不需要 GCC LTO。"""
name = str(path)
if name.startswith("/usr/include/"):
return True
if path.parent == Path("/usr/bin"):
return path.name in {
"x86_64-linux-gnu-gcc-14", "x86_64-linux-gnu-as",
"x86_64-linux-gnu-ld", "x86_64-linux-gnu-ar", "x86_64-linux-gnu-ranlib",
}
if name.startswith("/usr/libexec/gcc/"):
return path.name in {"cc1", "collect2", "liblto_plugin.so", "lto-wrapper"}
if name.startswith("/usr/lib/gcc/"):
return "/include/" in name or path.name in {
"libgcc.a", "libgcc_eh.a", "libgcc_s.so", "crtbegin.o", "crtbeginS.o",
"crtbeginT.o", "crtend.o", "crtendS.o",
"liblto_plugin.so",
}
return path.parent in {Path("/usr/lib/x86_64-linux-gnu"), Path("/usr/lib64")}
def output(*arguments, **options):
return subprocess.check_output(arguments, text=True, **options).strip()
def digest(path):
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def copy(source, destination):
"""只解引用作者明确选定的工具文件,不复制 Cargo home 或用户配置。"""
if source.is_dir():
shutil.copytree(source, destination, dirs_exist_ok=True)
else:
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, destination)
destination.chmod(0o755 if os.access(source, os.X_OK) else 0o644)
def script(path, body):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('#!/bin/sh\nset -eu\ntools=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)\n' + body)
path.chmod(0o755)
def prepare(compiler, work):
"""两种交付方式共用现有本地打包入口,不另做编译器发行格式。"""
work.mkdir()
version = tomllib.loads((SOURCE / "rust/Cargo.toml").read_text())["workspace"]["package"]["version"]
rust_version = tomllib.loads((SOURCE / "rust/rust-toolchain.toml").read_text())["toolchain"]["channel"]
author = work / "author"
for path in (
"rust/Cargo.toml", "rust/Cargo.lock", "rust/rust-toolchain.toml", "rust/src",
"lib", "skills", "LANGUAGE.md", "DOCUMENTATION.md",
):
copy(SOURCE / path, author / "source" / path)
copy(compiler, author / "bin/dever")
(author / "config").mkdir()
(author / "config/setting.json").write_text(json.dumps({
"format": "dever-local-bootstrap-input-v1", "version": version,
"target": "linux-x86_64", "source": "source",
"core": {"source": "bin/dever", "sha256": digest(compiler)},
}))
bundle = work / "dever"
subprocess.run([str(compiler), "release", str(author), "--output", str(bundle)], check=True)
return bundle, version, rust_version
def record(bundle, paths):
"""把交付附带文件纳入原有清单校验。"""
manifest = json.loads((bundle / "bootstrap.json").read_text())
for path in sorted(paths):
if path.is_symlink():
raise RuntimeError(f"staged artifact must be a regular file: {path}")
if path.is_file():
manifest["artifacts"].append({
"path": path.relative_to(bundle).as_posix(), "size": path.stat().st_size,
"sha256": digest(path), "executable": os.access(path, os.X_OK),
})
manifest["artifacts"].sort(key=lambda artifact: artifact["path"])
(bundle / "bootstrap.json").write_text(json.dumps(manifest, indent=2) + "\n")
return manifest
def native_tools(tools):
"""保留 Debian 工具的相对安装布局;glibc 只进链接 sysroot,不覆盖宿主加载器。"""
base = {str(Path(path).resolve()) for path in output("dpkg-query", "-L", "libc6").splitlines()}
packages = set(PACKAGES)
executables = []
for package in PACKAGES:
for name in output("dpkg-query", "-L", package).splitlines():
path = Path(name)
if not path.is_file() or not native_input(path):
continue
copy(path, tools / "native" / path.relative_to("/"))
# Debian 的链接脚本使用 /lib;发行包展开 usrmerge 别名,不改写官方库文件。
if package == "libc6":
copy(path, tools / "native" / path.relative_to("/usr"))
if os.access(path, os.X_OK):
with path.open("rb") as stream:
if stream.read(4) == b"\x7fELF":
executables.append(path)
for name in ("as", "ld", "ar"):
copy(Path(f"/usr/bin/x86_64-linux-gnu-{name}"), tools / "native/usr/bin" / name)
libraries = set()
for executable in executables:
listing = subprocess.run(["ldd", str(executable)], text=True, capture_output=True)
if listing.returncode and "not a dynamic executable" not in listing.stderr + listing.stdout:
raise RuntimeError(f"ldd {executable}: {listing.stderr}{listing.stdout}")
for line in listing.stdout.splitlines():
if "=> not found" in line:
raise RuntimeError(line)
fields = line.split()
if "=>" in fields and fields[2].startswith("/"):
library = Path(fields[2])
if str(library.resolve()) not in base:
libraries.add(library)
for library in sorted(libraries):
copy(library, tools / "lib" / library.name)
owner = output("dpkg-query", "-S", str(library.resolve())).splitlines()[0].split(": ", 1)[0]
packages.add(owner)
copy(Path("/usr/lib/x86_64-linux-gnu/libgcc_s.so.1"), tools / "lib/libgcc_s.so.1")
packages.add("libgcc-s1")
for package in sorted(packages):
name = package.split(":", 1)[0]
copy(Path("/usr/share/doc") / name / "copyright", tools / "licenses" / name / "copyright")
copy(Path("/usr/share/common-licenses"), tools / "licenses/common")
(tools / "native-packages.txt").write_text(output("dpkg-query", "-W", *sorted(packages)) + "\n")
script(tools / "bin/cc", '''export LD_LIBRARY_PATH="$tools/lib"
exec "$tools/native/usr/bin/x86_64-linux-gnu-gcc-14" \\
--sysroot="$tools/native" \\
-B"$tools/native/usr/lib/gcc/x86_64-linux-gnu/14/" \\
-B"$tools/native/usr/libexec/gcc/x86_64-linux-gnu/14/" \\
-B"$tools/native/usr/bin/" \\
-B"$tools/native/usr/lib/x86_64-linux-gnu/" "$@"
''')
def trim_vendor(vendor, version):
"""开发包编译业务程序;按 Cargo 完整依赖图保留运行库的全部特性和平台依赖。"""
metadata = json.loads(output("cargo", f"+{version}", "metadata", "--locked", "--offline",
"--all-features", "--format-version", "1", cwd=SOURCE / "rust"))
dependencies = {node["id"]: node["dependencies"] for node in metadata["resolve"]["nodes"]}
pending = [package["id"] for package in metadata["packages"] if package["name"] == "dever-runtime"]
required = set()
while pending:
package = pending.pop()
if package not in required:
required.add(package)
pending.extend(dependencies[package])
for package in metadata["packages"]:
if package["source"] and package["id"] not in required:
directory = vendor / f'{package["name"]}-{package["version"]}'
# 编译器二进制仍使用这些依赖;源码可移除,第三方许可必须随包保留。
licenses = {path for path in directory.rglob("*") if path.is_file()
and path.name.upper().startswith(("LICENSE", "COPYING", "COPYRIGHT", "NOTICE", "AUTHORS"))}
if package["license_file"]:
licenses.add(directory / package["license_file"])
for path in licenses:
copy(path, vendor.parent / "licenses/cargo" / path.relative_to(vendor))
shutil.rmtree(directory)
def rust_tools(tools, version, vendor):
rust = Path(output("rustc", f"+{version}", "--print", "sysroot"))
if f"host: {TRIPLE}" not in output("rustc", f"+{version}", "-vV"):
raise RuntimeError(f"this package requires the official {TRIPLE} toolchain")
for name in ("cargo", "rustc"):
copy(rust / "bin" / name, tools / "rust/bin" / name)
for library in (rust / "lib").glob("*.so*"):
if not library.is_symlink():
copy(library, tools / "rust/lib" / library.name)
for name in ("lib", "bin"):
copy(rust / "lib/rustlib" / TRIPLE / name, tools / "rust/lib/rustlib" / TRIPLE / name)
# 只裁剪已链接的工具;标准库 rlib/rmeta 中的符号、类型元数据与位码仍用于编译。
binaries = [*(tools / "rust/bin").iterdir(), *(tools / "rust/lib").glob("*.so*")]
binaries.extend(path for path in (tools / "rust/lib/rustlib" / TRIPLE / "bin").rglob("*")
if path.is_file())
for binary in binaries:
# 官方 .so 目录也含链接脚本,按原样保留,只处理 ELF 文件。
with binary.open("rb") as stream:
if stream.read(4) == b"\x7fELF":
subprocess.run(["strip", "--strip-unneeded", str(binary)], check=True)
copy(rust / "share/doc/rust", tools / "licenses/rust")
if vendor:
copy(vendor, tools / "vendor")
else:
subprocess.run([
"cargo", f"+{version}", "vendor", "--locked", "--offline", "--versioned-dirs",
str(tools / "vendor"),
], cwd=SOURCE / "rust", check=True)
trim_vendor(tools / "vendor", version)
config = tools / ".cargo/config.toml"
config.parent.mkdir()
config.write_text('''[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "vendor"
''')
script(tools / "bin/cargo", '''export CARGO_HOME="$tools/.cargo"
export RUSTC="$tools/rust/bin/rustc"
export CC="$tools/bin/cc"
export AR="$tools/native/usr/bin/x86_64-linux-gnu-ar"
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER="$CC"
export LD_LIBRARY_PATH="$tools/lib"
export PATH="$tools/native/usr/bin:/usr/bin:/bin"
exec "$tools/rust/bin/cargo" --config "$tools/.cargo/config.toml" "$@"
''')
script(tools / "bin/rustc", '''export LD_LIBRARY_PATH="$tools/lib"
exec "$tools/rust/bin/rustc" "$@"
''')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--compiler", required=True, type=Path)
parser.add_argument("--work", required=True, type=Path, help="new staging directory")
parser.add_argument("--output", required=True, type=Path, help="new .tar.xz or .tar.gz archive")
parser.add_argument("--vendor", type=Path, help="optional cargo vendor --locked --versioned-dirs result")
args = parser.parse_args()
system = Path("/etc/os-release").read_text()
if 'ID=debian\n' not in system or 'VERSION_ID="13"' not in system:
parser.error("prepare this package in the Debian 13 development container")
args.compiler = args.compiler.resolve(strict=True)
args.work = args.work.absolute()
args.output = args.output.absolute()
if not args.output.name.endswith((".tar.xz", ".tar.gz")):
parser.error("output must end in .tar.xz or .tar.gz")
if args.output.exists() or Path(str(args.output) + ".sha256").exists():
parser.error("output archive and checksum must not exist")
bundle, _, rust_version = prepare(args.compiler, args.work)
author = args.work / "author"
tools = bundle / "toolchain"
tools.mkdir()
native_tools(tools)
rust_tools(tools, rust_version, args.vendor)
# 先实际链接,避免把缺少 sysroot 文件的包花时间压缩后才发现。
smoke = author / "link.rs"
smoke.write_text('fn main() { println!("private Rust/C linker ready"); }\n')
subprocess.run([str(tools / "bin/rustc"), str(smoke), "-C",
"linker=" + str(tools / "bin/cc"), "-o", str(author / "link")], check=True)
subprocess.run([str(author / "link")], check=True)
copy(SOURCE / "build/README.md", bundle / "DEVELOPMENT.md")
manifest = record(bundle, [bundle / "DEVELOPMENT.md", *tools.rglob("*")])
args.output.parent.mkdir(parents=True, exist_ok=True)
# 归档必须先于第一次构建,不能把 Cargo 编译缓存放进发行包。
with args.output.open("xb") as stream:
compression = "xz -T4 -6" if args.output.name.endswith(".xz") else "gzip -9"
subprocess.run(["tar", "-C", str(args.work), "-I", compression, "-cf", "-", "dever"],
stdout=stream, check=True)
Path(str(args.output) + ".sha256").write_text(f"{digest(args.output)} {args.output.name}\n")
print(json.dumps({"archive": str(args.output), "bytes": args.output.stat().st_size,
"files": len(manifest["artifacts"]), "compiler_sha256": digest(args.compiler)}))
if __name__ == "__main__":
main()
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""在开发容器组装在线安装资源;不下载工具链、不发布或激活版本。"""
import argparse
import json
from pathlib import Path
import subprocess
import tomllib
from development import SOURCE, TRIPLE, copy, digest, native_tools, output, prepare, record, script
def archive(directory, destination):
with destination.open("xb") as stream:
subprocess.run([
"tar", "--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner",
"-C", str(directory.parent), "-I", "xz -T4 -6", "-cf", "-", directory.name,
], stdout=stream, check=True)
return digest(destination)
def checksum(path):
Path(str(path) + ".sha256").write_text(f"{digest(path)} {path.name}\n")
def dependencies(bundle, rust_version):
"""Cargo 自己裁剪锁文件;安装时只下载本机运行库依赖,不带作者编译依赖。"""
project = bundle / "toolchain/project"
(project / "src").mkdir(parents=True)
(project / "src/main.rs").write_text("fn main() {}\n")
features = sorted(tomllib.loads((bundle / "rust/src/runtime/Cargo.toml").read_text())["features"])
(project / "Cargo.toml").write_text(
'[package]\nname = "dever-development-dependencies"\nversion = "0.0.0"\nedition = "2024"\n'
'[workspace]\n[dependencies]\n'
f'dever-runtime = {{ path = "../../rust/src/runtime", features = {json.dumps(features)} }}\n'
)
copy(bundle / "rust/Cargo.lock", project / "Cargo.lock")
subprocess.run(["cargo", f"+{rust_version}", "metadata", "--offline", "--format-version", "1",
"--manifest-path", str(project / "Cargo.toml")], stdout=subprocess.DEVNULL, check=True)
def compiler_licenses(tools, rust_version):
metadata = json.loads(output("cargo", f"+{rust_version}", "metadata", "--locked", "--offline",
"--all-features", "--format-version", "1", cwd=SOURCE / "rust"))
for package in metadata["packages"]:
if not package["source"]:
continue
source = Path(package["manifest_path"]).parent
licenses = {path for path in source.rglob("*") if path.is_file()
and path.name.upper().startswith(("LICENSE", "COPYING", "COPYRIGHT", "NOTICE", "AUTHORS"))}
if package["license_file"]:
licenses.add(source / package["license_file"])
for path in licenses:
copy(path, tools / "licenses/cargo" / source.name / path.relative_to(source))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--compiler", required=True, type=Path)
parser.add_argument("--work", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path, help="new release assets directory")
args = parser.parse_args()
system = Path("/etc/os-release").read_text()
if 'ID=debian\n' not in system or 'VERSION_ID="13"' not in system:
parser.error("prepare native tools in the Debian 13 development container")
compiler = args.compiler.resolve(strict=True)
work, destination = args.work.absolute(), args.output.absolute()
if work.exists() or destination.exists():
parser.error("work and output must be new directories")
destination.mkdir(parents=True)
bundle, version, rust_version = prepare(compiler, work)
tools = bundle / "toolchain"
tools.mkdir()
native = work / "native-tools"
native.mkdir()
native_tools(native)
native_archive = destination / "native.tar.xz"
native_id = archive(native, native_archive)
native_archive = native_archive.rename(destination / f"dever-native-linux-x86_64-{native_id}.tar.xz")
checksum(native_archive)
copy(native / "bin/cc", tools / "bin/cc")
script(tools / "bin/cargo", '''export CARGO_HOME="$tools/cache"
export RUSTC="$tools/rust/bin/rustc"
export CC="$tools/bin/cc"
export AR="$tools/native/usr/bin/x86_64-linux-gnu-ar"
export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER="$CC"
export LD_LIBRARY_PATH="$tools/lib"
export PATH="$tools/native/usr/bin:/usr/bin:/bin"
exec "$tools/rust/bin/cargo" "$@"
''')
script(tools / "bin/rustc", '''export LD_LIBRARY_PATH="$tools/lib"
exec "$tools/rust/bin/rustc" "$@"
''')
(tools / "online").write_text(f"{rust_version} {native_id}\n")
copy(SOURCE / "build/prepare.sh", tools / "prepare")
dependencies(bundle, rust_version)
compiler_licenses(tools, rust_version)
copy(SOURCE / "build/README.md", bundle / "DEVELOPMENT.md")
record(bundle, [bundle / "DEVELOPMENT.md", *tools.rglob("*")])
core_archive = destination / f"dever-{version}-linux-x86_64.tar.xz"
archive(bundle, core_archive)
checksum(core_archive)
installer = (SOURCE / "install").read_text().replace('@DEVER_VERSION@', version)
(destination / "install").write_text(installer)
(destination / "install").chmod(0o755)
checksum(destination / "install")
print(json.dumps({"version": version, "compiler_sha256": digest(compiler),
"core_bytes": core_archive.stat().st_size,
"native_bytes": native_archive.stat().st_size,
"installer_bytes": (destination / "install").stat().st_size,
"rust": rust_version, "target": TRIPLE}))
if __name__ == "__main__":
main()
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# 在线包的唯一环境准备入口;安装器后台启动,编译入口等待同一把锁。
set -euo pipefail
package=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
tools="$package/toolchain"
root=$(cd "$package/../.." && pwd -P)
[[ ! -f $tools/ready ]] || exit 0
exec 9>"$root/.prepare.lock"
previous=
until flock -w 1 9; do
status='Waiting for Dever environment preparation...'
if [[ -f $tools/status ]]; then read -r status < "$tools/status" || true; fi
if [[ $status != "$previous" ]]; then printf '%s\n' "$status" >&2; previous=$status; fi
done
[[ ! -f $tools/ready ]] || exit 0
exec 3>&2
exec >"$tools/prepare.log" 2>&1
stage=
rust_stage=
phase='Preparing Dever environment'
finish() {
local code=$?
if (( code )); then
printf 'Failed: %s (exit %s). Details: %s\n' "$phase" "$code" "$tools/prepare.log" > "$tools/status"
cat "$tools/status" >&3
fi
if [[ -n $stage ]]; then rm -rf -- "$stage"; fi
if [[ -n $rust_stage ]]; then rm -rf -- "$rust_stage"; fi
}
trap finish EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
progress() {
phase=$1
printf '%s\n' "$phase" > "$tools/status"
printf '%s\n' "$phase" >&3
}
read -r rust_version native_id < "$tools/online"
mapfile -t sources < "$tools/sources"
release_url=${sources[0]}
rust_dist=${sources[1]}
triple=x86_64-unknown-linux-gnu
rust_root="$root/toolchains/rust-$rust_version-$triple"
native_root="$root/toolchains/native-$native_id"
downloads="$root/cache/downloads"
mkdir -p "$downloads"
stage=$(mktemp -d "$root/.prepare.XXXXXX")
transfers=()
archives=()
queue() {
local url=$1 name=$2
archives+=("$name")
if (( ${#transfers[@]} )); then transfers+=(--next); fi
transfers+=(--fail --location --silent --show-error --retry 2 --connect-timeout 15
--speed-limit 1024 --speed-time 60 --continue-at - --output "$downloads/$name.part" "$url")
transfers+=(--next --fail --location --silent --show-error --retry 2 --connect-timeout 15
--output "$downloads/$name.sha256" "$url.sha256")
}
native_archive="dever-native-linux-x86_64-$native_id.tar.xz"
if [[ ! -d $native_root ]]; then queue "$release_url/$native_archive" "$native_archive"; fi
if [[ ! -d $rust_root ]]; then
for component in rustc rust-std cargo; do
name="$component-$rust_version-$triple.tar.xz"
queue "$rust_dist/dist/$name" "$name"
done
fi
if (( ${#archives[@]} )); then
progress 'Downloading Dever build environment...'
curl --parallel --parallel-immediate --parallel-max 3 --fail-early "${transfers[@]}"
progress 'Verifying Dever build environment...'
for name in "${archives[@]}"; do
read -r expected remainder < "$downloads/$name.sha256"
if [[ $name == "$native_archive" && $expected != "$native_id" ]] ||
! printf '%s %s\n' "$expected" "$downloads/$name.part" | sha256sum --check --status; then
rm -f -- "$downloads/$name.part" "$downloads/$name.sha256"
echo "Checksum mismatch: $name" >&2
exit 1
fi
done
fi
progress 'Installing Dever build environment...'
if [[ ! -d $rust_root ]]; then
# 官方脚本不支持含空格的 prefix;先在独占临时目录安装,再原子发布。
rust_stage=$(mktemp -d /tmp/dever-toolchain.XXXXXX)
for component in rustc rust-std cargo; do
name="$component-$rust_version-$triple"
tar -xJf "$downloads/$name.tar.xz.part" -C "$rust_stage"
bash "$rust_stage/$name/install.sh" --prefix="$rust_stage/toolchain" --disable-ldconfig
rm -rf -- "$rust_stage/$name"
done
mv "$rust_stage/toolchain" "$stage/rust"
mv "$stage/rust" "$rust_root"
fi
if [[ ! -d $native_root ]]; then
tar -xJf "$downloads/$native_archive.part" -C "$stage"
mv "$stage/native-tools" "$native_root"
fi
"$tools/bin/rustc" --version
progress 'Preparing Dever project dependencies...'
"$tools/bin/cargo" fetch --locked --target "$triple" --manifest-path "$tools/project/Cargo.toml"
for name in "$native_archive" "rustc-$rust_version-$triple.tar.xz" \
"rust-std-$rust_version-$triple.tar.xz" "cargo-$rust_version-$triple.tar.xz"; do
rm -f -- "$downloads/$name.part" "$downloads/$name.sha256"
done
touch "$tools/ready"
progress 'Dever build environment is ready.'
+24
View File
@@ -0,0 +1,24 @@
{
"database": {
"default": {
"type": "postgres",
"url": "postgresql://dever@127.0.0.1:5432/dever_default?sslmode=disable",
"tls": "disabled",
"min_connections": 0,
"max_connections": 2,
"max_page_size": 100,
"wait_timeout_ms": 1000,
"io_timeout_ms": 5000
},
"postgres_test": {
"type": "postgres",
"url": "postgresql://dever@127.0.0.1:5432/dever_{case}?sslmode=disable",
"tls": "disabled",
"min_connections": 0,
"max_connections": 2,
"max_page_size": 100,
"wait_timeout_ms": 1000,
"io_timeout_ms": 5000
}
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
*.vsix
dist/
/core.[0-9]*
+12
View File
@@ -0,0 +1,12 @@
**/*
!package.json
!README.md
!language-configuration.json
!syntaxes/**
!extension.cjs
!graph.cjs
!inspection.cjs
!navigation.cjs
!reading.cjs
!hover.cjs
!dist/**
+50
View File
@@ -0,0 +1,50 @@
# Dever for VS Code
为 `.dever` 提供高亮、悬浮说明、源码跳转和调用流程。
## 安装
从 [发布页](https://git.dever.cc/dever/core/releases/tag/v0.0.1) 下载 `dever-language-0.0.1.vsix`,在 VS Code 执行 **Extensions: Install from VSIX**。
插件默认调用 PATH 中的 `dever`,需要带 `inspect` 的编译器。可用 `dever.command` 设置命令及固定参数,值为数组;远程执行时,编译器须能读取相同的源码路径。未信任的工作区不会执行编译器。
## 使用
| 操作 | 功能 |
| --- | --- |
| 悬浮函数 | 显示签名、主题说明和调用摘要 |
| F12 | 跳转定义 |
| Shift+F12 | 查看引用 |
| 显示调用层次结构 | 查看调用方和被调用方 |
| Dever: Preview Documentation | 打开主题说明 |
| Dever: Show Calls | 查看每处调用及源码位置 |
| Dever: Open Domain Links | 阅读领域调用图 |
源码与说明保存后刷新索引。未保存修改或源码检查错误会暂停相关导航并提示原因。查询不运行应用,也不调用 AI。
调用图内置 Mermaid,可离线阅读和点击节点跳转。修改代码后生成流程:
```sh
dever inspect . --links
dever inspect . --links --check
```
图保留分句、参数求值顺序、短路、集合处理、任务、事务和 Port/Adapter 选择。它展示可能执行的路径;部署配置决定实际 Adapter。
当前支持函数、API/CMD、REST、认证、Job、回调、SQL 和 Port/Adapter 导航,尚不提供通用类型和字段引用导航。源码与说明的组织见 [说明写法](https://git.dever.cc/dever/core/src/branch/main/DOCUMENTATION.md)。
## 开发插件
在本目录执行:
```sh
npm ci
npm run build
DEVER_TEST_BIN=/path/to/dever npm run test:navigation
npm test -- --markdown-grammar /path/to/markdown.tmLanguage.json
npm run package
```
流程图浏览器测试使用 `npm run test:flows`,需设置 `DEVER_TEST_BIN` 和 `DEVER_BROWSER_TOOLS`,后者指向已有 playwright-core、Chromium 的 node_modules;系统库路径按测试环境设置。
构建会把 Markdown 解析器和 Mermaid 放入 VSIX,用户无需另装 npm 或渲染库。开发阶段版本统一为 `0.0.1`。
+12
View File
@@ -0,0 +1,12 @@
const esbuild = require('esbuild');
const fs = require('node:fs/promises');
async function build() {
await fs.mkdir('dist', { recursive: true });
await Promise.all([
fs.copyFile(require.resolve('@mermaid-js/tiny'), 'dist/mermaid.js'),
fs.copyFile('flow-browser.js', 'dist/flow-browser.js'),
esbuild.build({ entryPoints: ['flow-preview.cjs'], bundle: true, outfile: 'dist/flow-preview.cjs', minify: true, legalComments: 'eof', platform: 'node', external: ['vscode'] })
]);
}
build().catch(error => { console.error(error); process.exitCode = 1; });
+7
View File
@@ -0,0 +1,7 @@
const { registerNavigation } = require('./navigation.cjs');
function activate(context) {
registerNavigation(context);
}
module.exports = { activate };
+39
View File
@@ -0,0 +1,39 @@
(() => {
const mermaid = globalThis.mermaid;
const vscode = acquireVsCodeApi();
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose', theme: document.body.classList.contains('vscode-dark') ? 'dark' : 'default', flowchart: { htmlLabels: false } });
// 渲染失败保留原始 Mermaid 并显示原因,不把空白图当成成功。
async function render() {
let sequence = 0;
for (const code of document.querySelectorAll('pre > code.language-mermaid')) {
try {
const { svg } = await mermaid.render(`dever-flow-${sequence++}`, code.textContent);
const container = document.createElement('div');
container.className = 'dever-flow';
container.innerHTML = svg;
code.parentElement.replaceWith(container);
} catch (error) {
const message = document.createElement('p');
message.className = 'dever-flow-error';
message.textContent = `流程图渲染失败:${error.message}`;
code.parentElement.before(message);
}
// Mermaid 的连续 Promise 会占满微任务队列;每张图后让页面处理加载、滚动和点击。
await new Promise(resolve => setTimeout(resolve, 0));
}
document.getElementById(document.body.dataset.anchor)?.scrollIntoView();
document.body.dataset.rendered = 'true';
}
document.addEventListener('click', event => {
const link = event.target.closest('a');
if (!link) return;
const href = link.getAttribute('href') || link.getAttribute('xlink:href');
if (!href) return;
event.preventDefault();
if (href.startsWith('#')) document.getElementById(decodeURIComponent(href.slice(1)))?.scrollIntoView();
else vscode.postMessage({ type: 'open', href });
});
render();
})();
+84
View File
@@ -0,0 +1,84 @@
const vscode = require('vscode');
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomBytes } = require('node:crypto');
const { pathToFileURL, fileURLToPath } = require('node:url');
const markdown = require('markdown-it')({ html: true });
const validateLink = markdown.validateLink;
markdown.validateLink = href => href.startsWith('file:') || validateLink(href);
// 流程图与调用表共用阅读面板;链接打开真实源码或当前索引。
async function openFlowPreview(context, target, root, calls) {
const panel = vscode.window.createWebviewPanel('dever.links', 'Dever 调用流程', vscode.ViewColumn.Beside, {
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'dist')]
});
let current = target.fsPath;
async function render(filename, anchor = '') {
const text = calls ? await calls.read(filename) : await fs.readFile(filename, 'utf8');
if (!calls && !text.startsWith('<!-- Generated by dever inspect --links. Do not edit. -->')) {
throw new Error('此文件不是生成的领域索引,请运行 dever inspect . --links。');
}
current = filename;
panel.title = calls ? `Dever · ${filename}` : `Dever · ${path.basename(path.dirname(filename))}`;
const script = panel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, 'dist', 'flow-browser.js'));
const mermaid = panel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, 'dist', 'mermaid.js'));
const nonce = randomBytes(16).toString('hex');
panel.webview.html = `<!DOCTYPE html><html><head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-${nonce}'; style-src ${panel.webview.cspSource} 'unsafe-inline'; img-src ${panel.webview.cspSource} data:; font-src ${panel.webview.cspSource} data:;">
<style>body{max-width:1100px;margin:24px auto;padding:0 24px;font:var(--vscode-font-size) var(--vscode-font-family);color:var(--vscode-editor-foreground);background:var(--vscode-editor-background)}a{color:var(--vscode-textLink-foreground)}table{border-collapse:collapse}td,th{padding:6px 12px;border:1px solid var(--vscode-panel-border)}pre{overflow:auto}details{margin:16px 0}.dever-flow{overflow:auto;margin:20px 0}.dever-flow svg{max-width:none!important}.dever-flow-error{color:var(--vscode-errorForeground)}</style>
</head><body data-anchor="${markdown.utils.escapeHtml(anchor)}">${markdown.render(text)}
<script nonce="${nonce}" src="${mermaid}"></script><script nonce="${nonce}" src="${script}"></script></body></html>`;
}
const messages = panel.webview.onDidReceiveMessage(async message => {
if (message.type !== 'open' || typeof message.href !== 'string') return;
try {
if (calls && message.href.startsWith('command:dever.showCalls?')) {
const [requestedRoot, name] = JSON.parse(decodeURIComponent(message.href.split('?')[1]));
if (requestedRoot !== root) throw new Error('链接超出当前项目。');
await render(name);
return;
}
if (calls && message.href.startsWith('dever-source:')) {
const target = vscode.Uri.parse(message.href);
if (JSON.parse(target.query).root !== root) throw new Error('链接超出当前项目。');
const document = await vscode.workspace.openTextDocument(target);
const location = /^#L(\d+)(?:,(\d+))?$/.exec(new URL(message.href).hash);
const line = location ? Number(location[1]) - 1 : 0;
const column = location?.[2] ? Number(location[2]) - 1 : 0;
await vscode.window.showTextDocument(document, { viewColumn: vscode.ViewColumn.One, selection: new vscode.Range(line, column, line, column) });
return;
}
const destination = new URL(message.href, pathToFileURL(calls ? root + path.sep : current));
if (destination.protocol !== 'file:') throw new Error('流程阅读页只打开项目源码和领域索引。');
const filename = fileURLToPath(destination);
const relative = path.relative(root, filename);
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new Error('链接超出当前项目。');
}
if (!calls && path.basename(filename) === 'link.md') {
await render(filename, decodeURIComponent(destination.hash.slice(1)));
} else {
const location = /^#L(\d+)(?:,(\d+))?$/.exec(destination.hash);
const line = location ? Number(location[1]) - 1 : 0;
const column = location?.[2] ? Number(location[2]) - 1 : 0;
const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filename));
await vscode.window.showTextDocument(document, { viewColumn: vscode.ViewColumn.One, selection: new vscode.Range(line, column, line, column) });
}
} catch (error) { vscode.window.showErrorMessage(error.message); }
});
panel.onDidDispose(() => messages.dispose());
if (calls) {
const changed = calls.changed.event(() => render(current).catch(error => {
panel.dispose();
vscode.window.showErrorMessage(error.message);
}));
panel.onDidDispose(() => changed.dispose());
}
context.subscriptions.push(panel);
try { await render(calls ? calls.name : current); } catch (error) { panel.dispose(); throw error; }
return panel;
}
module.exports = { openFlowPreview };
+28
View File
@@ -0,0 +1,28 @@
// 所有关系来自编译器;这里只按源码位置选择符号和分组显示。
function contains(range, position) {
const compare = (left, right) => left.line - right.line || left.character - right.character;
return compare(range.start, position) <= 0 && compare(position, range.end) < 0;
}
// 自动分派用于调用层级;不能抢走显式绑定或 Port 声明的“转到定义”。
const generated = new Set(['verify', 'rest-field', 'rest-model', 'adapter']);
function at(index, file, position) {
const calls = index.calls.filter(call => !generated.has(call.kind) && call.site.file === file && contains(call.site, position));
const targets = new Set(calls.map(call => call.callee));
if (targets.size) return index.symbols.filter(symbol => targets.has(symbol.id));
return index.symbols.filter(symbol => symbol.declarations.some(location =>
location.file === file && contains(location, position)));
}
function relations(index, id, incoming) {
const grouped = new Map();
for (const call of index.calls) {
if ((incoming ? call.callee : call.caller) !== id) continue;
const target = incoming ? call.caller : call.callee;
if (!grouped.has(target)) grouped.set(target, []);
grouped.get(target).push(call);
}
return [...grouped].map(([id, calls]) => ({ symbol: index.symbols.find(symbol => symbol.id === id), calls }));
}
module.exports = { at, relations, contains };
+60
View File
@@ -0,0 +1,60 @@
const vscode = require('vscode');
const { relations } = require('./graph.cjs');
const { uri } = require('./inspection.cjs');
function command(name, args) {
return `command:${name}?${encodeURIComponent(JSON.stringify(args))}`;
}
async function description(inspection, index, symbol, root) {
const value = index.documentation.find(item => item.symbol === symbol.name);
if (!value) return undefined;
const documentIndex = { files: [index.documentation_files[value.file]] };
await inspection.verify(documentIndex, 0, root);
return { ...value, uri: uri(documentIndex, 0, root) };
}
function registerHover(context, selector, inspection, lookup) {
context.subscriptions.push(vscode.languages.registerHoverProvider(selector, {
async provideHover(document, position) {
const state = await lookup(document, position);
if (!state?.symbols.length) return undefined;
const contents = [];
for (const symbol of state.symbols) {
const page = new vscode.MarkdownString();
page.isTrusted = { enabledCommands: ['dever.showCalls', 'dever.openDomainLinks', 'dever.previewDocumentation'] };
page.appendMarkdown(`**${symbol.name}**\n\n`);
if (state.index.documentation_errors.length) {
page.appendMarkdown('**说明校验未通过。** 失效或重复的标题详见“Dever”输出;签名和调用关系仍来自源码。\n\n');
}
for (const signature of symbol.signatures) page.appendCodeblock(signature, 'dever');
const detail = await description(inspection, state.index, symbol, state.root);
if (detail) {
page.baseUri = detail.uri;
page.appendMarkdown(`${detail.markdown}\n\n`);
} else {
page.appendMarkdown('尚未提供领域说明;签名和关系来自已保存源码。\n\n');
}
for (const incoming of [true, false]) {
const related = relations(state.index, symbol.id, incoming);
const label = incoming ? '调用方' : '调用目标';
const names = related.slice(0, 4).map(item => `\`${item.symbol.name}\``).join('、');
page.appendMarkdown(`${label}:${names || '当前静态索引中无记录'}${related.length > 4 ? `,共 ${related.length} 个` : ''}。\n\n`);
}
const links = [];
for (const site of symbol.declarations.slice(0, 1)) {
const target = uri(state.index, site.file, state.root).with({ fragment: `L${site.start.line + 1},${site.start.character + 1}` });
links.push(`[源码](${target.toString()})`);
}
if (detail) links.push(`[说明](${command('dever.previewDocumentation', [state.root, symbol.name])})`);
links.push(`[调用详情](${command('dever.showCalls', [state.root, symbol.name])})`);
links.push(`[领域流程](${command('dever.openDomainLinks', [state.root, symbol.name])})`);
page.appendMarkdown(links.join(' · '));
contents.push(page);
}
return new vscode.Hover(contents);
}
}));
}
module.exports = { registerHover, description, command };
+132
View File
@@ -0,0 +1,132 @@
const vscode = require('vscode');
const path = require('node:path');
const fs = require('node:fs/promises');
const { createHash } = require('node:crypto');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const execute = promisify(execFile);
function isSource(document) {
return document.languageId === 'dever';
}
async function projectRoot(document) {
if (document.uri.scheme === 'dever-source') return JSON.parse(document.uri.query).root;
const folder = vscode.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath;
for (let directory = path.dirname(document.uri.fsPath); ; directory = path.dirname(directory)) {
try {
await fs.stat(path.join(directory, 'config', 'setting.json'));
return directory;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (directory === folder || directory === path.dirname(directory)) break;
}
return folder || document.uri.fsPath;
}
function uri(index, file, root) {
const source = index.files[file];
return source.text === undefined ? vscode.Uri.file(source.path) : vscode.Uri.from({
scheme: 'dever-source', path: `/${source.path}`, query: JSON.stringify({ root, path: source.path })
});
}
function range(location) {
return new vscode.Range(location.start.line, location.start.character, location.end.line, location.end.character);
}
function createInspection(context) {
const cache = new Map();
const changed = new vscode.EventEmitter();
const output = vscode.window.createOutputChannel('Dever');
context.subscriptions.push(changed, output);
const invalidate = () => { cache.clear(); changed.fire(); };
const watcher = vscode.workspace.createFileSystemWatcher('**/{*.dever,README.md,doc/*.md,dever.lock,dever.api,setting.json}');
context.subscriptions.push(watcher, watcher.onDidChange(invalidate), watcher.onDidCreate(invalidate), watcher.onDidDelete(invalidate));
context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(document => {
if (isSource(document) || /[/\\]doc[/\\][^/\\]+\.md$/.test(document.uri.fsPath)) invalidate();
}));
context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('dever.command')) invalidate();
}));
async function load(root) {
if (!vscode.workspace.isTrusted) throw new Error('请信任工作区后再运行 Dever 源码检查。');
if (!cache.has(root)) {
const command = vscode.workspace.getConfiguration('dever', vscode.Uri.file(root)).get('command');
const promise = execute(command[0], [...command.slice(1), 'inspect', root, '--json'], {
maxBuffer: 32 * 1024 * 1024, windowsHide: true
}).then(({ stdout }) => {
const index = JSON.parse(stdout);
for (const error of index.documentation_errors) output.appendLine(error);
return index;
}).catch(error => {
if (cache.get(root) === promise) cache.delete(root);
const message = error.stderr?.trim() || error.message;
output.appendLine(message);
throw new Error(message);
});
cache.set(root, promise);
}
const pending = cache.get(root);
const index = await pending;
if (cache.get(root) !== pending) throw new Error('检查期间源码已变化,请重新查询调用关系。');
return index;
}
async function verify(index, file, root) {
const document = await vscode.workspace.openTextDocument(uri(index, file, root));
if (document.isDirty) throw new Error('调用关系基于已保存源码,请先保存文件。');
const digest = createHash('sha256').update(document.getText()).digest('hex');
if (digest !== index.files[file].sha256) {
invalidate();
throw new Error('源码已变化,请刷新调用关系。');
}
return document;
}
async function current(document) {
if (document.isDirty) return undefined;
const root = await projectRoot(document);
if (vscode.workspace.textDocuments.some(value => value.isDirty && isSource(value)
&& (value.uri.fsPath === root || value.uri.fsPath.startsWith(`${root}${path.sep}`)))) return undefined;
const index = await load(root);
const sourcePath = document.uri.scheme === 'dever-source' ? JSON.parse(document.uri.query).path : document.uri.fsPath;
const file = index.files.findIndex(source => source.path === sourcePath);
if (file < 0) return undefined;
await verify(index, file, root);
return { root, index, file };
}
async function location(index, site, root) {
await verify(index, site.file, root);
return new vscode.Location(uri(index, site.file, root), range(site));
}
async function checkLinks(root) {
if (!vscode.workspace.isTrusted) throw new Error('请信任工作区后再检查领域流程。');
if (vscode.workspace.textDocuments.some(document => document.isDirty && isSource(document)
&& document.uri.fsPath.startsWith(`${root}${path.sep}`))) throw new Error('请先保存源码,再查看领域流程。');
const command = vscode.workspace.getConfiguration('dever', vscode.Uri.file(root)).get('command');
try {
await execute(command[0], [...command.slice(1), 'inspect', root, '--links', '--check'], { windowsHide: true });
} catch (error) {
throw new Error(error.stderr?.trim() || error.stdout?.trim() || error.message);
}
}
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider('dever-source', {
async provideTextDocumentContent(uri) {
const { root, path } = JSON.parse(uri.query);
const index = await load(root);
const source = index.files.find(source => source.path === path)
|| index.documentation_files.find(description => description.path === path);
if (source?.text === undefined) throw new Error(`未找到内置源码:${path}`);
return source.text;
}
}));
return { load, current, verify, location, checkLinks, invalidate, changed };
}
module.exports = { createInspection, range, uri, isSource, projectRoot };
+18
View File
@@ -0,0 +1,18 @@
{
"comments": {
"lineComment": "#"
},
"brackets": [["{", "}"], ["[", "]"], ["(", ")"]],
"autoClosingPairs": [
{ "open": "{", "close": "}", "notIn": ["string", "comment"] },
{ "open": "[", "close": "]", "notIn": ["string", "comment"] },
{ "open": "(", "close": ")", "notIn": ["string", "comment"] },
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] }
],
"surroundingPairs": [["{", "}"], ["[", "]"], ["(", ")"], ["\"", "\""]],
"wordPattern": "[A-Za-z_][A-Za-z0-9_]*",
"indentationRules": {
"increaseIndentPattern": "^\\s*(?!#)(?:[^\"#]|\"(?:\\\\.|[^\"\\\\])*\")*[({\\[]\\s*(?:#.*)?$",
"decreaseIndentPattern": "^\\s*[})\\]]"
}
}
+103
View File
@@ -0,0 +1,103 @@
const vscode = require('vscode');
const { createInspection, range, uri } = require('./inspection.cjs');
const { at, relations } = require('./graph.cjs');
const { registerReading } = require('./reading.cjs');
const { registerHover } = require('./hover.cjs');
function registerNavigation(context) {
const inspection = createInspection(context);
const selector = [
{ language: 'dever', scheme: 'file' },
{ language: 'dever', scheme: 'dever-source' }
];
async function item(index, symbol, root) {
const first = symbol.declarations[0];
if (!first) return undefined;
await inspection.verify(index, first.file, root);
const end = symbol.declarations.at(-1).end;
const span = range({ start: first.start, end });
const selected = new vscode.Range(span.start, span.start);
const value = new vscode.CallHierarchyItem(vscode.SymbolKind.Function, symbol.name, symbol.kind,
uri(index, first.file, root), span, selected);
return value;
}
async function lookup(document, position) {
const state = await inspection.current(document);
return state ? { ...state, symbols: at(state.index, state.file, position) } : undefined;
}
context.subscriptions.push(vscode.languages.registerDefinitionProvider(selector, {
async provideDefinition(document, position) {
const state = await lookup(document, position);
if (!state) return [];
return Promise.all(state.symbols.flatMap(symbol => symbol.declarations)
.map(site => inspection.location(state.index, site, state.root)));
}
}));
context.subscriptions.push(vscode.languages.registerReferenceProvider(selector, {
async provideReferences(document, position, options) {
const state = await lookup(document, position);
if (!state) return [];
const ids = new Set(state.symbols.map(symbol => symbol.id));
const sites = state.index.calls.filter(call => ids.has(call.callee)).map(call => call.site);
if (options.includeDeclaration) sites.push(...state.symbols.flatMap(symbol => symbol.declarations));
const unique = [...new Map(sites.map(site => [JSON.stringify(site), site])).values()];
return Promise.all(unique.map(site => inspection.location(state.index, site, state.root)));
}
}));
async function hierarchy(value, incoming) {
const document = await vscode.workspace.openTextDocument(value.uri);
const state = await inspection.current(document);
if (!state) throw new Error('请保存源码后重新打开调用层级。');
const { root, index, file } = state;
const start = value.selectionRange.start;
const symbol = index.symbols.find(symbol => symbol.name === value.name && symbol.declarations.some(site =>
site.file === file && site.start.line === start.line && site.start.character === start.character));
if (!symbol) throw new Error('函数位置已变化,请重新打开调用层级。');
await inspection.verify(index, symbol.declarations[0].file, root);
const result = [];
for (const relation of relations(index, symbol.id, incoming)) {
const target = await item(index, relation.symbol, root);
if (!target) continue;
for (const call of relation.calls) await inspection.verify(index, call.site.file, root);
const ranges = relation.calls.map(call => range(call.site));
result.push(incoming ? new vscode.CallHierarchyIncomingCall(target, ranges)
: new vscode.CallHierarchyOutgoingCall(target, ranges));
}
return result;
}
context.subscriptions.push(vscode.languages.registerCallHierarchyProvider(selector, {
async prepareCallHierarchy(document, position) {
const state = await lookup(document, position);
if (!state) return [];
return (await Promise.all(state.symbols.map(symbol => item(state.index, symbol, state.root)))).filter(Boolean);
},
provideCallHierarchyIncomingCalls(value) { return hierarchy(value, true); },
provideCallHierarchyOutgoingCalls(value) { return hierarchy(value, false); }
}));
context.subscriptions.push(vscode.languages.registerCodeLensProvider(selector, {
onDidChangeCodeLenses: inspection.changed.event,
async provideCodeLenses(document) {
const state = await inspection.current(document);
if (!state) return [];
return state.index.symbols.filter(symbol => symbol.kind !== 'generated'
&& symbol.declarations[0]?.file === state.file).map(symbol => {
const incoming = relations(state.index, symbol.id, true);
const count = incoming.reduce((total, relation) => total + relation.calls.length, 0);
const start = range(symbol.declarations[0]).start;
return new vscode.CodeLens(new vscode.Range(start, start), {
title: `${incoming.length} 个调用方 · ${count} 处调用 · 查看调用关系`,
command: 'dever.showCalls', arguments: [state.root, symbol.name]
});
});
}
}));
registerReading(context, inspection, lookup);
registerHover(context, selector, inspection, lookup);
}
module.exports = { registerNavigation };
+2458
View File
File diff suppressed because it is too large. Load diff
+110
View File
@@ -0,0 +1,110 @@
{
"name": "dever-language",
"displayName": "Dever Language",
"description": "Dever source navigation, domain documentation, hover and call flows.",
"version": "0.0.1",
"main": "./extension.cjs",
"activationEvents": [
"onLanguage:dever"
],
"publisher": "shemic",
"private": true,
"license": "UNLICENSED",
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Programming Languages"
],
"keywords": [
"dever",
"markdown",
"syntax highlighting"
],
"repository": {
"type": "git",
"url": "ssh://git@git.dever.cc:2222/dever/core.git",
"directory": "editor/vscode"
},
"contributes": {
"commands": [
{
"command": "dever.previewDocumentation",
"title": "Dever: Preview Documentation"
},
{
"command": "dever.showCalls",
"title": "Dever: Show Calls"
},
{
"command": "dever.openDomainLinks",
"title": "Dever: Open Domain Links"
}
],
"configuration": {
"title": "Dever",
"properties": {
"dever.command": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"default": [
"dever"
],
"description": "Compiler executable followed by fixed arguments; inspect is appended without a shell."
}
}
},
"languages": [
{
"id": "dever",
"aliases": [
"Dever",
"dever"
],
"extensions": [
".dever"
],
"configuration": "./language-configuration.json"
}
],
"grammars": [
{
"language": "dever",
"scopeName": "source.dever",
"path": "./syntaxes/dever.tmLanguage.json"
},
{
"scopeName": "markdown.dever.codeblock",
"path": "./syntaxes/dever-markdown.tmLanguage.json",
"injectTo": [
"text.html.markdown"
],
"embeddedLanguages": {
"meta.embedded.block.dever": "dever"
},
"tokenTypes": {
"meta.embedded.block.dever": "other"
}
}
]
},
"scripts": {
"build": "node build.cjs",
"vscode:prepublish": "npm run build",
"test": "node ../../test/editor-vscode/highlight.test.cjs",
"test:navigation": "node --test ../../test/editor-vscode/navigation.test.cjs",
"test:flows": "node --test ../../test/editor-vscode/flows.test.cjs",
"package": "vsce package --no-dependencies --skip-license"
},
"devDependencies": {
"@mermaid-js/tiny": "12.2.0",
"@vscode/vsce": "4.0.0",
"esbuild": "0.28.2",
"markdown-it": "15.0.3",
"vscode-oniguruma": "2.0.1",
"vscode-textmate": "9.2.0"
}
}
+119
View File
@@ -0,0 +1,119 @@
const vscode = require('vscode');
const path = require('node:path');
const { relations } = require('./graph.cjs');
const { uri, projectRoot } = require('./inspection.cjs');
const { openFlowPreview } = require('./dist/flow-preview.cjs');
const { description, command } = require('./hover.cjs');
function registerReading(context, inspection, lookup) {
const changed = new vscode.EventEmitter();
const previews = new Map();
context.subscriptions.push(changed, inspection.changed.event(() => {
for (const uri of previews.values()) changed.fire(uri);
}));
context.subscriptions.push(vscode.workspace.onDidCloseTextDocument(document => {
previews.delete(document.uri.toString());
}));
const provider = {
onDidChange: changed.event,
async provideTextDocumentContent(documentUri) {
previews.set(documentUri.toString(), documentUri);
const { root, name } = JSON.parse(documentUri.query);
const index = await inspection.load(root);
const symbols = index.symbols.filter(symbol => symbol.name === name);
if (!symbols.length) throw new Error(`函数已不存在:${name}`);
const lines = [`# ${name}`, '', '静态源码关系;分支可能只执行一部分,任务派发不表示同步执行。', '', index.coverage, ''];
if (index.documentation_errors.length) lines.push('**说明校验未通过。** 详见“Dever”输出。', '');
function link(site) {
const file = index.files[site.file];
const target = uri(index, site.file, root).with({ fragment: `L${site.start.line + 1},${site.start.character + 1}` });
return `[${file.path}:${site.start.line + 1}](${target.toString()})`;
}
for (const symbol of symbols) {
for (const signature of symbol.signatures) lines.push('```dever', signature, '```', '');
const detail = await description(inspection, index, symbol, root);
if (detail) lines.push(detail.markdown, '', `[主题说明](${detail.uri.toString()})`, '');
for (const site of symbol.declarations) {
await inspection.verify(index, site.file, root);
lines.push('## 定义', '', link(site), '');
}
for (const incoming of [true, false]) {
lines.push(incoming ? '## 谁调用它' : '## 它调用谁', '');
const related = relations(index, symbol.id, incoming);
if (!related.length) lines.push('当前已检查源码中没有此类关系。', '');
else lines.push('| 函数 | 领域 | 关系 | 调用位置 | 定义 / 上下游 |', '| --- | --- | --- | --- | --- |');
for (const relation of related) {
for (const call of relation.calls) {
await inspection.verify(index, call.site.file, root);
const definitions = relation.symbol.declarations.map(link).join('、') || '内建操作';
const chain = command('dever.showCalls', [root, relation.symbol.name]);
lines.push(`| ${relation.symbol.name} | ${relation.symbol.name.split('.').slice(0, 2).join('.')} | ${call.kind} | ${link(call.site)} | ${definitions} · [上下游](${chain}) |`);
}
}
lines.push('');
}
}
return lines.join('\n');
}
};
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider('dever-calls', provider));
context.subscriptions.push(vscode.commands.registerCommand('dever.showCalls', async (root, name) => {
try {
if (!root || !name) {
const editor = vscode.window.activeTextEditor;
if (!editor) return;
const state = await lookup(editor.document, editor.selection.active);
if (!state) throw new Error('请先保存源码,再查看调用关系。');
if (!state.symbols.length) throw new Error('请把光标放在函数或调用位置。');
root = state.root;
name = state.symbols[0].name;
}
const target = vscode.Uri.from({ scheme: 'dever-calls', path: '/calls.md', query: JSON.stringify({ root, name }) });
await openFlowPreview(context, target, root, {
name,
changed: inspection.changed,
read: name => provider.provideTextDocumentContent(target.with({ query: JSON.stringify({ root, name }) }))
});
} catch (error) { vscode.window.showErrorMessage(error.message); }
}));
context.subscriptions.push(vscode.commands.registerCommand('dever.previewDocumentation', async (root, name) => {
try {
const document = vscode.window.activeTextEditor?.document;
if (!root || !name) {
if (!document) return;
const state = await lookup(document, vscode.window.activeTextEditor.selection.active);
root = state?.root || await projectRoot(document);
name = state?.symbols[0]?.name;
}
const index = await inspection.load(root);
const symbol = index.symbols.find(value => value.name === name);
const detail = symbol && await description(inspection, index, symbol, root);
if (!detail) throw new Error('该符号尚未提供 doc/ 说明。');
await vscode.commands.executeCommand('markdown.showPreviewToSide', detail.uri.with({ fragment: `L${detail.line + 1}` }));
} catch (error) { vscode.window.showErrorMessage(error.message); }
}));
context.subscriptions.push(vscode.commands.registerCommand('dever.openDomainLinks', async (root, name) => {
try {
const document = vscode.window.activeTextEditor?.document;
if (!root && !document) return;
root ||= await projectRoot(document);
const index = await inspection.load(root);
const symbol = index.symbols.find(value => value.name === name);
const source = symbol?.declarations[0];
const filename = source ? index.files[source.file].path : document.uri.fsPath;
const relative = path.relative(path.join(root, 'module'), filename).split(path.sep);
if (relative.length < 3 || relative[0] === '..') {
throw new Error('请在 module/<组件>/<领域>/ 内的源码中执行此命令。');
}
const target = vscode.Uri.file(path.join(root, 'module', ...relative.slice(0, 2), 'link.md'));
await inspection.checkLinks(root);
const panel = await openFlowPreview(context, target, root);
const changed = inspection.changed.event(() => panel.dispose());
panel.onDidDispose(() => changed.dispose());
} catch (error) {
vscode.window.showErrorMessage(`${error.message} 可在项目根运行 dever inspect . --links 生成索引。`);
}
}));
}
module.exports = { registerReading };
@@ -0,0 +1,31 @@
{
"name": "Dever Markdown blocks",
"scopeName": "markdown.dever.codeblock",
"injectionSelector": "L:text.html.markdown -markup.fenced_code.block -markup.raw.block -markup.quote -markup.list -meta.embedded.block -comment",
"patterns": [
{
"name": "markup.fenced_code.block.markdown",
"contentName": "meta.embedded.block.dever source.dever",
"begin": "^ {0,3}(`{3,})[ \\t]*(dever|typescript dever)[ \\t]*$",
"end": "^ {0,3}\\1`* *$",
"beginCaptures": {
"1": { "name": "punctuation.definition.markdown.dever" },
"2": { "name": "fenced_code.block.language.markdown" }
},
"endCaptures": { "0": { "name": "punctuation.definition.markdown.dever" } },
"patterns": [{ "include": "source.dever" }]
},
{
"name": "markup.fenced_code.block.markdown",
"contentName": "meta.embedded.block.dever source.dever",
"begin": "^ {0,3}(~{3,})[ \\t]*(dever|typescript dever)[ \\t]*$",
"end": "^ {0,3}\\1~* *$",
"beginCaptures": {
"1": { "name": "punctuation.definition.markdown.dever" },
"2": { "name": "fenced_code.block.language.markdown" }
},
"endCaptures": { "0": { "name": "punctuation.definition.markdown.dever" } },
"patterns": [{ "include": "source.dever" }]
}
]
}
@@ -0,0 +1,95 @@
{
"name": "Dever",
"scopeName": "source.dever",
"patterns": [
{ "include": "#comments" },
{ "include": "#strings" },
{ "include": "#unsupported" },
{ "include": "#declarations" },
{ "include": "#constants" },
{ "include": "#numbers" },
{ "include": "#names" },
{ "include": "#keywords" },
{ "include": "#operators" }
],
"repository": {
"comments": {
"name": "comment.line.number-sign.dever",
"begin": "#",
"end": "$"
},
"strings": {
"name": "string.quoted.double.dever",
"begin": "\"",
"end": "\"|$",
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.dever" } },
"endCaptures": { "0": { "name": "punctuation.definition.string.end.dever" } },
"patterns": [
{ "name": "constant.character.escape.dever", "match": "\\\\(?:[\"\\\\nrt]|u\\{[0-9A-Fa-f]{1,6}\\})" },
{ "name": "invalid.illegal.escape.dever", "match": "\\\\." }
]
},
"declarations": {
"patterns": [
{
"match": "\\b(type)\\s+([A-Z][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.dever" },
"2": { "name": "entity.name.type.dever" }
}
},
{
"match": "^\\s*(?:(public)\\s+)?(get|post|put|delete|cmd|rest)\\b",
"captures": {
"1": { "name": "storage.modifier.dever" },
"2": { "name": "keyword.control.api.dever" }
}
},
{
"match": "^\\s*(external)\\s+(command|exec|pip|npm|go)\\b",
"captures": {
"1": { "name": "storage.modifier.external.dever" },
"2": { "name": "keyword.other.external.dever" }
}
},
{ "name": "keyword.other.declaration.dever", "match": "^\\s*(database|relation|seed|migrate|sql|index|unique|schedule)\\b" }
]
},
"constants": {
"name": "constant.language.dever",
"match": "\\b(true|false|null|other)\\b"
},
"unsupported": {
"name": "invalid.illegal.keyword.dever",
"match": "\\b(package|exposes|if|else|switch|match|when|by|for|while|break|continue|iterate|recur|repeat|return|function|throw|catch|try|import|use|class|interface|unsafe|macro)\\b"
},
"numbers": {
"name": "constant.numeric.dever",
"match": "(?<![A-Za-z0-9_])[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?(?![A-Za-z0-9_])"
},
"names": {
"patterns": [
{ "name": "variable.other.member.dever", "match": "\\b[a-z_][A-Za-z0-9_]*(?=\\s*:)" },
{ "name": "entity.name.function.dever", "match": "\\b[a-z_][A-Za-z0-9_]*(?=\\s*\\()" },
{ "name": "support.type.dever", "match": "\\b(Int|Float|Decimal|Bool|Text|Bytes|Secret|List|Map|Stream|AsyncStream|Channel|Task|Group|Json|Date|Time|DateTime|Duration|Id|Uuid)\\b" },
{ "name": "entity.name.type.dever", "match": "\\b[A-Z][A-Za-z0-9_]*\\b" },
{ "name": "entity.name.namespace.dever", "match": "\\b(dever|app|domain|model|port|adapter)(?=\\.)" }
]
},
"keywords": {
"patterns": [
{ "name": "keyword.operator.logical.dever", "match": "\\b(and|or|not)\\b" },
{ "name": "storage.modifier.dever", "match": "\\b(public|global|pure|transaction|job|recover|fails|private|owner)\\b" },
{ "name": "keyword.other.model.dever", "match": "\\b(default|unique|index|raw|lower|upper|before|after|drop)\\b" },
{ "name": "keyword.other.adapter.dever", "match": "\\b(setting|lib|allow)\\b" }
]
},
"operators": {
"patterns": [
{ "name": "keyword.operator.dever", "match": "//|==|!=|<=|>=|[=+*/%<>?-]" },
{ "name": "punctuation.section.dever", "match": "[{}()\\[\\]]" },
{ "name": "punctuation.separator.dever", "match": "[,:.]" }
]
}
}
}
+80
View File
@@ -0,0 +1,80 @@
# Dever 示例
入门项目见 [随包示例](../skills/references/examples.md)。CMS 演示多租户内容管理,包括登录、权限、文章修订、上传和定时发布。
CMS 只维护一份 [Dever 源码](cms/dever/module/news/article/api/admin/manage.dever),领域说明存放在 README 和 doc 中:
```text
module/
platform/tenant/ 平台租户
user/account/ 凭据、成员、会话、bootstrap CMD
user/authorization/ 核心角色授权的业务入口
content/category/ 分类
content/revision/ 文章编辑与修订快照
media/asset/ 上传、Storage Port/Adapter
publishing/schedule/ 定时发布与 Job
audit/operation/ 操作审计
system/health/ 健康 CMD
news/article/
app/editing.dever
app/publication.dever
api/admin/manage.dever
api/front/browse.dever
domain.dever
model.dever
model/publication.dever
test/
content/category/lifecycle.dever
news/article/publish.dever
user/account/bootstrap.dever
user/account/validation.dever
user/account/lifecycle.dever
```
## 数据和权限
平台租户、账号、成员和会话使用 `global type`,保存在平台库。文章、分类、修订、媒体、排期和审计放在各租户自己的数据库。
账户、成员和会话同属账户领域,避免登录调用与 Model ID 引用形成循环依赖。租户文章的作者编号来自已验证身份,使用 Int 存储;物理外键只连接同一个数据库中的 Model,不能从租户库指向平台账户库。
verify 先核实身份数据,核心再按站点和角色检查接口权限,通过后才访问租户数据。
文章接口位于 `/news/article/admin/manage/`:create 创建,list/detail 查询,remove 删除草稿,publish 发布。
编辑使用 `/content/revision/admin/manage/create`,传入文章 ID、expected_version、标题正文和可空分类/媒体 ID。版本冲突返回 409,文章更新与修订快照一起提交。文章不用自动 REST 写接口,以免跳过这些规则;列表返回 `app.ArticleView`,不泄漏私有 Model。
定时发布使用 `/publishing/schedule/admin/manage/schedule`,传入文章 ID 和 publish_at。创建时检查作者和草稿状态;每个排期有独立任务,不会合并同时发布的文章。
执行任务前,核心再次核验身份和权限。发布、审计和排期状态一起提交或回滚,完成任务重复执行不重复写入。手动重复发布返回 409。
## 先检查,再运行
在仓库根目录执行:
```bash
dever check examples/cms/dever
dever test examples/cms/dever --filter news.article.publish
dever fmt examples/cms/dever --check
```
测试使用临时 SQLite,不连接部署数据库。去掉 `--filter` 可执行全部应用测试。
实际部署前先替换 JWT secret,并把两个站点的 origin 和 Host 改成真实 HTTPS 入口;origin 是用户访问的外部地址,不是内部监听地址。
用 bootstrap CMD 建立平台租户、Owner 成员和密码:
```sh
dever run examples/cms/dever -- user.account.bootstrap '{"tenant_key":"tenant-one","tenant_name":"Tenant One","email":"owner@example.com","display_name":"Owner","password":"change-me-now"}'
```
替换示例账号与密码,再用返回的真实 tenant_id 和 user_id 执行以下命令,不要猜测 ID 是 1:
```text
dever tenant migrate examples/cms/dever <tenant_id>
dever tenant owner examples/cms/dever <tenant_id> admin <user_id>
dever tenant owner examples/cms/dever <tenant_id> front <user_id>
```
这会迁移租户数据库,并建立后台、前台的核心 Owner。之后各站点通过 `user/authorization/<site>/manage` 管理普通角色。运行配置在项目的 `config/setting.json`,数据在 `data/`;`dever run examples/cms/dever` 不带 CMD 参数时启动 HTTP 服务。
应用测试覆盖分类、发布规则、账号和会话。真实登录、HTTP、租户隔离及发布流程使用 [CMS 验收入口](../test/performance/cms.py)。
+58
View File
@@ -0,0 +1,58 @@
{
"runtime": {
"mode": "all",
"shutdown_ms": 30000
},
"job": {
"workers": 1,
"poll_ms": 1000,
"lease_ms": 60000,
"retry_base_ms": 1000,
"retry_max_ms": 300000
},
"http": {
"host": "127.0.0.1",
"port": 8080
},
"database": {
"default": {
"type": "sqlite",
"path": "data/db/platform.db",
"tenant_directory": "data/tenants",
"max_connections": 4,
"max_page_size": 100
}
},
"tenant": {
"database": "default",
"max_pools": 32,
"idle_timeout_ms": 300000
},
"auth": {
"providers": {
"session": {
"verify": "user.account.verify",
"jwtSecret": "replace-this-example-secret-before-deployment-2026",
"ttlSeconds": 3600,
"cookie": "cms_session"
}
}
},
"sites": {
"admin": {
"path": "admin",
"hosts": ["cms.example.com"],
"auth": "session",
"origin": "https://cms.example.com"
},
"front": {
"path": "front",
"hosts": ["cms.example.com"],
"auth": "session",
"origin": "https://cms.example.com"
}
},
"log": {
"level": "info"
}
}
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,7 @@
# 操作审计
保存登录、退出、发布、上传和授权等操作记录。
- [操作审计应用](doc/app.md)
- [操作审计模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1,10 @@
record(kind: model.OperationKind, resource: Text, resource_id: Text) () {
stored = model.create(
{
kind = kind
resource = resource
resource_id = resource_id
}
)
discarded = stored
}
@@ -0,0 +1,5 @@
# 操作审计应用
## audit.operation.record
保存一次操作审计,记录操作类型、资源名称和资源标识。
@@ -0,0 +1,9 @@
# 操作审计模型
## audit.operation.model.OperationKind
审计操作类型:登录、退出、发布、上传和授权。
## audit.operation.model.Operation
租户操作审计记录,包含操作者、操作类型和目标资源。
@@ -0,0 +1,60 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 5cc5a514d99bdf266632507f65376a89a8ba16e0a87baa6f0f2be4435fa35943 -->
# audit.operation
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
没有已收录的入口。
## 外部调用方
| 能力 | 调用方 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [audit.operation.record](app.dever#L1) | [media.asset.record](../../media/asset/app.dever#L17) | 调用 | [../../media/asset/app.dever:26:3](../../media/asset/app.dever#L26,3) |
| [audit.operation.record](app.dever#L1) | [news.article.publishDraft](../../news/article/app/publication.dever#L48) | 调用 | [../../news/article/app/publication.dever:65:3](../../news/article/app/publication.dever#L65,3) |
## 能力与内部调用
### audit.operation.model.Operation
定义:[model.dever:9:1](model.dever#L9,1)
没有已收录的下游调用。
### audit.operation.model.OperationKind
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
### audit.operation.record
定义:[app.dever:1:1](app.dever#L1,1)
<a id="flow-c1586d946bef8bfb-1"></a>
```mermaid
flowchart TD
n0(["record"])
click n0 href "app.dever#L1,1" "打开源码"
n1(["返回"])
n3["audit.operation.model.create"]
click n3 href "app.dever#L2,12" "打开源码"
n4["赋值 stored"]
click n4 href "app.dever#L2,12" "打开源码"
n5["赋值 discarded"]
click n5 href "app.dever#L9,15" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n1
```
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [audit.operation.model.create](model.dever#L9) | 领域内 | 内建操作 | [app.dever:2:12](app.dever#L2,12) |
@@ -0,0 +1,14 @@
type OperationKind {
Login = "登录"
Logout = "登出"
Publish = "发布"
Upload = "上传"
Access = "授权"
}
type Operation {
actor_id: Int?
kind: OperationKind index
resource: Text(1, 120)
resource_id: Text(1, 120)
}
@@ -0,0 +1,8 @@
# 内容分类
创建分类并管理启用状态,供文章校验所选分类。
- [分类接口](doc/api-admin-manage.md)
- [内容分类应用](doc/app.md)
- [内容分类模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1,3 @@
post create = app.create
delete disable = app.disable
@@ -0,0 +1,39 @@
type CategoryView {
id: model.id
name: Text
slug: Text
status: Text
}
create(name: Text) (category: CategoryView) {
stored = model.create(
{
name = name
slug = name
}
)
category = CategoryView {
id = stored.id
name = stored.name
slug = stored.slug
status = "active"
}
}
disable(id: model.id) (ok: Bool) {
changed = model.update(
id,
{
status = model.CategoryStatus.Disabled
}
)
discarded = changed
ok = true
}
requireActive(id: model.id) () {
stored = model.get(id)
dever.api.require(stored.status == model.CategoryStatus.Active, dever.api.Error.Conflict)
}
requireActive(id: null) () {}
@@ -0,0 +1,9 @@
# 分类接口
## content.category.api.admin.manage.create
创建分类。
## content.category.api.admin.manage.disable
禁用分类。
@@ -0,0 +1,17 @@
# 内容分类应用
## content.category.CategoryView
分类接口返回的编号、名称、slug 和状态。
## content.category.create
创建分类。名称去除首尾空白,slug 同时转为小写并保持唯一。
## content.category.disable
把已有分类标为禁用,记录仍保留。
## content.category.requireActive
分类编号为空时跳过;否则要求分类已启用,禁用时返回冲突。
@@ -0,0 +1,9 @@
# 内容分类模型
## content.category.model.CategoryStatus
分类的启用或禁用状态。
## content.category.model.Category
分类名称、唯一 slug 和状态。
@@ -0,0 +1,402 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 22ff06f2e26c68d4bf521c52d2ecba992a5af0f615c4f380876f2fe34a8b4aa0 -->
# content.category
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
- API [content.category.api.admin.manage.create](api/admin/manage.dever#L1) → [content.category.create](app.dever#L8)
- API [content.category.api.admin.manage.disable](api/admin/manage.dever#L3) → [content.category.disable](app.dever#L23)
## 外部调用方
| 能力 | 调用方 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.requireActive](app.dever#L34) | [news.article.create](../../news/article/app/editing.dever#L16) | 调用 | [../../news/article/app/editing.dever:24:3](../../news/article/app/editing.dever#L24,3) |
| [content.category.requireActive](app.dever#L34) | [news.article.edit](../../news/article/app/editing.dever#L77) | 调用 | [../../news/article/app/editing.dever:89:3](../../news/article/app/editing.dever#L89,3) |
| [content.category.requireActive](app.dever#L34) | [news.article.publishDraft](../../news/article/app/publication.dever#L48) | 调用 | [../../news/article/app/publication.dever:51:3](../../news/article/app/publication.dever#L51,3) |
## 能力与内部调用
### content.category.CategoryView
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### content.category.api.admin.manage.create
定义:[api/admin/manage.dever:1:1](api/admin/manage.dever#L1,1)
<a id="flow-d5f276e1d0182184-1"></a>
```mermaid
flowchart TD
n0(["content.category.api.admin.manage.create"])
click n0 href "api/admin/manage.dever#L1,15" "打开源码"
n1{"路由 POST /content/category/admin/manage/create,topic #34;admin/manage#34; 接受请求?"}
n2(["返回 HTTP 404"])
n3["进入 API"]
n4["取得认证时间"]
n5(["返回 HTTP 500"])
n6["按 topic #34;admin/manage#34; 选择站点与 Provider"]
n7(["返回 HTTP 403"])
n8{"读取 Bearer 或站点 Cookie;两者互斥"}
n9(["返回 HTTP 401"])
n10["认证上下文就绪"]
n11(["返回 HTTP 401"])
n12["校验 Token 的签名、有效期及站点"]
n13{"Cookie 且非 GET / HEAD / OPTIONS?"}
n14["检查站点 Origin"]
n15(["返回 HTTP 403"])
n16(["返回 HTTP 401"])
n17{"按 Provider.verify 选择验证函数"}
n18[["user.account.verify"]]
click n18 href "api/admin/manage.dever#L1,15" "打开源码"
n19(["返回 HTTP 业务错误映射状态;其他故障 500"])
n20["用验证结果建立身份"]
n21(["返回 HTTP 认证错误状态"])
n22(["返回 HTTP 500"])
n23["建立请求数据库上下文"]
n24{"有已验证租户?"}
n25["数据库上下文就绪"]
n26{"部署启用了租户存储?"}
n27(["返回 HTTP 403"])
n28["按已验证租户选择数据库"]
n29(["返回 HTTP 503"])
n30{"检查租户组件启用状态:content"}
n31(["返回 HTTP 403"])
n32(["返回 HTTP 503"])
n33["进入写入口事务"]
n34["失败:保留 HTTP 错误状态"]
n35["回滚入口拥有的事务"]
n36(["返回 HTTP 500"])
n37(["返回错误响应"])
n38{"权限检查:content.category.{site}.create"}
n39["权限通过"]
n40["读取 API 输入"]
n41["解码具体参数并检查字段合同"]
n42[["content.category.create"]]
click n42 href "api/admin/manage.dever#L1,15" "打开源码"
n43["按输出类型检查可序列化值并编码响应"]
n44["提交入口拥有的事务"]
n45(["返回 HTTP 500"])
n46["发布暂存的认证 Header / Cookie"]
n47(["返回成功响应"])
n0 --> n1
n1 -->|"未匹配此入口:继续路由;均未匹配"| n2
n1 --> n3
n3 --> n4
n4 -->|"失败"| n5
n4 --> n6
n6 -->|"无站点绑定"| n7
n6 --> n8
n8 -->|"凭据格式错误或冲突"| n9
n8 -->|"无凭据"| n11
n8 -->|"有凭据"| n13
n13 -->|"否"| n12
n13 -->|"是"| n14
n14 -->|"不符合"| n15
n14 -->|"通过"| n12
n12 -->|"无 Provider 或 Token 无效"| n16
n12 --> n17
n17 -->|"user.account.verify"| n18
n18 -->|"验证函数失败"| n19
n18 --> n20
n20 -->|"身份不合法"| n21
n20 -->|"通过"| n10
n17 -->|"无匹配验证函数"| n22
n10 --> n23
n23 --> n24
n24 -->|"否"| n26
n26 -->|"是:缺少租户身份"| n27
n26 -->|"否"| n25
n24 -->|"是"| n28
n28 -->|"选择失败"| n29
n28 --> n30
n30 -->|"未启用"| n31
n30 -->|"查询失败"| n32
n30 -->|"允许"| n25
n25 --> n33
n34 --> n35
n35 -->|"事务结束失败"| n36
n35 --> n37
n33 --> n38
n38 -->|"拒绝:Access 状态;查询故障:500"| n34
n38 -->|"允许"| n39
n39 --> n40
n40 -->|"失败:输入错误状态"| n34
n40 --> n41
n41 -->|"失败:400"| n34
n41 --> n42
n42 -->|"业务错误映射状态;其他故障:500"| n34
n42 --> n43
n43 -->|"编码失败:500"| n34
n43 --> n44
n44 -->|"提交失败"| n45
n44 --> n46
n46 --> n47
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| user.account.verify | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) | [user.account.verify](../../user/account/link.md#flow-aaaacb87d61f819c-1) |
| content.category.create | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) | [content.category.create](link.md#flow-dffb2069d02e287c-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.create](app.dever#L8) | 领域内 | API 绑定 | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) |
| [user.account.verify](../../user/account/app/account.dever#L133) | 跨领域 [user.account](../../user/account/link.md) | 身份验证 | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) |
### content.category.api.admin.manage.disable
定义:[api/admin/manage.dever:3:1](api/admin/manage.dever#L3,1)
<a id="flow-5e559918fcf97f3a-1"></a>
```mermaid
flowchart TD
n0(["content.category.api.admin.manage.disable"])
click n0 href "api/admin/manage.dever#L3,18" "打开源码"
n1{"路由 DELETE /content/category/admin/manage/disable,topic #34;admin/manage#34; 接受请求?"}
n2(["返回 HTTP 404"])
n3["进入 API"]
n4["取得认证时间"]
n5(["返回 HTTP 500"])
n6["按 topic #34;admin/manage#34; 选择站点与 Provider"]
n7(["返回 HTTP 403"])
n8{"读取 Bearer 或站点 Cookie;两者互斥"}
n9(["返回 HTTP 401"])
n10["认证上下文就绪"]
n11(["返回 HTTP 401"])
n12["校验 Token 的签名、有效期及站点"]
n13{"Cookie 且非 GET / HEAD / OPTIONS?"}
n14["检查站点 Origin"]
n15(["返回 HTTP 403"])
n16(["返回 HTTP 401"])
n17{"按 Provider.verify 选择验证函数"}
n18[["user.account.verify"]]
click n18 href "api/admin/manage.dever#L3,18" "打开源码"
n19(["返回 HTTP 业务错误映射状态;其他故障 500"])
n20["用验证结果建立身份"]
n21(["返回 HTTP 认证错误状态"])
n22(["返回 HTTP 500"])
n23["建立请求数据库上下文"]
n24{"有已验证租户?"}
n25["数据库上下文就绪"]
n26{"部署启用了租户存储?"}
n27(["返回 HTTP 403"])
n28["按已验证租户选择数据库"]
n29(["返回 HTTP 503"])
n30{"检查租户组件启用状态:content"}
n31(["返回 HTTP 403"])
n32(["返回 HTTP 503"])
n33["进入写入口事务"]
n34["失败:保留 HTTP 错误状态"]
n35["回滚入口拥有的事务"]
n36(["返回 HTTP 500"])
n37(["返回错误响应"])
n38{"权限检查:content.category.{site}.disable"}
n39["权限通过"]
n40["读取 API 输入"]
n41["解码具体参数并检查字段合同"]
n42[["content.category.disable"]]
click n42 href "api/admin/manage.dever#L3,18" "打开源码"
n43["按输出类型检查可序列化值并编码响应"]
n44["提交入口拥有的事务"]
n45(["返回 HTTP 500"])
n46["发布暂存的认证 Header / Cookie"]
n47(["返回成功响应"])
n0 --> n1
n1 -->|"未匹配此入口:继续路由;均未匹配"| n2
n1 --> n3
n3 --> n4
n4 -->|"失败"| n5
n4 --> n6
n6 -->|"无站点绑定"| n7
n6 --> n8
n8 -->|"凭据格式错误或冲突"| n9
n8 -->|"无凭据"| n11
n8 -->|"有凭据"| n13
n13 -->|"否"| n12
n13 -->|"是"| n14
n14 -->|"不符合"| n15
n14 -->|"通过"| n12
n12 -->|"无 Provider 或 Token 无效"| n16
n12 --> n17
n17 -->|"user.account.verify"| n18
n18 -->|"验证函数失败"| n19
n18 --> n20
n20 -->|"身份不合法"| n21
n20 -->|"通过"| n10
n17 -->|"无匹配验证函数"| n22
n10 --> n23
n23 --> n24
n24 -->|"否"| n26
n26 -->|"是:缺少租户身份"| n27
n26 -->|"否"| n25
n24 -->|"是"| n28
n28 -->|"选择失败"| n29
n28 --> n30
n30 -->|"未启用"| n31
n30 -->|"查询失败"| n32
n30 -->|"允许"| n25
n25 --> n33
n34 --> n35
n35 -->|"事务结束失败"| n36
n35 --> n37
n33 --> n38
n38 -->|"拒绝:Access 状态;查询故障:500"| n34
n38 -->|"允许"| n39
n39 --> n40
n40 -->|"失败:输入错误状态"| n34
n40 --> n41
n41 -->|"失败:400"| n34
n41 --> n42
n42 -->|"业务错误映射状态;其他故障:500"| n34
n42 --> n43
n43 -->|"编码失败:500"| n34
n43 --> n44
n44 -->|"提交失败"| n45
n44 --> n46
n46 --> n47
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| user.account.verify | [api/admin/manage.dever:3:18](api/admin/manage.dever#L3,18) | [user.account.verify](../../user/account/link.md#flow-aaaacb87d61f819c-1) |
| content.category.disable | [api/admin/manage.dever:3:18](api/admin/manage.dever#L3,18) | [content.category.disable](link.md#flow-85101fdf961a71c3-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.disable](app.dever#L23) | 领域内 | API 绑定 | [api/admin/manage.dever:3:18](api/admin/manage.dever#L3,18) |
| [user.account.verify](../../user/account/app/account.dever#L133) | 跨领域 [user.account](../../user/account/link.md) | 身份验证 | [api/admin/manage.dever:3:18](api/admin/manage.dever#L3,18) |
### content.category.create
定义:[app.dever:8:1](app.dever#L8,1)
<a id="flow-dffb2069d02e287c-1"></a>
```mermaid
flowchart TD
n0(["create"])
click n0 href "app.dever#L8,1" "打开源码"
n1(["返回"])
n3["content.category.model.create"]
click n3 href "app.dever#L9,12" "打开源码"
n4["赋值 stored"]
click n4 href "app.dever#L9,12" "打开源码"
n5["赋值 category"]
click n5 href "app.dever#L15,14" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n1
```
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.model.create](model.dever#L6) | 领域内 | 内建操作 | [app.dever:9:12](app.dever#L9,12) |
### content.category.disable
定义:[app.dever:23:1](app.dever#L23,1)
<a id="flow-85101fdf961a71c3-1"></a>
```mermaid
flowchart TD
n0(["disable"])
click n0 href "app.dever#L23,1" "打开源码"
n1(["返回"])
n3["content.category.model.update"]
click n3 href "app.dever#L24,13" "打开源码"
n4["赋值 changed"]
click n4 href "app.dever#L24,13" "打开源码"
n5["赋值 discarded"]
click n5 href "app.dever#L30,15" "打开源码"
n6["赋值 ok"]
click n6 href "app.dever#L31,8" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n6
n6 --> n1
```
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.model.update](model.dever#L6) | 领域内 | 内建操作 | [app.dever:24:13](app.dever#L24,13) |
### content.category.model.Category
定义:[model.dever:6:1](model.dever#L6,1)
没有已收录的下游调用。
### content.category.model.CategoryStatus
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
### content.category.requireActive
定义:[app.dever:34:1](app.dever#L34,1)、[app.dever:39:1](app.dever#L39,1)
<a id="flow-eee0931d93e09e79-1"></a>
```mermaid
flowchart TD
n0(["requireActive"])
click n0 href "app.dever#L34,1" "打开源码"
n1(["返回"])
n3{"按实参匹配分句(互斥)"}
n4["id: model.id"]
click n4 href "app.dever#L34,1" "打开源码"
n5["content.category.model.get"]
click n5 href "app.dever#L35,12" "打开源码"
n6["赋值 stored"]
click n6 href "app.dever#L35,12" "打开源码"
n7[["dever.api.require"]]
click n7 href "app.dever#L36,3" "打开源码"
n8["id: null"]
click n8 href "app.dever#L39,1" "打开源码"
n0 --> n3
n3 -->|"id: model.id"| n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n1
n3 -->|"id: null"| n8
n8 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| dever.api.require | [app.dever:36:3](app.dever#L36,3) | 依赖或生成的分派(本页不展开) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.model.get](model.dever#L6) | 领域内 | 内建操作 | [app.dever:35:12](app.dever#L35,12) |
| `dever.api.require` | 依赖 | 调用 | [app.dever:36:3](app.dever#L36,3) |
@@ -0,0 +1,10 @@
type CategoryStatus {
Active = "启用"
Disabled = "禁用"
}
type Category {
name: Text(1, 80)
slug: Text(1, 100) lower unique
status: CategoryStatus default CategoryStatus.Active index
}
@@ -0,0 +1,8 @@
# 内容修订
在编辑文章的同一事务中保存版本快照。
- [修订接口](doc/api-admin-manage.md)
- [内容修订应用](doc/app.md)
- [内容修订模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1 @@
post create = app.create
@@ -0,0 +1,45 @@
type RevisionView {
id: model.id
article_id: news.article.model.id
version: Int
title: Text
body: Text
status: Text
}
transaction create(
selected_article_id: news.article.model.id,
expected_version: Int,
title: Text,
body: Text,
category_id: content.category.model.id?,
asset_id: media.asset.model.id?
) (revision: RevisionView) {
edited = news.article.edit(
selected_article_id,
expected_version,
title,
body,
category_id,
asset_id
)
stored = model.create(
{
article_id = selected_article_id
version = edited.version
title = edited.title
body = edited.body
category_id = edited.category_id
asset_id = edited.asset_id
status = model.RevisionStatus.Draft
}
)
revision = RevisionView {
id = stored.id
article_id = stored.article_id
version = stored.version
title = stored.title
body = stored.body
status = "draft"
}
}
@@ -0,0 +1,5 @@
# 修订接口
## content.revision.api.admin.manage.create
按版本编辑文章,并保存修订快照。
@@ -0,0 +1,9 @@
# 内容修订应用
## content.revision.RevisionView
编辑后返回的修订编号、文章编号、版本及内容。
## content.revision.create
按 expected_version 编辑文章并保存修订快照,两步在同一事务中完成。版本冲突、分类或媒体不可用时整次操作失败。
@@ -0,0 +1,9 @@
# 内容修订模型
## content.revision.model.RevisionStatus
修订的草稿或已发布状态。
## content.revision.model.Revision
文章某一版本的内容快照;同一文章和版本只能保存一条。
@@ -0,0 +1,212 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 3a206461ddeadfa4087f3a0db7ee697474811b4cc2f983c8d57592e0bf48eeed -->
# content.revision
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
- API [content.revision.api.admin.manage.create](api/admin/manage.dever#L1) → [content.revision.create](app.dever#L10)
## 外部调用方
当前加载的源码中没有已收录的外部调用。
## 能力与内部调用
### content.revision.RevisionView
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### content.revision.api.admin.manage.create
定义:[api/admin/manage.dever:1:1](api/admin/manage.dever#L1,1)
<a id="flow-61096d20b01e1b77-1"></a>
```mermaid
flowchart TD
n0(["content.revision.api.admin.manage.create"])
click n0 href "api/admin/manage.dever#L1,15" "打开源码"
n1{"路由 POST /content/revision/admin/manage/create,topic #34;admin/manage#34; 接受请求?"}
n2(["返回 HTTP 404"])
n3["进入 API"]
n4["取得认证时间"]
n5(["返回 HTTP 500"])
n6["按 topic #34;admin/manage#34; 选择站点与 Provider"]
n7(["返回 HTTP 403"])
n8{"读取 Bearer 或站点 Cookie;两者互斥"}
n9(["返回 HTTP 401"])
n10["认证上下文就绪"]
n11(["返回 HTTP 401"])
n12["校验 Token 的签名、有效期及站点"]
n13{"Cookie 且非 GET / HEAD / OPTIONS?"}
n14["检查站点 Origin"]
n15(["返回 HTTP 403"])
n16(["返回 HTTP 401"])
n17{"按 Provider.verify 选择验证函数"}
n18[["user.account.verify"]]
click n18 href "api/admin/manage.dever#L1,15" "打开源码"
n19(["返回 HTTP 业务错误映射状态;其他故障 500"])
n20["用验证结果建立身份"]
n21(["返回 HTTP 认证错误状态"])
n22(["返回 HTTP 500"])
n23["建立请求数据库上下文"]
n24{"有已验证租户?"}
n25["数据库上下文就绪"]
n26{"部署启用了租户存储?"}
n27(["返回 HTTP 403"])
n28["按已验证租户选择数据库"]
n29(["返回 HTTP 503"])
n30{"检查租户组件启用状态:content, media, news"}
n31(["返回 HTTP 403"])
n32(["返回 HTTP 503"])
n33["进入写入口事务"]
n34["失败:保留 HTTP 错误状态"]
n35["回滚入口拥有的事务"]
n36(["返回 HTTP 500"])
n37(["返回错误响应"])
n38{"权限检查:content.revision.{site}.create"}
n39["权限通过"]
n40["读取 API 输入"]
n41["解码具体参数并检查字段合同"]
n42[["content.revision.create"]]
click n42 href "api/admin/manage.dever#L1,15" "打开源码"
n43["按输出类型检查可序列化值并编码响应"]
n44["提交入口拥有的事务"]
n45(["返回 HTTP 500"])
n46["发布暂存的认证 Header / Cookie"]
n47(["返回成功响应"])
n0 --> n1
n1 -->|"未匹配此入口:继续路由;均未匹配"| n2
n1 --> n3
n3 --> n4
n4 -->|"失败"| n5
n4 --> n6
n6 -->|"无站点绑定"| n7
n6 --> n8
n8 -->|"凭据格式错误或冲突"| n9
n8 -->|"无凭据"| n11
n8 -->|"有凭据"| n13
n13 -->|"否"| n12
n13 -->|"是"| n14
n14 -->|"不符合"| n15
n14 -->|"通过"| n12
n12 -->|"无 Provider 或 Token 无效"| n16
n12 --> n17
n17 -->|"user.account.verify"| n18
n18 -->|"验证函数失败"| n19
n18 --> n20
n20 -->|"身份不合法"| n21
n20 -->|"通过"| n10
n17 -->|"无匹配验证函数"| n22
n10 --> n23
n23 --> n24
n24 -->|"否"| n26
n26 -->|"是:缺少租户身份"| n27
n26 -->|"否"| n25
n24 -->|"是"| n28
n28 -->|"选择失败"| n29
n28 --> n30
n30 -->|"未启用"| n31
n30 -->|"查询失败"| n32
n30 -->|"允许"| n25
n25 --> n33
n34 --> n35
n35 -->|"事务结束失败"| n36
n35 --> n37
n33 --> n38
n38 -->|"拒绝:Access 状态;查询故障:500"| n34
n38 -->|"允许"| n39
n39 --> n40
n40 -->|"失败:输入错误状态"| n34
n40 --> n41
n41 -->|"失败:400"| n34
n41 --> n42
n42 -->|"业务错误映射状态;其他故障:500"| n34
n42 --> n43
n43 -->|"编码失败:500"| n34
n43 --> n44
n44 -->|"提交失败"| n45
n44 --> n46
n46 --> n47
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| user.account.verify | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) | [user.account.verify](../../user/account/link.md#flow-aaaacb87d61f819c-1) |
| content.revision.create | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) | [content.revision.create](link.md#flow-8c2beaa3a2e4fcf0-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.revision.create](app.dever#L10) | 领域内 | API 绑定 | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) |
| [user.account.verify](../../user/account/app/account.dever#L133) | 跨领域 [user.account](../../user/account/link.md) | 身份验证 | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) |
### content.revision.create
定义:[app.dever:10:1](app.dever#L10,1)
<a id="flow-8c2beaa3a2e4fcf0-1"></a>
```mermaid
flowchart TD
n0(["create"])
click n0 href "app.dever#L10,1" "打开源码"
n1["开始事务"]
n2(["提交事务,返回"])
n3(["回滚事务,传播错误"])
n4[["news.article.edit"]]
click n4 href "app.dever#L18,12" "打开源码"
n5["赋值 edited"]
click n5 href "app.dever#L18,12" "打开源码"
n6["content.revision.model.create"]
click n6 href "app.dever#L26,12" "打开源码"
n7["赋值 stored"]
click n7 href "app.dever#L26,12" "打开源码"
n8["赋值 revision"]
click n8 href "app.dever#L37,14" "打开源码"
n0 --> n1
n1 -.->|"事务体任一未捕获错误"| n3
n1 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n8
n8 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| news.article.edit | [app.dever:18:12](app.dever#L18,12) | [news.article.edit](../../news/article/link.md#flow-2c557350a87b85a1-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [news.article.edit](../../news/article/app/editing.dever#L77) | 跨领域 [news.article](../../news/article/link.md) | 调用 | [app.dever:18:12](app.dever#L18,12) |
| [content.revision.model.create](model.dever#L6) | 领域内 | 内建操作 | [app.dever:26:12](app.dever#L26,12) |
### content.revision.model.Revision
定义:[model.dever:6:1](model.dever#L6,1)
没有已收录的下游调用。
### content.revision.model.RevisionStatus
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
@@ -0,0 +1,16 @@
type RevisionStatus {
Draft = "草稿"
Published = "已发布"
}
type Revision {
article_id: news.article.model.id index
version: Int
title: Text(1, 160)
body: Text
category_id: content.category.model.id?
asset_id: media.asset.model.id?
status: RevisionStatus default RevisionStatus.Draft index
}
unique(article_id, version)
@@ -0,0 +1,10 @@
# 媒体资源
保存上传文件及元数据,供文章引用可用媒体。
- [媒体本地适配器](doc/adapter.md)
- [上传接口](doc/api-admin-manage.md)
- [媒体资源应用](doc/app.md)
- [媒体资源模型](doc/model.md)
- [媒体存储端口](doc/port.md)
- [调用流程](link.md)
@@ -0,0 +1,3 @@
port.store(file: Upload) (key: Uuid) {
key = dever.storage.put(file)
}
@@ -0,0 +1 @@
long post upload = app.store
@@ -0,0 +1,41 @@
type StoreResult {
id: model.id
storage_key: Uuid
filename: Text
content_type: Text
size: Int
}
store(file: Upload) (result: StoreResult) {
filename = dever.api.uploadFilename(file)
content_type = dever.api.uploadContentType(file)
size = dever.api.uploadSize(file)
key = port.store(file)
result = record(key, filename, content_type, size)
}
transaction record(key: Uuid, filename: Text, content_type: Text, size: Int) (result: StoreResult) {
asset = model.create(
{
storage_key = key
filename = filename
content_type = content_type
size = size
}
)
audit.operation.record(audit.operation.model.OperationKind.Upload, "asset", filename)
result = StoreResult {
id = asset.id
storage_key = asset.storage_key
filename = asset.filename
content_type = asset.content_type
size = asset.size
}
}
requireReady(id: model.id) () {
stored = model.get(id)
dever.api.require(stored.status == model.AssetStatus.Ready, dever.api.Error.Conflict)
}
requireReady(id: null) () {}
@@ -0,0 +1,5 @@
# 媒体本地适配器
## media.asset.adapter.port.store
使用本地 storage 保存上传内容,返回文件 Uuid。
@@ -0,0 +1,5 @@
# 上传接口
## media.asset.api.admin.manage.upload
接收 multipart 文件上传,保存文件及资源元数据。
@@ -0,0 +1,17 @@
# 媒体资源应用
## media.asset.StoreResult
上传完成后返回资源编号、存储键、文件名、内容类型和字节数。
## media.asset.store
读取上传元数据并保存文件,再写入资源记录。文件存储不占用数据库事务。
## media.asset.record
在同一事务中写入资源元数据和上传审计。
## media.asset.requireReady
媒体编号为空时跳过;否则要求资源处于 Ready 状态,不可用时返回冲突。
@@ -0,0 +1,9 @@
# 媒体资源模型
## media.asset.model.AssetStatus
媒体资源的就绪或已删除状态。
## media.asset.model.Asset
已存文件的唯一存储键、元数据及状态。
@@ -0,0 +1,5 @@
# 媒体存储端口
## media.asset.port.store
保存一次性上传内容,返回存储键;失败使用 dever.storage.PutResult。
@@ -0,0 +1,350 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 11ad386ddcac8cef62a4a2d0330b0c2b4678196a227a7676c7e6a55aba63b5f1 -->
# media.asset
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
- API [media.asset.api.admin.manage.upload](api/admin/manage.dever#L1) → [media.asset.store](app.dever#L9)
## 外部调用方
| 能力 | 调用方 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [media.asset.requireReady](app.dever#L36) | [news.article.create](../../news/article/app/editing.dever#L16) | 调用 | [../../news/article/app/editing.dever:25:3](../../news/article/app/editing.dever#L25,3) |
| [media.asset.requireReady](app.dever#L36) | [news.article.edit](../../news/article/app/editing.dever#L77) | 调用 | [../../news/article/app/editing.dever:90:3](../../news/article/app/editing.dever#L90,3) |
| [media.asset.requireReady](app.dever#L36) | [news.article.publishDraft](../../news/article/app/publication.dever#L48) | 调用 | [../../news/article/app/publication.dever:52:3](../../news/article/app/publication.dever#L52,3) |
## 能力与内部调用
### media.asset.StoreResult
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### media.asset.adapter.port.store
定义:[adapter.dever:1:1](adapter.dever#L1,1)
<a id="flow-eb9faa6e4f022f76-1"></a>
```mermaid
flowchart TD
n0(["port.store"])
click n0 href "adapter.dever#L1,1" "打开源码"
n1(["返回"])
n3[["dever.storage.put"]]
click n3 href "adapter.dever#L2,9" "打开源码"
n4["赋值 key"]
click n4 href "adapter.dever#L2,9" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| dever.storage.put | [adapter.dever:2:9](adapter.dever#L2,9) | 依赖或生成的分派(本页不展开) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| `dever.storage.put` | 依赖 | 调用 | [adapter.dever:2:9](adapter.dever#L2,9) |
### media.asset.api.admin.manage.upload
定义:[api/admin/manage.dever:1:1](api/admin/manage.dever#L1,1)
<a id="flow-7e3fa2eda06052de-1"></a>
```mermaid
flowchart TD
n0(["media.asset.api.admin.manage.upload"])
click n0 href "api/admin/manage.dever#L1,20" "打开源码"
n1{"路由 POST /media/asset/admin/manage/upload,topic #34;admin/manage#34; 接受请求?"}
n2(["返回 HTTP 404"])
n3["进入 API"]
n4["取得认证时间"]
n5(["返回 HTTP 500"])
n6["按 topic #34;admin/manage#34; 选择站点与 Provider"]
n7(["返回 HTTP 403"])
n8{"读取 Bearer 或站点 Cookie;两者互斥"}
n9(["返回 HTTP 401"])
n10["认证上下文就绪"]
n11(["返回 HTTP 401"])
n12["校验 Token 的签名、有效期及站点"]
n13{"Cookie 且非 GET / HEAD / OPTIONS?"}
n14["检查站点 Origin"]
n15(["返回 HTTP 403"])
n16(["返回 HTTP 401"])
n17{"按 Provider.verify 选择验证函数"}
n18[["user.account.verify"]]
click n18 href "api/admin/manage.dever#L1,20" "打开源码"
n19(["返回 HTTP 业务错误映射状态;其他故障 500"])
n20["用验证结果建立身份"]
n21(["返回 HTTP 认证错误状态"])
n22(["返回 HTTP 500"])
n23["建立请求数据库上下文"]
n24{"有已验证租户?"}
n25["数据库上下文就绪"]
n26{"部署启用了租户存储?"}
n27(["返回 HTTP 403"])
n28["按已验证租户选择数据库"]
n29(["返回 HTTP 503"])
n30{"检查租户组件启用状态:audit, media"}
n31(["返回 HTTP 403"])
n32(["返回 HTTP 503"])
n33["失败:保留 HTTP 错误状态"]
n34(["返回错误响应"])
n35{"权限检查:media.asset.{site}.upload"}
n36["权限通过"]
n37["读取 multipart、取得 Upload 和字段"]
n38["解码具体参数并检查字段合同"]
n39[["media.asset.store"]]
click n39 href "api/admin/manage.dever#L1,20" "打开源码"
n40["按输出类型检查可序列化值并编码响应"]
n41["发布暂存的认证 Header / Cookie"]
n42(["返回成功响应"])
n0 --> n1
n1 -->|"未匹配此入口:继续路由;均未匹配"| n2
n1 --> n3
n3 --> n4
n4 -->|"失败"| n5
n4 --> n6
n6 -->|"无站点绑定"| n7
n6 --> n8
n8 -->|"凭据格式错误或冲突"| n9
n8 -->|"无凭据"| n11
n8 -->|"有凭据"| n13
n13 -->|"否"| n12
n13 -->|"是"| n14
n14 -->|"不符合"| n15
n14 -->|"通过"| n12
n12 -->|"无 Provider 或 Token 无效"| n16
n12 --> n17
n17 -->|"user.account.verify"| n18
n18 -->|"验证函数失败"| n19
n18 --> n20
n20 -->|"身份不合法"| n21
n20 -->|"通过"| n10
n17 -->|"无匹配验证函数"| n22
n10 --> n23
n23 --> n24
n24 -->|"否"| n26
n26 -->|"是:缺少租户身份"| n27
n26 -->|"否"| n25
n24 -->|"是"| n28
n28 -->|"选择失败"| n29
n28 --> n30
n30 -->|"未启用"| n31
n30 -->|"查询失败"| n32
n30 -->|"允许"| n25
n33 --> n34
n25 --> n35
n35 -->|"拒绝:Access 状态;查询故障:500"| n33
n35 -->|"允许"| n36
n36 --> n37
n37 -->|"失败:上传错误状态"| n33
n37 --> n38
n38 -->|"失败:400"| n33
n38 --> n39
n39 -->|"业务错误映射状态;其他故障:500"| n33
n39 --> n40
n40 -->|"编码失败:500"| n33
n40 --> n41
n41 --> n42
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| user.account.verify | [api/admin/manage.dever:1:20](api/admin/manage.dever#L1,20) | [user.account.verify](../../user/account/link.md#flow-aaaacb87d61f819c-1) |
| media.asset.store | [api/admin/manage.dever:1:20](api/admin/manage.dever#L1,20) | [media.asset.store](link.md#flow-9ce0fcefee26c58b-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [media.asset.store](app.dever#L9) | 领域内 | API 绑定 | [api/admin/manage.dever:1:20](api/admin/manage.dever#L1,20) |
| [user.account.verify](../../user/account/app/account.dever#L133) | 跨领域 [user.account](../../user/account/link.md) | 身份验证 | [api/admin/manage.dever:1:20](api/admin/manage.dever#L1,20) |
### media.asset.model.Asset
定义:[model.dever:6:1](model.dever#L6,1)
没有已收录的下游调用。
### media.asset.model.AssetStatus
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
### media.asset.port.store
定义:[port.dever:1:1](port.dever#L1,1)
没有已收录的下游调用。
### media.asset.record
定义:[app.dever:17:1](app.dever#L17,1)
<a id="flow-b00da9b3a217d427-1"></a>
```mermaid
flowchart TD
n0(["record"])
click n0 href "app.dever#L17,1" "打开源码"
n1["开始事务"]
n2(["提交事务,返回"])
n3(["回滚事务,传播错误"])
n4["media.asset.model.create"]
click n4 href "app.dever#L18,11" "打开源码"
n5["赋值 asset"]
click n5 href "app.dever#L18,11" "打开源码"
n6[["audit.operation.record"]]
click n6 href "app.dever#L26,3" "打开源码"
n7["赋值 result"]
click n7 href "app.dever#L27,12" "打开源码"
n0 --> n1
n1 -.->|"事务体任一未捕获错误"| n3
n1 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| audit.operation.record | [app.dever:26:3](app.dever#L26,3) | [audit.operation.record](../../audit/operation/link.md#flow-c1586d946bef8bfb-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [media.asset.model.create](model.dever#L6) | 领域内 | 内建操作 | [app.dever:18:11](app.dever#L18,11) |
| [audit.operation.record](../../audit/operation/app.dever#L1) | 跨领域 [audit.operation](../../audit/operation/link.md) | 调用 | [app.dever:26:3](app.dever#L26,3) |
### media.asset.requireReady
定义:[app.dever:36:1](app.dever#L36,1)、[app.dever:41:1](app.dever#L41,1)
<a id="flow-2c00b9e4b7136711-1"></a>
```mermaid
flowchart TD
n0(["requireReady"])
click n0 href "app.dever#L36,1" "打开源码"
n1(["返回"])
n3{"按实参匹配分句(互斥)"}
n4["id: model.id"]
click n4 href "app.dever#L36,1" "打开源码"
n5["media.asset.model.get"]
click n5 href "app.dever#L37,12" "打开源码"
n6["赋值 stored"]
click n6 href "app.dever#L37,12" "打开源码"
n7[["dever.api.require"]]
click n7 href "app.dever#L38,3" "打开源码"
n8["id: null"]
click n8 href "app.dever#L41,1" "打开源码"
n0 --> n3
n3 -->|"id: model.id"| n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n1
n3 -->|"id: null"| n8
n8 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| dever.api.require | [app.dever:38:3](app.dever#L38,3) | 依赖或生成的分派(本页不展开) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [media.asset.model.get](model.dever#L6) | 领域内 | 内建操作 | [app.dever:37:12](app.dever#L37,12) |
| `dever.api.require` | 依赖 | 调用 | [app.dever:38:3](app.dever#L38,3) |
### media.asset.store
定义:[app.dever:9:1](app.dever#L9,1)
<a id="flow-9ce0fcefee26c58b-1"></a>
```mermaid
flowchart TD
n0(["store"])
click n0 href "app.dever#L9,1" "打开源码"
n1(["返回"])
n3["dever.api.uploadFilename"]
click n3 href "app.dever#L10,14" "打开源码"
n4["赋值 filename"]
click n4 href "app.dever#L10,14" "打开源码"
n5["dever.api.uploadContentType"]
click n5 href "app.dever#L11,18" "打开源码"
n6["赋值 content_type"]
click n6 href "app.dever#L11,18" "打开源码"
n7["dever.api.uploadSize"]
click n7 href "app.dever#L12,10" "打开源码"
n8["赋值 size"]
click n8 href "app.dever#L12,10" "打开源码"
n9[["media.asset.adapter.port.store"]]
click n9 href "app.dever#L13,9" "打开源码"
n10["赋值 key"]
click n10 href "app.dever#L13,9" "打开源码"
n11[["media.asset.record"]]
click n11 href "app.dever#L14,12" "打开源码"
n12["赋值 result"]
click n12 href "app.dever#L14,12" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n8
n8 --> n9
n9 --> n10
n10 --> n11
n11 --> n12
n12 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| media.asset.adapter.port.store | [app.dever:13:9](app.dever#L13,9) | [media.asset.adapter.port.store](link.md#flow-eb9faa6e4f022f76-1) |
| media.asset.record | [app.dever:14:12](app.dever#L14,12) | [media.asset.record](link.md#flow-b00da9b3a217d427-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| `dever.api.uploadFilename` | 内建操作 | 内建操作 | [app.dever:10:14](app.dever#L10,14) |
| `dever.api.uploadContentType` | 内建操作 | 内建操作 | [app.dever:11:18](app.dever#L11,18) |
| `dever.api.uploadSize` | 内建操作 | 内建操作 | [app.dever:12:10](app.dever#L12,10) |
| [media.asset.adapter.port.store](adapter.dever#L1) | 领域内 | 调用 | [app.dever:13:9](app.dever#L13,9) |
| [media.asset.record](app.dever#L17) | 领域内 | 调用 | [app.dever:14:12](app.dever#L14,12) |
@@ -0,0 +1,12 @@
type AssetStatus {
Ready = "就绪"
Removed = "已删除"
}
type Asset {
storage_key: Uuid unique
filename: Text(1, 255)
content_type: Text(1, 160)
size: Int
status: AssetStatus default AssetStatus.Ready index
}
@@ -0,0 +1 @@
store(file: Upload) (key: Uuid) fails dever.storage.PutResult
@@ -0,0 +1,12 @@
# 文章
管理作者草稿和版本,手动或定时发布为前台快照。
- [文章管理接口](doc/api-admin-manage.md)
- [文章浏览接口](doc/api-front-browse.md)
- [文章草稿](doc/app-editing.md)
- [文章发布](doc/app-publication.md)
- [文章规则](doc/domain.md)
- [文章公开读模型](doc/model-publication.md)
- [文章模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1,9 @@
get list = app.list
get detail = app.detail
post create = app.create
delete remove = app.remove
post publish = app.publish
@@ -0,0 +1,3 @@
get list = app.published
get detail = app.publishedDetail
@@ -0,0 +1,111 @@
type ArticleView {
id: model.id
slug: Text
title: Text
body: Text
version: Int
category_id: content.category.model.id?
asset_id: media.asset.model.id?
}
type ArticlePage {
rows: List<ArticleView>
total: Int
}
create(
slug: Text,
title: Text,
body: Text,
category_id: content.category.model.id?,
asset_id: media.asset.model.id?
) (article: ArticleView) {
dever.api.require(text.trim(body) != "", dever.api.Error.Invalid)
content.category.requireActive(category_id)
media.asset.requireReady(asset_id)
stored = model.create(
{
author_id = user.account.currentUser()
slug = slug
title = title
body = body
category_id = category_id
asset_id = asset_id
}
)
article = detail(stored.id)
}
list(size: Int) (articles: ArticlePage) {
author = user.account.currentUser()
articles = ArticlePage {
rows = model.ownedArticles(author, domain.pageSize(size))
total = model.count(
{
where = author_id == author
}
)
}
}
detail(id: model.id) (article: ArticleView) {
stored = model.get(id)
dever.api.require(stored.author_id == user.account.currentUser(), dever.api.Error.Forbidden)
article = ArticleView {
id = stored.id
slug = stored.slug
title = stored.title
body = stored.body
version = stored.version
category_id = stored.category_id
asset_id = stored.asset_id
}
}
remove(id: model.id) (removed: Int) {
stored = model.get(id)
dever.api.require(stored.author_id == user.account.currentUser(), dever.api.Error.Forbidden)
dever.api.require(stored.status == model.ArticleStatus.Draft, dever.api.Error.Conflict)
removed = model.delete(
{
where = id == stored.id and version == stored.version
}
)
domain.requireUpdated(removed)
}
edit(
id: model.id,
expected_version: Int,
title: Text,
body: Text,
category_id: content.category.model.id?,
asset_id: media.asset.model.id?
) (article: ArticleView) {
stored = model.get(id)
dever.api.require(stored.author_id == user.account.currentUser(), dever.api.Error.Forbidden)
dever.api.require(stored.status == model.ArticleStatus.Draft, dever.api.Error.Conflict)
dever.api.require(text.trim(body) != "", dever.api.Error.Invalid)
content.category.requireActive(category_id)
media.asset.requireReady(asset_id)
changed = model.update(
{
where = id == stored.id and version == expected_version and status == model.ArticleStatus.Draft
},
{
title = title
body = body
category_id = category_id
asset_id = asset_id
version = expected_version + 1
}
)
domain.requireUpdated(changed)
article = detail(stored.id)
}
requireSchedulable(article_id: model.id) () {
stored = model.get(article_id)
dever.api.require(stored.author_id == user.account.currentUser(), dever.api.Error.Forbidden)
dever.api.require(stored.status == model.ArticleStatus.Draft, dever.api.Error.Conflict)
}
@@ -0,0 +1,95 @@
transaction publish(article_id: model.id) (publication: model.publication.Publication) {
stored = model.get(article_id)
dever.api.require(stored.author_id == user.account.currentUser(), dever.api.Error.Forbidden)
dever.api.require(stored.status == model.ArticleStatus.Draft, dever.api.Error.Conflict)
publication = publishDraft(article_id)
}
published(size: Int) (articles: model.publication.Page) {
articles = model.publication.list(
{
order = [published_at.desc, id.desc]
size = domain.pageSize(size)
}
)
}
publishedDetail(id: model.publication.id) (article: model.publication.Publication) {
article = model.publication.get(id)
dever.api.require(text.trim(article.body) != "", dever.api.Error.NotFound)
}
transaction publishScheduled(article_id: model.id) (
publication: model.publication.Publication
) {
stored = model.get(article_id)
publication = scheduledPublication(stored.status, article_id)
}
scheduledPublication(status: model.ArticleStatus.Published, article_id: model.id) (
publication: model.publication.Publication
) {
stored = model.get(article_id)
publication = existingPublication(
model.publication.first(
{
where = slug == stored.slug
}
)
)
}
scheduledPublication(status: model.ArticleStatus.Draft, article_id: model.id) (
publication: model.publication.Publication
) {
publication = publishDraft(article_id)
}
publishDraft(article_id: model.id) (publication: model.publication.Publication) {
stored = model.get(article_id)
dever.api.require(stored.status == model.ArticleStatus.Draft, dever.api.Error.Conflict)
content.category.requireActive(stored.category_id)
media.asset.requireReady(stored.asset_id)
published_at = dever.time.now()
changed = model.update(
{
where = id == stored.id and version == stored.version and status == model.ArticleStatus.Draft
},
{
status = model.ArticleStatus.Published
published_at = published_at
version = stored.version + 1
}
)
domain.requireUpdated(changed)
audit.operation.record(
audit.operation.model.OperationKind.Publish,
"article",
stored.slug
)
publication = model.publication.upsert(
{
slug = stored.slug
},
{
title = stored.title
body = stored.body
published_at = published_at
},
{
title = stored.title
body = stored.body
published_at = published_at
}
)
}
existingPublication(stored: model.publication.Publication) (
publication: model.publication.Publication
) {
publication = stored
}
existingPublication(stored: null) (publication: model.publication.Publication) {
fail(dever.api.Error.NotFound)
}
@@ -0,0 +1,21 @@
# 文章管理接口
## news.article.api.admin.manage.list
读取当前作者的文章列表与总数。
## news.article.api.admin.manage.detail
读取当前作者的文章详情。
## news.article.api.admin.manage.create
创建当前作者的草稿。
## news.article.api.admin.manage.remove
删除当前作者的草稿。
## news.article.api.admin.manage.publish
发布当前作者的草稿。
@@ -0,0 +1,9 @@
# 文章浏览接口
## news.article.api.front.browse.list
读取当前租户的已发布文章。
## news.article.api.front.browse.detail
读取已发布文章详情。
@@ -0,0 +1,33 @@
# 文章草稿
## news.article.ArticleView
编辑接口返回的文章内容、版本、分类和媒体编号。
## news.article.ArticlePage
当前作者的文章列表及总数。
## news.article.create
为当前作者创建草稿。正文不能为空白,所选分类和媒体须可用;正文保留原文。
## news.article.list
读取当前作者的最新文章和总数,size 必须为 1–100。
## news.article.detail
读取当前作者的一篇文章,访问其他作者的文章返回禁止访问。
## news.article.remove
删除当前作者的草稿,并检查版本未被并发修改。已发布文章不能删除。
## news.article.edit
按 expected_version 修改当前作者的草稿,成功后版本加一。正文为空白或分类、媒体不可用时失败。
## news.article.requireSchedulable
要求文章属于当前作者且仍为草稿,供创建发布计划使用。
@@ -0,0 +1,29 @@
# 文章发布
## news.article.publish
发布当前作者的草稿;状态更新、审计和公开快照在同一事务中完成。
## news.article.published
按发布时间和编号倒序读取发布快照,size 必须为 1–100。
## news.article.publishedDetail
读取一份发布快照;正文为空白时返回不存在。
## news.article.publishScheduled
执行定时发布。已发布文章复用现有快照,草稿进入发布流程。
## news.article.scheduledPublication
根据文章状态选择复用快照或发布草稿。
## news.article.publishDraft
检查草稿、分类和媒体,更新文章状态与版本,写入审计并保存发布快照;并发修改返回冲突。
## news.article.existingPublication
取出已有发布快照;找不到时返回不存在。
@@ -0,0 +1,9 @@
# 文章规则
## news.article.domain.pageSize
只接受 1–100 的列表大小,其他值返回请求无效。
## news.article.domain.requireUpdated
要求数据库恰好修改一条记录,否则返回冲突。
@@ -0,0 +1,5 @@
# 文章公开读模型
## news.article.model.publication.Publication
前台读取的发布快照,保存 slug、标题、正文和发布时间。
@@ -0,0 +1,9 @@
# 文章模型
## news.article.model.ArticleStatus
文章的草稿或已发布状态。
## news.article.model.Article
作者所属的文章草稿和发布状态。版本、作者与发布状态不直接作为公开字段返回。
@@ -0,0 +1,13 @@
pageSize(value: Int >= 1 and <= 100) (size: Int) pure {
size = value
}
pageSize(value: other) (size: Int) pure {
fail(dever.api.Error.Invalid)
}
requireUpdated(changed: 1) () pure {}
requireUpdated(changed: other) () pure {
fail(dever.api.Error.Conflict)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,25 @@
type ArticleStatus {
Draft = "草稿"
Published = "已发布"
}
type Article {
private owner author_id: Int = dever.auth.userId()
slug: Text(1, 180) lower unique
title: Text(1, 160)
body: Text raw
category_id: content.category.model.id?
asset_id: media.asset.model.id?
private version: Int default 1
private status: ArticleStatus default ArticleStatus.Draft index
private published_at: DateTime?
}
index(author_id, created_at)
sql ownedArticles(author: Int, size: Int) (
articles: List<app.ArticleView>
) {
sqlite = "SELECT id, slug, title, body, version, category_id, asset_id FROM news_article_model WHERE author_id = ?1 ORDER BY id DESC LIMIT ?2"
postgres = "SELECT id, slug, title, body, version, category_id, asset_id FROM news_article_model WHERE author_id = $1 ORDER BY id DESC LIMIT $2"
}
@@ -0,0 +1,6 @@
type Publication {
slug: Text(1, 180) unique
title: Text(1, 160)
body: Text
published_at: DateTime index
}
@@ -0,0 +1,8 @@
# 租户
管理平台租户记录和启用状态,数据库准备由部署命令完成。
- [租户应用](doc/app.md)
- [租户规则](doc/domain.md)
- [租户模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1,41 @@
type TenantView {
id: model.id
key: Text
name: Text
}
provision(key: Text, name: Text) (tenant: TenantView) {
stored = model.create(
{
key = key
name = name
}
)
tenant = TenantView {
id = stored.id
key = stored.key
name = stored.name
}
}
active(tenant_key: Text) (tenant: TenantView) {
stored = model.first(
{
where = key == domain.normalizeKey(tenant_key)
}
)
tenant = activeView(stored)
}
activeView(stored: model.Tenant) (tenant: TenantView) {
dever.api.require(stored.status == model.TenantStatus.Active, dever.api.Error.Unauthorized)
tenant = TenantView {
id = stored.id
key = stored.key
name = stored.name
}
}
activeView(stored: null) (tenant: TenantView) {
fail(dever.api.Error.Unauthorized)
}
@@ -0,0 +1,17 @@
# 租户应用
## platform.tenant.TenantView
租户编号、唯一键和显示名称。
## platform.tenant.provision
创建租户记录,key 去除首尾空白后转为小写并保持唯一。
## platform.tenant.active
按规范化 key 查找启用租户,不存在或已禁用时返回未授权。
## platform.tenant.activeView
检查租户启用状态并转换为 TenantView,空记录返回未授权。
@@ -0,0 +1,5 @@
# 租户规则
## platform.tenant.domain.normalizeKey
去掉租户键两端空白并转为小写,用于与 Model 写入规则一致地查询。
@@ -0,0 +1,9 @@
# 租户模型
## platform.tenant.model.TenantStatus
租户的启用或禁用状态。
## platform.tenant.model.Tenant
平台库中的租户记录;key 唯一,UUID 自动生成。
@@ -0,0 +1,3 @@
normalizeKey(value: Text) (key: Text) pure {
key = text.lower(text.trim(value))
}
@@ -0,0 +1,197 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 3d8386ca09494f4f62fa83613bf8379bbfa858632449d9d19fe86f3a86ad1d9c -->
# platform.tenant
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
没有已收录的入口。
## 外部调用方
| 能力 | 调用方 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [platform.tenant.active](app.dever#L21) | [user.account.login](../../user/account/app/account.dever#L70) | 调用 | [../../user/account/app/account.dever:78:14](../../user/account/app/account.dever#L78,14) |
| [platform.tenant.active](app.dever#L21) | [user.account.establishLogin](../../user/account/app/account.dever#L91) | 调用 | [../../user/account/app/account.dever:99:14](../../user/account/app/account.dever#L99,14) |
| [platform.tenant.active](app.dever#L21) | [user.account.verify](../../user/account/app/account.dever#L133) | 调用 | [../../user/account/app/account.dever:137:14](../../user/account/app/account.dever#L137,14) |
| [platform.tenant.provision](app.dever#L7) | [user.account.registerAccount](../../user/account/app/account.dever#L41) | 调用 | [../../user/account/app/account.dever:48:12](../../user/account/app/account.dever#L48,12) |
## 能力与内部调用
### platform.tenant.TenantView
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### platform.tenant.active
定义:[app.dever:21:1](app.dever#L21,1)
<a id="flow-720cd3e6ee01541e-1"></a>
```mermaid
flowchart TD
n0(["active"])
click n0 href "app.dever#L21,1" "打开源码"
n1(["返回"])
n3[["platform.tenant.domain.normalizeKey"]]
click n3 href "app.dever#L24,22" "打开源码"
n4["platform.tenant.model.first"]
click n4 href "app.dever#L22,12" "打开源码"
n5["赋值 stored"]
click n5 href "app.dever#L22,12" "打开源码"
n6[["platform.tenant.activeView"]]
click n6 href "app.dever#L27,12" "打开源码"
n7["赋值 tenant"]
click n7 href "app.dever#L27,12" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| platform.tenant.domain.normalizeKey | [app.dever:24:22](app.dever#L24,22) | [platform.tenant.domain.normalizeKey](link.md#flow-675cb56e9567397f-1) |
| platform.tenant.activeView | [app.dever:27:12](app.dever#L27,12) | [platform.tenant.activeView](link.md#flow-b450477f16968f30-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [platform.tenant.model.first](model.dever#L6) | 领域内 | 内建操作 | [app.dever:22:12](app.dever#L22,12) |
| [platform.tenant.domain.normalizeKey](domain.dever#L1) | 领域内 | 调用 | [app.dever:24:22](app.dever#L24,22) |
| [platform.tenant.activeView](app.dever#L30) | 领域内 | 调用 | [app.dever:27:12](app.dever#L27,12) |
### platform.tenant.activeView
定义:[app.dever:30:1](app.dever#L30,1)、[app.dever:39:1](app.dever#L39,1)
<a id="flow-b450477f16968f30-1"></a>
```mermaid
flowchart TD
n0(["activeView"])
click n0 href "app.dever#L30,1" "打开源码"
n1(["返回"])
n2(["传播错误"])
n3{"按实参匹配分句(互斥)"}
n4["stored: model.Tenant"]
click n4 href "app.dever#L30,1" "打开源码"
n5[["dever.api.require"]]
click n5 href "app.dever#L31,3" "打开源码"
n6["赋值 tenant"]
click n6 href "app.dever#L32,12" "打开源码"
n7["stored: null"]
click n7 href "app.dever#L39,1" "打开源码"
n8["fail dever.api.Error.Unauthorized"]
click n8 href "app.dever#L40,8" "打开源码"
n0 --> n3
n3 -->|"stored: model.Tenant"| n4
n4 --> n5
n5 --> n6
n6 --> n1
n3 -->|"stored: null"| n7
n7 --> n8
n8 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| dever.api.require | [app.dever:31:3](app.dever#L31,3) | 依赖或生成的分派(本页不展开) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| `dever.api.require` | 依赖 | 调用 | [app.dever:31:3](app.dever#L31,3) |
### platform.tenant.domain.normalizeKey
定义:[domain.dever:1:1](domain.dever#L1,1)
<a id="flow-675cb56e9567397f-1"></a>
```mermaid
flowchart TD
n0(["normalizeKey"])
click n0 href "domain.dever#L1,1" "打开源码"
n1(["返回"])
n3[["text.trim"]]
click n3 href "domain.dever#L2,20" "打开源码"
n4[["text.lower"]]
click n4 href "domain.dever#L2,9" "打开源码"
n5["赋值 key"]
click n5 href "domain.dever#L2,9" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| text.trim | [domain.dever:2:20](domain.dever#L2,20) | 依赖或生成的分派(本页不展开) |
| text.lower | [domain.dever:2:9](domain.dever#L2,9) | 依赖或生成的分派(本页不展开) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| `text.lower` | 依赖 | 调用 | [domain.dever:2:9](domain.dever#L2,9) |
| `text.trim` | 依赖 | 调用 | [domain.dever:2:20](domain.dever#L2,20) |
### platform.tenant.model.Tenant
定义:[model.dever:6:1](model.dever#L6,1)
没有已收录的下游调用。
### platform.tenant.model.TenantStatus
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
### platform.tenant.provision
定义:[app.dever:7:1](app.dever#L7,1)
<a id="flow-cc4bbca9579ab41a-1"></a>
```mermaid
flowchart TD
n0(["provision"])
click n0 href "app.dever#L7,1" "打开源码"
n1(["返回"])
n3["platform.tenant.model.create"]
click n3 href "app.dever#L8,12" "打开源码"
n4["赋值 stored"]
click n4 href "app.dever#L8,12" "打开源码"
n5["赋值 tenant"]
click n5 href "app.dever#L14,12" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n1
```
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [platform.tenant.model.create](model.dever#L6) | 领域内 | 内建操作 | [app.dever:8:12](app.dever#L8,12) |
@@ -0,0 +1,11 @@
type TenantStatus {
Active = "启用"
Disabled = "禁用"
}
global type Tenant {
uuid: Uuid generated unique
key: Text(1, 64) lower unique
name: Text(1, 100)
status: TenantStatus default TenantStatus.Active index
}
@@ -0,0 +1,9 @@
# 发布计划
为文章安排一次定时发布,重复任务不会重复发布。
- [发布计划接口](doc/api-admin-manage.md)
- [发布计划应用](doc/app.md)
- [发布计划任务](doc/job.md)
- [发布计划模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1 @@
post schedule = app.create
@@ -0,0 +1,74 @@
type PublishRequest {
schedule_id: model.id
}
type ScheduleView {
id: model.id
article_id: news.article.model.id
publish_at: DateTime
status: Text
}
transaction create(article_id: news.article.model.id, publish_at: DateTime) (
schedule: ScheduleView
) {
news.article.requireSchedulable(article_id)
stored = model.create(
{
article_id = article_id
publish_at = publish_at
}
)
queued = dever.job.enqueueAt(
job.publish,
PublishRequest {
schedule_id = stored.id
},
uuid.toText(stored.uuid),
publish_at
)
discarded = queued
schedule = ScheduleView {
id = stored.id
article_id = stored.article_id
publish_at = stored.publish_at
status = "pending"
}
}
transaction publish(input: PublishRequest) () {
scheduled = model.get(input.schedule_id)
complete(scheduled.status, scheduled.id)
}
complete(status: model.ScheduleStatus.Completed, schedule_id: model.id) () {}
complete(status: model.ScheduleStatus.Cancelled, schedule_id: model.id) () {}
complete(status: model.ScheduleStatus.Pending, schedule_id: model.id) () {
scheduled = model.get(schedule_id)
dever.api.require(model.exists(
{
where = id == schedule_id and publish_at <= dever.time.now()
}
), dever.api.Error.Invalid)
changed = model.update(
{
where = id == scheduled.id and status == model.ScheduleStatus.Pending
},
{
status = model.ScheduleStatus.Completed
}
)
publishClaimed(changed, scheduled.article_id)
}
publishClaimed(changed: 0, article_id: news.article.model.id) () {}
publishClaimed(changed: 1, article_id: news.article.model.id) () {
publication = news.article.publishScheduled(article_id)
}
publishClaimed(changed: other, article_id: news.article.model.id) () {
fail(dever.api.Error.Conflict)
}
@@ -0,0 +1,5 @@
# 发布计划接口
## publishing.schedule.api.admin.manage.schedule
为当前作者的草稿建立定时发布计划。
@@ -0,0 +1,25 @@
# 发布计划应用
## publishing.schedule.PublishRequest
发布任务的输入,只保存计划编号。
## publishing.schedule.ScheduleView
创建计划后返回文章编号、计划时间和状态。
## publishing.schedule.create
为当前作者的草稿建立发布计划并按时入队;计划记录和入队在同一事务内完成。每篇文章只能有一条计划。
## publishing.schedule.publish
加载发布计划并按其状态执行,供 Job 调用。
## publishing.schedule.complete
仅处理已经到时的 Pending 计划;已完成或取消的计划不再执行。状态更新与文章发布在同一事务中完成。
## publishing.schedule.publishClaimed
成功更新一条计划后发布文章;零条表示无需重复执行,其他数量返回冲突。
@@ -0,0 +1,5 @@
# 发布计划任务
## publishing.schedule.job.publish
执行发布计划,最多执行 3 次(含首次),单次期限 30 秒。
@@ -0,0 +1,9 @@
# 发布计划模型
## publishing.schedule.model.ScheduleStatus
计划的待发布、已完成或已取消状态。
## publishing.schedule.model.Schedule
每篇文章的唯一发布计划,保存执行时间和状态。
Whitespace-only changes.
@@ -0,0 +1,5 @@
database default
job publish(input: app.PublishRequest) () retry(3) timeout(30000) {
app.publish(input)
}
@@ -0,0 +1,430 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: be17003a018467df95914a96c87fcb8890e4ade3118c3511d7e00fde98087ea7 -->
# publishing.schedule
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
- API [publishing.schedule.api.admin.manage.schedule](api/admin/manage.dever#L1) → [publishing.schedule.create](app.dever#L12)
- Job [publishing.schedule.job.publish](job.dever#L3)
## 外部调用方
当前加载的源码中没有已收录的外部调用。
## 能力与内部调用
### publishing.schedule.PublishRequest
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### publishing.schedule.ScheduleView
定义:[app.dever:5:1](app.dever#L5,1)
没有已收录的下游调用。
### publishing.schedule.api.admin.manage.schedule
定义:[api/admin/manage.dever:1:1](api/admin/manage.dever#L1,1)
<a id="flow-4a14d9ba44a23d84-1"></a>
```mermaid
flowchart TD
n0(["publishing.schedule.api.admin.manage.schedule"])
click n0 href "api/admin/manage.dever#L1,17" "打开源码"
n1{"路由 POST /publishing/schedule/admin/manage/schedule,topic #34;admin/manage#34; 接受请求?"}
n2(["返回 HTTP 404"])
n3["进入 API"]
n4["取得认证时间"]
n5(["返回 HTTP 500"])
n6["按 topic #34;admin/manage#34; 选择站点与 Provider"]
n7(["返回 HTTP 403"])
n8{"读取 Bearer 或站点 Cookie;两者互斥"}
n9(["返回 HTTP 401"])
n10["认证上下文就绪"]
n11(["返回 HTTP 401"])
n12["校验 Token 的签名、有效期及站点"]
n13{"Cookie 且非 GET / HEAD / OPTIONS?"}
n14["检查站点 Origin"]
n15(["返回 HTTP 403"])
n16(["返回 HTTP 401"])
n17{"按 Provider.verify 选择验证函数"}
n18[["user.account.verify"]]
click n18 href "api/admin/manage.dever#L1,17" "打开源码"
n19(["返回 HTTP 业务错误映射状态;其他故障 500"])
n20["用验证结果建立身份"]
n21(["返回 HTTP 认证错误状态"])
n22(["返回 HTTP 500"])
n23["建立请求数据库上下文"]
n24{"有已验证租户?"}
n25["数据库上下文就绪"]
n26{"部署启用了租户存储?"}
n27(["返回 HTTP 403"])
n28["按已验证租户选择数据库"]
n29(["返回 HTTP 503"])
n30{"检查租户组件启用状态:news, publishing"}
n31(["返回 HTTP 403"])
n32(["返回 HTTP 503"])
n33["进入写入口事务"]
n34["失败:保留 HTTP 错误状态"]
n35["回滚入口拥有的事务"]
n36(["返回 HTTP 500"])
n37(["返回错误响应"])
n38{"权限检查:publishing.schedule.{site}.schedule"}
n39["权限通过"]
n40["从可信身份建立 Job 执行上下文"]
n41["读取 API 输入"]
n42["解码具体参数并检查字段合同"]
n43[["publishing.schedule.create"]]
click n43 href "api/admin/manage.dever#L1,17" "打开源码"
n44["按输出类型检查可序列化值并编码响应"]
n45["提交入口拥有的事务"]
n46(["返回 HTTP 500"])
n47["发布暂存的认证 Header / Cookie"]
n48(["返回成功响应"])
n0 --> n1
n1 -->|"未匹配此入口:继续路由;均未匹配"| n2
n1 --> n3
n3 --> n4
n4 -->|"失败"| n5
n4 --> n6
n6 -->|"无站点绑定"| n7
n6 --> n8
n8 -->|"凭据格式错误或冲突"| n9
n8 -->|"无凭据"| n11
n8 -->|"有凭据"| n13
n13 -->|"否"| n12
n13 -->|"是"| n14
n14 -->|"不符合"| n15
n14 -->|"通过"| n12
n12 -->|"无 Provider 或 Token 无效"| n16
n12 --> n17
n17 -->|"user.account.verify"| n18
n18 -->|"验证函数失败"| n19
n18 --> n20
n20 -->|"身份不合法"| n21
n20 -->|"通过"| n10
n17 -->|"无匹配验证函数"| n22
n10 --> n23
n23 --> n24
n24 -->|"否"| n26
n26 -->|"是:缺少租户身份"| n27
n26 -->|"否"| n25
n24 -->|"是"| n28
n28 -->|"选择失败"| n29
n28 --> n30
n30 -->|"未启用"| n31
n30 -->|"查询失败"| n32
n30 -->|"允许"| n25
n25 --> n33
n34 --> n35
n35 -->|"事务结束失败"| n36
n35 --> n37
n33 --> n38
n38 -->|"拒绝:Access 状态;查询故障:500"| n34
n38 -->|"允许"| n39
n39 --> n40
n40 -->|"失败:认证错误状态"| n34
n40 --> n41
n41 -->|"失败:输入错误状态"| n34
n41 --> n42
n42 -->|"失败:400"| n34
n42 --> n43
n43 -->|"业务错误映射状态;其他故障:500"| n34
n43 --> n44
n44 -->|"编码失败:500"| n34
n44 --> n45
n45 -->|"提交失败"| n46
n45 --> n47
n47 --> n48
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| user.account.verify | [api/admin/manage.dever:1:17](api/admin/manage.dever#L1,17) | [user.account.verify](../../user/account/link.md#flow-aaaacb87d61f819c-1) |
| publishing.schedule.create | [api/admin/manage.dever:1:17](api/admin/manage.dever#L1,17) | [publishing.schedule.create](link.md#flow-a5c2828f7faa5041-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [publishing.schedule.create](app.dever#L12) | 领域内 | API 绑定 | [api/admin/manage.dever:1:17](api/admin/manage.dever#L1,17) |
| [user.account.verify](../../user/account/app/account.dever#L133) | 跨领域 [user.account](../../user/account/link.md) | 身份验证 | [api/admin/manage.dever:1:17](api/admin/manage.dever#L1,17) |
### publishing.schedule.complete
定义:[app.dever:44:1](app.dever#L44,1)、[app.dever:46:1](app.dever#L46,1)、[app.dever:48:1](app.dever#L48,1)
<a id="flow-87bb9cb4f5f0c2f6-1"></a>
```mermaid
flowchart TD
n0(["complete"])
click n0 href "app.dever#L44,1" "打开源码"
n1(["返回"])
n3{"按实参匹配分句(互斥)"}
n4["status: model.ScheduleStatus.Completed"]
click n4 href "app.dever#L44,1" "打开源码"
n5["status: model.ScheduleStatus.Cancelled"]
click n5 href "app.dever#L46,1" "打开源码"
n6["status: model.ScheduleStatus.Pending"]
click n6 href "app.dever#L48,1" "打开源码"
n7["publishing.schedule.model.get"]
click n7 href "app.dever#L49,15" "打开源码"
n8["赋值 scheduled"]
click n8 href "app.dever#L49,15" "打开源码"
n9[["dever.time.now"]]
click n9 href "app.dever#L52,53" "打开源码"
n10["publishing.schedule.model.exists"]
click n10 href "app.dever#L50,21" "打开源码"
n11[["dever.api.require"]]
click n11 href "app.dever#L50,3" "打开源码"
n12["publishing.schedule.model.update"]
click n12 href "app.dever#L55,13" "打开源码"
n13["赋值 changed"]
click n13 href "app.dever#L55,13" "打开源码"
n14[["publishing.schedule.publishClaimed"]]
click n14 href "app.dever#L63,3" "打开源码"
n0 --> n3
n3 -->|"status: model.ScheduleStatus.Completed"| n4
n4 --> n1
n3 -->|"status: model.ScheduleStatus.Cancelled"| n5
n5 --> n1
n3 -->|"status: model.ScheduleStatus.Pending"| n6
n6 --> n7
n7 --> n8
n8 --> n9
n9 --> n10
n10 --> n11
n11 --> n12
n12 --> n13
n13 --> n14
n14 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| dever.time.now | [app.dever:52:53](app.dever#L52,53) | 依赖或生成的分派(本页不展开) |
| dever.api.require | [app.dever:50:3](app.dever#L50,3) | 依赖或生成的分派(本页不展开) |
| publishing.schedule.publishClaimed | [app.dever:63:3](app.dever#L63,3) | [publishing.schedule.publishClaimed](link.md#flow-c4a292464addac3f-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [publishing.schedule.model.get](model.dever#L7) | 领域内 | 内建操作 | [app.dever:49:15](app.dever#L49,15) |
| `dever.api.require` | 依赖 | 调用 | [app.dever:50:3](app.dever#L50,3) |
| [publishing.schedule.model.exists](model.dever#L7) | 领域内 | 内建操作 | [app.dever:50:21](app.dever#L50,21) |
| `dever.time.now` | 依赖 | 调用 | [app.dever:52:53](app.dever#L52,53) |
| [publishing.schedule.model.update](model.dever#L7) | 领域内 | 内建操作 | [app.dever:55:13](app.dever#L55,13) |
| [publishing.schedule.publishClaimed](app.dever#L66) | 领域内 | 调用 | [app.dever:63:3](app.dever#L63,3) |
### publishing.schedule.create
定义:[app.dever:12:1](app.dever#L12,1)
<a id="flow-a5c2828f7faa5041-1"></a>
```mermaid
flowchart TD
n0(["create"])
click n0 href "app.dever#L12,1" "打开源码"
n1["开始事务"]
n2(["提交事务,返回"])
n3(["回滚事务,传播错误"])
n4[["news.article.requireSchedulable"]]
click n4 href "app.dever#L15,3" "打开源码"
n5["publishing.schedule.model.create"]
click n5 href "app.dever#L16,12" "打开源码"
n6["赋值 stored"]
click n6 href "app.dever#L16,12" "打开源码"
n7[["uuid.toText"]]
click n7 href "app.dever#L27,5" "打开源码"
n8["持久化任务入队"]
click n8 href "app.dever#L22,12" "打开源码"
n9[["Job:publishing.schedule.job.publish"]]
click n9 href "app.dever#L22,12" "打开源码"
n10["赋值 queued"]
click n10 href "app.dever#L22,12" "打开源码"
n11["赋值 discarded"]
click n11 href "app.dever#L30,15" "打开源码"
n12["赋值 schedule"]
click n12 href "app.dever#L31,14" "打开源码"
n0 --> n1
n1 -.->|"事务体任一未捕获错误"| n3
n1 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n8
n8 -.->|"由 Worker 后续调度,不等待执行"| n9
n8 --> n10
n10 --> n11
n11 --> n12
n12 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| news.article.requireSchedulable | [app.dever:15:3](app.dever#L15,3) | [news.article.requireSchedulable](../../news/article/link.md#flow-dc698efe57d88983-1) |
| uuid.toText | [app.dever:27:5](app.dever#L27,5) | 依赖或生成的分派(本页不展开) |
| Job:publishing.schedule.job.publish | [app.dever:22:12](app.dever#L22,12) | [publishing.schedule.job.publish](link.md#flow-c9df8cbe8f8cd013-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [news.article.requireSchedulable](../../news/article/app/editing.dever#L107) | 跨领域 [news.article](../../news/article/link.md) | 调用 | [app.dever:15:3](app.dever#L15,3) |
| [publishing.schedule.model.create](model.dever#L7) | 领域内 | 内建操作 | [app.dever:16:12](app.dever#L16,12) |
| [publishing.schedule.job.publish](job.dever#L3) | 领域内 | 入队 | [app.dever:23:5](app.dever#L23,5) |
| `uuid.toText` | 依赖 | 调用 | [app.dever:27:5](app.dever#L27,5) |
### publishing.schedule.job.publish
定义:[job.dever:3:1](job.dever#L3,1)
<a id="flow-c9df8cbe8f8cd013-1"></a>
```mermaid
flowchart TD
n0(["publish"])
click n0 href "job.dever#L3,1" "打开源码"
n1(["返回"])
n3[["publishing.schedule.publish"]]
click n3 href "job.dever#L4,3" "打开源码"
n0 --> n3
n3 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| publishing.schedule.publish | [job.dever:4:3](job.dever#L4,3) | [publishing.schedule.publish](link.md#flow-1452f352cf5266e6-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [publishing.schedule.publish](app.dever#L39) | 领域内 | 调用 | [job.dever:4:3](job.dever#L4,3) |
### publishing.schedule.model.Schedule
定义:[model.dever:7:1](model.dever#L7,1)
没有已收录的下游调用。
### publishing.schedule.model.ScheduleStatus
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
### publishing.schedule.publish
定义:[app.dever:39:1](app.dever#L39,1)
<a id="flow-1452f352cf5266e6-1"></a>
```mermaid
flowchart TD
n0(["publish"])
click n0 href "app.dever#L39,1" "打开源码"
n1["开始事务"]
n2(["提交事务,返回"])
n3(["回滚事务,传播错误"])
n4["publishing.schedule.model.get"]
click n4 href "app.dever#L40,15" "打开源码"
n5["赋值 scheduled"]
click n5 href "app.dever#L40,15" "打开源码"
n6[["publishing.schedule.complete"]]
click n6 href "app.dever#L41,3" "打开源码"
n0 --> n1
n1 -.->|"事务体任一未捕获错误"| n3
n1 --> n4
n4 --> n5
n5 --> n6
n6 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| publishing.schedule.complete | [app.dever:41:3](app.dever#L41,3) | [publishing.schedule.complete](link.md#flow-87bb9cb4f5f0c2f6-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [publishing.schedule.model.get](model.dever#L7) | 领域内 | 内建操作 | [app.dever:40:15](app.dever#L40,15) |
| [publishing.schedule.complete](app.dever#L44) | 领域内 | 调用 | [app.dever:41:3](app.dever#L41,3) |
### publishing.schedule.publishClaimed
定义:[app.dever:66:1](app.dever#L66,1)、[app.dever:68:1](app.dever#L68,1)、[app.dever:72:1](app.dever#L72,1)
<a id="flow-c4a292464addac3f-1"></a>
```mermaid
flowchart TD
n0(["publishClaimed"])
click n0 href "app.dever#L66,1" "打开源码"
n1(["返回"])
n2(["传播错误"])
n3{"按实参匹配分句(互斥)"}
n4["changed: 0"]
click n4 href "app.dever#L66,1" "打开源码"
n5["changed: 1"]
click n5 href "app.dever#L68,1" "打开源码"
n6[["news.article.publishScheduled"]]
click n6 href "app.dever#L69,17" "打开源码"
n7["赋值 publication"]
click n7 href "app.dever#L69,17" "打开源码"
n8["changed: other"]
click n8 href "app.dever#L72,1" "打开源码"
n9["fail dever.api.Error.Conflict"]
click n9 href "app.dever#L73,8" "打开源码"
n0 --> n3
n3 -->|"changed: 0"| n4
n4 --> n1
n3 -->|"changed: 1"| n5
n5 --> n6
n6 --> n7
n7 --> n1
n3 -->|"changed: other"| n8
n8 --> n9
n9 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| news.article.publishScheduled | [app.dever:69:17](app.dever#L69,17) | [news.article.publishScheduled](../../news/article/link.md#flow-5ba4105cff8222e5-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [news.article.publishScheduled](../../news/article/app/publication.dever#L22) | 跨领域 [news.article](../../news/article/link.md) | 调用 | [app.dever:69:17](app.dever#L69,17) |
@@ -0,0 +1,12 @@
type ScheduleStatus {
Pending = "待发布"
Completed = "已完成"
Cancelled = "已取消"
}
type Schedule {
uuid: Uuid generated unique
article_id: news.article.model.id unique
publish_at: DateTime index
status: ScheduleStatus default ScheduleStatus.Pending index
}
@@ -0,0 +1,7 @@
# 健康检查
提供命令行健康检查。
- [健康检查命令](doc/api.md)
- [系统健康](doc/app.md)
- [调用流程](link.md)
Loaded 100 of 1479 files, more files were not shown because too many files have changed in this diff. Show more