115 KiB
Dever 语言开发指南
本文描述当前已经实现的 Dever 语言。它面向使用 Dever 编写程序的人,不涉及编译器内部实现。
编译、检查、静态特化和运行时边界的设计见 Dever 语言实现原理。
1. 快速开始
先按 安装指引 安装机器版 Dever。AI 开发使用独立 dever-language skill。已有命令时直接生成项目;目标目录必须不存在,父目录必须已存在:
dever new my_app
# Markdown 写法:dever new my_app --markdown
命令接收项目根。Dever 源码固定放在 module/,部署配置固定放在 config/setting.json,运行数据放在 data/。模板生成:
my_app/
config/
setting.json
AGENTS.md
README.md
module/
hello/
greeting/
app.dever
api.dever
test/hello/greeting/greet.dever
my_app/module/hello/greeting/app.dever:
greet(name: Text) (message: Text) {
message = "Hello, " + name
}
my_app/module/hello/greeting/api.dever:
cmd greet = app.greet
my_app/config/setting.json:
{
"log": { "level": "info" }
}
模板不需要数据库或 HTTP 配置。添加 HTTP 时再完整配置站点与身份,不能只添加 get 而遗漏 sites/auth。配置不会编译进二进制。run 使用项目目录中的配置;独立二进制启动时使用可执行文件同级的 config/setting.json,相对数据路径也以该目录为基准。
语言在开发和安装后的命令名均为 dever,Go 框架使用 dever-go。开发仓库生成 target/debug/dever;机器版启动器根据 config/setting.json 中可选的精确 dever.version 选择已安装版本,未锁定项目使用机器活动版本,再把项目命令和退出码原样分派。启动器的内部构建名为 dever-launcher,安装后仍叫 dever,版本核心为 dever-core。Linux 机器安装由多个项目共享。源码构建不会替换机器上已有的全局命令。
以下命令在已经提供 dever 的开发环境中执行:
dever fmt my_app --check
dever check my_app
dever test my_app
dever run my_app -- hello.greeting.greet '{"name":"Dever"}'
dever build my_app --output my_app/app
Linux 开发环境运行构建产物:
./my_app/app hello.greeting.greet '{"name":"Dever"}'
new:从随版本验证的模板原子创建新项目,不覆盖已有目录;--markdown生成等价 Markdown 源码。- 机器版
update:从官方仓库的 Releases 下载并校验,更新核心与配套 skill,不改业务项目文件。skill path [project-root]返回对应版本的指引。在项目根目录运行skill install,自动向AGENTS.md写入或更新 Dever 规则块,保留其他内容,无需指定技能目录。new自动生成同一份规则。项目可用dever.version锁定版本;普通项目命令不会触发更新。 check:加载生产源码,完成语法、名称、类型、分句及应用入口可达性和冗余硬检查;不执行应用或准备运行资源。fmt --check:只检查格式,不修改文件。fmt:统一格式化项目module/和存在的test/;需要修复格式时单独执行dever fmt my_app。test:检查生产源码与应用测试,再按名称串行运行每个隔离用例;没有test/时报告 0 个测试并成功。run:编译器从 API、CMD、Job 声明生成入口;无参数时按setting.json的 runtime mode 启动服务,-- <component>.<domain>.<cmd> '<json-object>'只执行指定命令。build:默认生成本机独立可执行程序;Linux x86_64 可加--target linux-aarch64,使用预先准备的 ARM runtime 和依赖生成 ARM 程序。输出路径必须尚不存在,已有文件不会被覆盖。发布时把二进制放在项目根,使它能从同级config/setting.json加载部署配置。Windows 和 macOS 发行流程尚未交付。
check、fmt、run 和 build 默认只使用本地源码及随编译器提供的 package,不会隐式联网下载依赖。
2. 源文件与领域职责
Dever 源文件使用 .dever 或 .dever.md 后缀和 UTF-8 编码。两种格式共用语言规则;Markdown 写法见下一小节。标识符使用 ASCII;注释和 Text 可以包含任意 UTF-8 字符。
# 单行注释
type Greeting { message: Text }
greet(name: Text) (response: Greeting) {
response = Greeting { message = "你好," + name }
}
规则如下:
#开始单行注释,没有块注释。- 不写分号,声明和语句由换行或结构边界分隔。
- 应用
run/build不使用手写main.dever;入口由 API、CMD 与 Job 声明生成。 - 业务源码固定为
<component>/<domain>/<role>.dever,或在确实需要按主题拆分时使用<component>/<domain>/<role>/<topic>.dever。 - role 只能是
app、domain、model、port、adapter、api、job。应用源码不写package、exposes、public或internal。 app中的全部函数自动成为领域能力,调用名固定为<component>.<domain>.<function>;同组件可省略 component,同领域内部可写app.<function>。app/<topic>.dever的 topic 不进入调用名。- App 能力的输入、输出和向外传播的错误都属于公开契约,不能引用 Domain、Adapter 等私有类型。业务错误类型应声明在 App,Domain 可通过
app.ErrorType.Variant使用它。 domain、model、port、adapter、api和job都是领域私有实现。跨领域、跨组件只能调用目标 App;API 和 Job 入口不是普通源码调用目标。- App 可调用本领域 Domain、Port 及其他领域 App;API 只能调用同领域 App。Domain 只能调用同领域 Domain。Adapter 只能调用同文件 helper 与标准能力,不能反调 App、Domain、Model、Port 或其他 Adapter。Model 操作仍只能由所属领域 App 使用静态 DSL 执行。
- App、Domain、Port、Adapter、Job 的单文件与同名目录二选一,目录至少包含两个内聚主题文件。Model、API 可以同时使用角色主文件和主题目录,不受最少文件数限制。非 API 的主题目录保持一层,只有
api/可递归。 - 一个文件可放一组内聚类型和函数,不按函数机械拆文件。禁止用
common、shared、utils、helper、base、service作为业务源码桶。 port只声明类型和无函数体的操作合同,必须写fails app.ErrorChoice;错误 choice 至少包含一个显式 error 分支,也可引用公开标准错误 choice。输入必须是无约束的明确值类型,输出同样不加范围约束。当前 Port 不接受 handler 参数。App/Domain 的外部文件、网络 I/O 必须经过 Port;日志、时间、随机数等已有能力保持原约束。- 没有
import、use、通配导入或别名。 - 不同领域之间不能循环依赖;同领域职责文件共享一个依赖边界,函数递归仍被拒绝。
Port 示例:App 声明 type DeliveryError { error Unavailable(message: Text) };同领域 port.dever 声明 send(message: Text) () fails app.DeliveryError;adapter.dever 实现 port.send(message: Text) () { ... }。Port topic 使用 port.mail.send。qualified 实现不能被普通函数调用。Port/Adapter 只能引用其合同实际使用的 App 类型,不能调用 App。Port 自有 DTO 保持私有,不能泄漏到 App 公开签名。
一个 Adapter 文件完整实现一个 Port;单实现自动绑定,多实现由 config/setting.json 的 adapter.<component.domain[.topic]>.use 选择,根 adapter 名为 default,topic 名为实现名。编译产物包含全部候选,运行时选择不改变 failure 全集、effect 或 suspension 合同。每个实现的实际 failure 必须是声明全集的子集。
Adapter 可声明 setting { endpoint: Text token: Secret },通过只读 setting.endpoint 访问。配置形如 {"adapter":{"notification.mail":{"use":"smtp","setting":{"endpoint":"...","token":"..."}}}}。仅选中实现要求 setting;未知 Port/实现/字段、重复键、缺失或错误类型在入口前失败。无 setting 的单实现允许省略配置文件。Secret 不可观察,配置错误不回显值;不使用环境变量。
外部程序同样只能实现 Port。需要调用现成 Linux 二进制时用 external command;需要类型化多操作 Worker 协议时用 external exec 或生态 Adapter。
普通二进制不必实现 Dever 协议,也不必另写 Python/Go 包装。例如将程序放在 module/media/tool/bin/tool:
# module/media/tool/port.dever
execute(args: List<Text>, stdin: Bytes?) (output: dever.process.Output) fails dever.process.Error
# module/media/tool/adapter.dever
external command "bin/tool" {}
# module/media/tool/app.dever
version() (text: Text) {
output = port.execute(["--version"], null)
text = dever.bytes.to_text(output.stdout)
}
# module/media/tool/api.dever
cmd version = app.version
执行 dever run . -- media.tool.version '{}',或先 dever build . --output program 再执行 ./program media.tool.version '{}'。
command Port 只有一个操作,操作名由业务决定;输入固定为 args: List<Text>,可选第二项 stdin: Bytes?,输出固定为 output: dever.process.Output,错误为 dever.process.Error。只需 argv 时可省略整项 stdin 参数。Output 有 code: Int、stdout: Bytes、stderr: Bytes;二进制输出不做隐式文本转换,正常非零退出码也是结果。每次调用启动一次进程,不重试,不使用 shell;空格、引号、$() 等按参数原样传递。未提供 stdin 时立即发送 EOF。启动失败、超时和输出超限产生源位置 runtime fault。
调用限额只从 config/setting.json 读取:{"adapter":{"media.tool":{"command":{"timeout_ms":30000,"output_limit":1048576}}}}。示例值就是默认值;timeout_ms 最大 3600000,output_limit 为 stdout/stderr 合计字节数、最大 8388608,均必须为正数。argv 含终止符总计最多 65536 字节且不含 NUL,stdin 最多 8388608 字节。超时、取消和退出会回收本次受管进程组;不保证清理主动脱离进程组的守护程序。需要文件、网络或子进程时仍在声明中写 allow file/network/process,实际权限继承所在容器;command 不接受 setting 或 lib 声明。
run/build 离线嵌入 ELF 和相邻 bin/lib/ 下显式随附的普通动态库文件;静态程序可直接使用,动态程序使用签名发行包的 GNU loader 和 OS 库。不扫描宿主库,不接受符号链接或 lib 子目录;缺库、错架构、SONAME/版本不匹配、越界搜索路径在构建期失败。Linux x86_64 与 ARM64 必须提供各自架构的二进制和库,Dever 不转换第三方二进制。单独使用 command 不需要 dever lib 或 dever.lock;资源可随 Dever Package 分发,Package 自身仍须锁定。测试继续用 Port fake,不执行真实命令。
在 Adapter 文件中写一个 external exec 声明,编译器会用对应 Port 的全部操作、输入、输出、业务错误和可选 setting 生成唯一协议合同,不需要再逐个重复函数:
setting {
endpoint: Text
token: Secret
}
external exec "worker/mail" {
allow network
}
entry 是所属领域目录内的可移植相对路径;上例构建时收集 module/notification/mail/worker/mail,将全部候选 exec Worker 的字节、SHA-256 和可执行位嵌入程序。入口原子提取到私有 data/cache/lib/<bundle>,每次启动 Worker 重新验证精确资源清单、摘要和 Unix 所有权/权限;不扫描其他 bundle,不依赖部署目录保留源码 Worker。路径不接受绝对形式、.、..、反斜杠或盘符,也不通过 shell、PATH、环境变量或外部命令字符串查找。capability 名称为 network、file、process、gpu;重复值和未知值在检查期拒绝。Worker 握手必须精确回显已编译的 Port、Adapter、schema、操作和 capability 集合。Linux Worker 直接执行,不创建内层 OS 沙箱。allow 是编译期效果及协议声明,不限制操作系统访问;实际文件、网络、GPU 权限由所在容器的用户、挂载、网络和设备策略决定。旧 Adapter files / gpu 授权字段已删除,真实路径通过业务 setting 传入。GPU 继承父容器设备和必要驱动环境,不重挂载设备,不修改主机安全策略。仅执行可信代码;配置与受管 C ABI 用法见 sdk/README.md。
external 只在 Adapter role 中有意义,且一个文件只能包含该声明和至多一个 setting。它完整实现且只能实现同领域一个 Port identity;不能同时写 Dever Adapter 函数或 helper。应用测试仍必须提供 case-local Dever Port fake,不启动生产 Worker,也不读取生产 Adapter setting。reference 后端明确不执行外部程序,正式执行只走 native 后端。
Worker 操作不设隐式执行期限,不会因执行超过 30 秒而自动失败;等待结果直到调用方主动取消、Worker 异常或应用停止。启动握手、协议写入和退出清理仍有独立短限时。long 只控制入口事务边界,不改变这些生命周期规则;业务确需期限时使用现有 Task 超时能力,不增加另一套 Worker 超时配置。external command 的显式命令限额不属于 Worker 协议,本次不改变其合同。
生态 Adapter 复用同一 Port 合同,声明中的 Lib 不带重复的生态前缀:
external pip "worker/mail.py" {
lib "httpx@0.28.1"
allow network
}
pip/npm/go 与 exec 使用相同的生命周期和 typed wire schema;各 Worker 的依赖与 runtime 独立锁在 dever.lock。Lib 解析是显式 dever lib 操作;run/build 只读取已锁定的资产。Python/JavaScript 打包器生成 SDK、合同和 runner,自动绑定入口中与 Port 操作同名的函数,不要求业务再写 handlers 注册表。运行树只从锁定 pack、依赖和当前 Adapter 源码生成;Python 固定使用 -I -S -B,Node 支持 ESM 和 CommonJS。启动解释器、argv 和工作目录必须匹配编译嵌入的资源清单,既不寻找系统语言环境,也不从部署配置接受任意命令。
Go Worker 使用锁定的目标专用 build pack 内的 compiler、linker 和标准库离线编译。入口源码声明 package main,按操作名导出 func(context.Context, any, any) (any, error):send 对应 Send,text.render 对应 TextRender;生成入口静态绑定函数并由 Go 编译器检查签名,不要求业务手写 main 或注册表。构建按目标 build tags 选文件,支持 go:embed,导入只能来自标准库、当前 Adapter 源码、SDK 或锁定的 Go module 闭包。构建产物只携带 Worker 可执行文件与 checked 合同,启动不寻找系统 Go。参与解析的 go.mod 和最终 module ZIP 均验证官方 sumdb 签名、包含证明与 Go h1;项目现有锁保存 checkpoint,一致性和反回滚以该锁为锚,主动删除锁会重新建立锚。
dever-lock-v6 保存每个 Worker 的精确生态依赖,以及原始归档的准确来源和文件名。Python extras 使用规范化、排序后的 pip:name[a,b]@version,不同 Worker 不共享隐式 extras;实际选中 wheel 的 METADATA 决定依赖,原生 wheel 还必须符合签名解释器的 tags、扩展 ABI 和封闭动态库搜索规则。npm 保留每个安装实例的嵌套路径、peer 上下文、optional 省略原因和 bundled 归属,不能把不同依赖上下文压成一份全局版本表。
dever lib install <root> 按已有锁恢复 Package、Lib 和必要运行环境,不重新选择版本,不改项目配置和锁文件。缺少构建产物时使用收据中的完整固定输入重放,并核对输出摘要;第三方构建不能重现相同字节时明确失败。运行环境和构建工具通过机器共享签名扩展按需准备,run/build 仍不联网。ARM 应用资源先用 dever target add linux-aarch64 准备。
显式 dever lib add/update 可用签名 build pack 构建 PEP 517 sdist、执行 npm registry 包的 preinstall/install/postinstall 和原生 addon 编译。build pack 必须提供目标运行时、头文件、编译器、sysroot、固定 shell 和工具闭包;缺少的工具或库明确报错,不寻找宿主环境。Python 的静态与动态 build requirements 使用独立精确锁,console scripts 安装到隔离构建 prefix;metadata 与 wheel hook 分进程执行,最终 wheel 必须匹配父进程保存的 metadata。npm publishing hooks 不作为 registry 安装钩子执行,可选依赖构建失败会裁剪其不可用闭包,必需依赖失败则整个准备失败。
源码构建收据绑定源码摘要、运行时、工具包、固定 frontend、构建依赖与输出摘要;npm 还绑定完整安装图。run/build 离线消费已锁定输出,不再次执行上述安装钩子或源码构建。Node addon 在仅有目标 Node 和运行库的独立进程中逐个验证;所有 .node 文件必须有成功或明确拒绝记录,运行树只安装成功项,其他平台/ABI 的变体保留在原锁定归档中。本次源码生成的 addon 验证失败会使准备失败,不能改用纯 JavaScript 回退并声称原生构建成功。Linux 构建使用明确的签名工具与输入、独立临时工作区;构建钩子直接继承容器权限,不再宣称文件/网络隔离。构建期限为五分钟,stdout/stderr 各限 16 MiB;工作区每 250 ms 检查 512 MiB/65,536 项预算并在超限时回收子进程,这不是内核磁盘 quota,检查间隔内可暂时超限。最终安装树仍限 256 MiB/65,536 项。普通 Worker 由同一进程监管执行;其他平台不能借宿主工具绕过缺失的发行支持。
Dever Package 的请求放在 config/setting.json.package:registry 是显式 HTTPS registry origin,use 是 name@version-range 数组。dever package add/update 解析版本和传递依赖;Package 与 Lib 共用 dever.lock 和机器摘要缓存。Package 的源码归属于 module/<name>/,Worker 资源归属于同组件路径或 worker/<name>/;其他组件路径不能混入归档,也不能与项目本地组件重名。check/test/run/build 自动加载锁定源码,无须增加 import 或注册方法;缺失的锁定 Worker 必须失败,不能使用项目同名文件替代。remove 离线裁剪已验证锁,不重新选择剩余依赖版本。
test/<component>/<domain>/<topic>.dever 可在本文件写 port.send(...) (...) { ... } fake;每个可达 Port 必须完整提供 fake,不读取生产 Adapter 配置或回退生产实现。同一套件只编译一次,每个 case 在独立进程绑定自己的 fake。编译检查与代码生成也按 case 独立推断 effect、failure 和挂起行为,不合并不同测试的 fake;生产合同独立检查。
持久任务使用同领域 job.dever,复杂时按主题拆成平铺 job/*.dever。Job 只适配同领域 App,不直接调用 Model、Domain、Port、Adapter 或并发原语:
database default
job publish(input: app.Publish) () retry(5) timeout(30000) {
app.apply(input)
}
job cleanup() () retry(3) timeout(1000) {
app.clean()
}
schedule cleanup = "0 * * * *"
Job 必须声明数据库、retry 和 timeout。输入为零个或一个同领域 App record,输出为零;payload 使用共享 wire codec,允许保留 Model 身份的 ModelId,不允许 Secret、private 字段、资源或 Choice。retry(1..100) 包含首次执行,timeout(1..3600000) 单位为毫秒。入口不能作为普通函数或 handler 传递。
App 使用 dever.job.enqueue(job.publish, input, key);无 payload 时省略 input。enqueue_at 在 key 后增加 DateTime。返回 Id;key 为 1..1024 字节的业务幂等键。同物理租户、执行租户、Job identity 与 key 的 pending/running 任务返回同一 Id,终结后允许重新使用 key。transaction App 内业务写入与入队共享物理事务;跨连接事务被拒绝。入队不会同步调用 handler 或继承其 effect/failure。
入队必须处于编译器建立的可信执行作用域。受保护 HTTP 入口在自动权限校验成功后保存 User 身份的 Provider、站点、subject、session、租户声明和该入口唯一的 permission key;不保存 JWT、Cookie、角色或权限集合。Worker 每次执行都重新调用配置绑定的 verify,确认该 key 仍在当前编译产物的权限目录,并查询当前角色;session、租户、组件或权限已失效时任务进入 blocked,业务 handler 不执行。public HTTP 调用链不能入队;CMD、schedule 和测试入口使用明确的 System 身份。Job payload 内的用户或租户字段始终只是业务数据,不能成为可信执行身份。
schedule 仅绑定本文件零输入 Job。cron 固定为 UTC 五字段(分、时、日、月、星期),支持数字、*、列表、范围、步长,星期为 0..6;日和星期均受限时按 OR 匹配。首次启用从当前分钟建立 cursor,不回放历史;后续每轮最多扫描并推进 256 个分钟槽,仅匹配 cron 的槽生成任务。cursor、occurrence 和推进同事务,多 scheduler 与已完成任务都不会重建同一窗口。修改已持久化的 cron fingerprint 会失败,需要明确迁移。
应用入口由编译器根据 Job 与 API 声明生成;两者同时存在时,共用运行时并由唯一配置文件 config/setting.json 选择启动模式:
{
"runtime": { "mode": "all", "shutdown_ms": 30000 },
"job": { "workers": 2, "poll_ms": 250, "lease_ms": 60000, "retry_base_ms": 1000, "retry_max_ms": 300000 }
}
这里还需配置实际 database,API 模式需配置 http。mode 为 api、worker 或 all;省略 runtime 时编译产物按实际 API/Job 能力推导 api、worker 或 all,关闭期限默认 30000 ms。显式 mode 仍必须与源码能力和所需配置一致。worker 连接池至少比 worker 数量多一个连接,留给 claim/heartbeat。SQLite 的写事务会阻塞续租,因此还要求 lease_ms 大于每个 Job 的 timeout;PostgreSQL 支持较短租约并续租。未知字段、无效范围、所选 mode 缺少源码能力或设置均在监听/claim 前失败,不支持环境变量或命令行覆盖。
任务是至少一次执行;外部副作用仍需业务幂等。每次 claim 增加 attempt 并生成独立 token,续租和结果提交要求 token 与有效 lease 匹配。崩溃、取消后的任务保留 lease,到期才能重领,达到次数上限进入 dead。失败按有界指数退避;未知 target、schema 不兼容、坏 payload、无可信执行身份、身份/能力复核失败,或不满足当前租约约束的历史任务策略进入 blocked,错误摘要不保存业务明文。私有队列 schema 升级时,无法证明执行身份或能力的历史活跃 User 任务也会阻断,不会降级为 System。SIGTERM/Ctrl-C 停止接收新请求和 claim,排空到共同期限后取消在途工作,汇合后关闭数据库并 flush 日志。
应用测试可用 dever.test.advance_clock(ms) 推进 case 独立时钟,dever.test.drain_jobs(limit) 执行到期任务并返回处理数(limit 为 1..10000)。测试复用生产队列状态机、case 的 SQLite 与 Port fake,不启动服务或读取部署配置;没有可达 Job 时 drain 返回 0,也不因此要求数据库。
job.lease_ms 范围为 100..7200000 毫秒,Job timeout 上限仍为 3600000 毫秒。测试使用最大租约,保证所有合法 timeout 都能在隔离 SQLite 中运行。
路径示例:
my_app/module/user/account/api.dever
my_app/module/user/account/app.dever
my_app/module/user/account/domain.dever
my_app/module/user/account/model.dever
my_app/module/news/article/app/publish.dever
my_app/module/news/article/app/query.dever
最后两项表示文章 App 已有至少两个内聚主题;对外仍调用 news.article.publish(...) 和 news.article.count()。只有一个主题文件时应改回 news/article/app.dever。
命名规则:
| 对象 | 格式 | 示例 |
|---|---|---|
| component、domain、role、topic、function、字段和局部值 | lower_snake_case |
user.account、can_sign_in |
| type、选择项 | UpperCamelCase |
User、Created |
| 缩写 | 按普通单词处理 | Id、HttpClient、user_id |
2.1 Markdown 程序源码
使用 .dever.md 可以把程序和面向人的用途说明放在一起。Dever 代码仍写在顶层 dever 或 typescript dever 围栏中;未安装 Dever 编辑器插件时,typescript dever 可让编辑器尝试使用 TypeScript 高亮,第二个标记用于确认其中仍是 Dever 源码。普通 .md 文档不参与编译。
Markdown 结构也是编译契约:一个一级标题说明包,一个二级标题说明一个 type 或一个逻辑函数。标题使用自然语言,技术名称、公开清单、输入输出和类型结构使用固定列表,并由编译器与代码核对。
例如 user/account/api.dever.md:
# 问候接口
向调用方返回一条问候语。
- 包:`user.account.api`
- 公开类型:无
- 公开方法:无
- 使用:无
## 获取问候
绑定本领域的问候能力。
- 声明:`get greeting`
```typescript dever
get greeting = app.greet
```
规则如下:
- 文件必须有且只有一个一级标题。一级标题下先写非空包说明,再依次写
包、公开类型、公开方法、使用;一级标题下不能有 Dever 代码块。 - 每个 type、逻辑函数或 API 声明必须分别使用一个二级标题、非空用途说明和一个 Dever 代码块。一个逻辑函数包括同一名称、同一参数数量的全部条件分句;不同声明不能共用说明,分句也不能拆到多个说明中。
- type 说明使用
类型以及字段或分支。函数说明使用函数、输入、输出;API 说明使用声明。空清单显式写无;非空输入、输出、字段和分支均须带自然语言说明。 - 源码身份必须与路径一致。App 的公开名称和调用方式由 role 自动得到;Model 的记录、choice 和 ID 类型由 Model role 自动提供;API 声明不列入公开方法。字段、分支、函数输入和输出的名称、类型、数量及顺序必须与代码一致;同一函数各分句的输入参数名必须相同。函数模式按解析后的统一类型记录,例如
true/false分句写Bool。 - 路径规则与普通源码一致;
user/account/app.dever.md的源码身份是user.account.app,其中register的调用方式是user.account.register(...)。 - 两种源码格式可以在一个项目中混用;Model/API 主文件可与 topic 目录并存,其他 role 二选一。
- 程序围栏使用至少三个反引号或波浪号,允许 0–3 个空格缩进,信息字符串须是字面的
dever或typescript dever;关闭围栏使用相同字符且长度不短于开始围栏,尾部只允许空格,不允许 Tab。程序围栏必须闭合,不能依靠文件结束隐式关闭。 - 三级及更深标题、表格、普通补充说明、其它语言及无语言标记的代码块不执行。引用、列表、HTML 或展示代码块内的围栏不作为程序。补充内容放在所属 Dever 代码块之后。
- 函数输入输出、条件分句、类型、失败义务、
pure和依赖边界都按现有 Dever 规则检查,说明文字不能代替正式契约。 check/api/run/build命令不变,并强制检查 Markdown 契约;错误显示.dever.md原文中的行列并关联对应代码。fmt检查结构并仅更新程序块内部,保留说明、围栏和展示代码块。
固定列表的完整格式见 Dever Markdown 源码规范,完整接口示例见 Markdown CMS。安装 VS Code 语法插件 后,两种围栏都使用与 .dever 相同的 Dever 高亮规则,.dever.md 保持 Markdown 语言模式。插件不提供补全、跳转或实时编译诊断;契约与语言合法性仍由编译器检查。
2.2 应用测试
应用测试位于项目根的 test/<component>/<domain>/<topic>.dever 或 .dever.md。文件名 <topic> 选择同名的普通 topic() () 作为唯一入口;入口不能有参数或输出,也不能声明为 transaction。文件中的其他函数和类型只供本文件使用,不会自动执行或向生产源码、其他测试公开。路径只允许 component、domain、topic 三层,不建立 common、shared 或全局 fixture 目录。
测试是所属领域的受控友元:可调用同领域 App 和 Domain,跨领域只能调用目标 App。测试可以读取 App 返回的 Model 值,但不能直接执行 Model CRUD,也不能调用 Port、Adapter、API 或其他测试。生产源码同样不能反向依赖测试。测试不能启动 dever.api.serve()。
首版断言只有:
register() () {
account = user.account.register("New@Example.com", "New", "hash")
assert(account.email == "new@example.com")
assert_eq(account.display_name, "New")
}
assert 只接受 Bool;assert_eq 要求两侧类型完全相同且可比较,不做隐式数值转换。失败报告测试身份、原始源码位置和实际/期望值。业务失败继续用 result(call) 与 choice 分句验证,不增加异常断言语法。
dever test <project-root> 在执行任何用例前检查全部生产和测试源码,并把所有测试入口编译成一个可缓存的原生测试程序;每个测试仍在独立原生进程与临时项目中运行。不使用 Model 的测试不初始化数据库;使用 Model 的测试走正式迁移、Seed、事务和关闭流程,但数据库配置由运行器在临时目录生成且只使用 SQLite。CLI 仍读取 config/setting.json 与锁中的依赖元数据,测试执行不会连接其中的部署数据库或使用项目 data/,也不从环境变量接收数据库覆盖;成功和失败都会清理临时文件。
定向运行使用 --filter <component[.domain[.topic]]>,例如 --filter user.account 或 --filter user.account.register。它按完整身份或点分段前缀选择用例,不使用子串或通配符;无匹配时明确失败。筛选不跳过其他源码的检查,不改变原生 suite 的索引;未选择用例计入 not run。报告仍显示原有的 component/domain/topic 路径身份。
每例默认最多运行 60 秒,--timeout-ms <正整数毫秒> 可覆盖;限时不包含测试程序的编译。超时用例失败,运行器终止并回收本例拥有的进程组和后代,然后继续后续选中用例。任一用例失败或超时,命令返回非零。测试仍按顺序执行,不提供共享 setup、并行或重试。
3. 类型与值
3.1 基础类型
| 类型 | 含义 |
|---|---|
Bool |
true 或 false |
Int |
有符号 64 位整数,溢出不会环绕 |
Decimal |
decimal128,最多 34 位有效数字,银行家舍入 |
Float |
IEEE 754 binary64 近似数 |
Text |
UTF-8 文本,不做隐式 Unicode 规范化 |
Id |
不透明稳定标识 |
Bytes |
二进制字节序列 |
Secret |
不透明敏感值;仅受控密码学及输入边界使用 |
literal 规则:
enabled = true
count = 42
price = 19.90
ratio = 1.25e6
name = "中文和 Unicode 都可以"
missing = null
- 整数 literal 默认是
Int。 - 带小数点的 literal 默认是
Decimal;明确的 Float 上下文可将其作为Float。 - 科学计数 literal 是
Float。 - Text 支持
\\、\"、\n、\r、\t和\u{HEX}。 Decimal与Float不隐式混合;Int 转 Float 使用float.from_int。
3.2 字段型 type(record)
字段型 type 是固定结构的数据:
type User {
id: Id
name: Text
phone: Text?
}
create_user(id: Id, name: Text) (user: User) {
user = User {
id = id
name = name
phone = null
}
}
rename(user: User, name: Text) (result: User) {
result = user
result.name = name
}
构造时必须一次写全全部字段。未知、重复或遗漏字段都是编译错误。字段只能在完整值上读取或修改,使用 value.field = expression 语法;不能靠逐字段赋值创建一个半初始化值。
3.3 选择型 type(choice)
选择型 type 表示一个值当前只能处于其中一种状态:
type CreateResult {
Created(user: User)
InvalidName(reason: Text)
EmailAlreadyUsed(email: Text)
}
result = CreateResult.InvalidName("name is required")
选择项必须通过所属 type 限定。选择项可以不携带数据,也可以声明一个或多个有名称、有类型的携带值。携带值的名称属于 type 契约;构造和匹配仍按声明位置书写,不是命名参数调用。
3.4 可空、List 与 Map
Value? 表示 Value 或 null。没有 Value??、隐式默认值、?. 或强制解包。
names = ["Ada", "Lin"]
labels = {
"env" = "production"
"region" = "china"
}
- List 的元素必须具有相同静态类型。
- Map 的类型是
Map<Key, Value>,保留插入顺序。 - Map key 可以是 Text、Int、Bool、Id 或不携带数据的选择型 type。
- Decimal、Float、可空值、字段型值、List 和 Map 不能作为 Map key。
- 空
[]和{}必须能从参数或输出等上下文推断类型。 get、first和find返回可空值;可空化是幂等的。entries(map)返回保持插入顺序的List<MapEntry<Key, Value>>;每个MapEntry有key和value两个字段。修改局部 MapEntry 仍遵守值语义,不会回写原 Map。
3.5 Stream 与资源
Stream<Item> 是按需拉取、顺序、一次消费的流。文件、Socket、Listener 和 Stream 是资源值;复制它们会建立指向同一资源状态的别名。Stream 的所有别名共享一个读取位置,任何别名消费或关闭后,其他别名都会看到相同状态。
AsyncStream<Item> 是异步拉取的资源流,等待下一项时挂起当前协程。它的别名共享读取位置和关闭状态;元素可跨线程传递时,流也可跨线程传递。它不与同步 Stream 隐式转换,不预取或自动收集为 List。
普通字段型值、选择型值、List 和 Map 使用值语义:修改一个局部名称不会改变调用方或其他名称持有的值。
4. function 与命名输出
Dever 的所有行为都由 function 定义:
line_total(price: Decimal, quantity: Int) (total: Decimal) {
total = price * quantity
}
- 第一组括号是输入,第二组括号是命名输出。
- 没有输入或输出时仍写
()。 - 不使用
function或return关键字。 - 调用按位置传参,不支持命名参数调用。
- 零输出调用是一条动作语句。
- 单输出调用直接得到值。
- 多输出调用得到只能按输出名称访问的结果。
- 用户 function 不能直接或间接递归。
- function 签名由 package、名称和输入数量确定;同名但输入数量不同的 function 是不同签名。
多输出示例:
name_parts(value: Text) (original: Text, normalized: Text) {
original = text.trim(value)
normalized = text.lower(original)
}
display(value: Text) (result: Text) {
parts = name_parts(value)
result = parts.original + ":" + parts.normalized
}
普通应用没有手写 main();命名输出保留在 App 函数中,API/CMD 绑定一个可序列化输出并封装为 JSON。
5. 分句是唯一条件机制
Dever 没有 if、else、switch、match 或条件表达式。所有业务分支都使用同名 function 分句:
type OrderStatus {
Draft
Paid
Rejected(reason: Text)
}
status_text(status: OrderStatus.Paid) (result: Text) {
result = "paid"
}
status_text(status: OrderStatus.Rejected(reason)) (result: Text) {
result = reason
}
status_text(status: other) (result: Text) {
result = "pending"
}
可空值也通过分句处理:
phone_text(phone: Text) (result: Text) {
result = phone
}
phone_text(phone: null) (result: Text) {
result = "not provided"
}
数值范围可以直接写在输入模式中:
grade(score: Int < 60) (result: Text) {
result = "fail"
}
grade(score: Int >= 60 and < 90) (result: Text) {
result = "pass"
}
grade(score: Int >= 90) (result: Text) {
result = "excellent"
}
同一签名的全部分句必须紧邻书写。中间插入 type 声明、其他 function 或另一输入数量的同名 function 后,再次书写原签名会直接报错,不会形成第二组规则。
分句输入支持:完整类型、选择项、null、Bool/Text/数值常量、Int/Decimal 范围和 other。选择项必须为每个携带值提供一个位置绑定;不需要使用的绑定可以写 _。编译器把一组分句作为规则检查:
- 输入和输出契约一致;
- 分句互不重叠;
- 所有可能值都被覆盖;
- 没有不可达分句。
分句没有源码顺序优先级。多输入分句按所有输入组合检查覆盖和重叠;other 是该输入位置在整组明确模式之外的补集,只能用于分句输入。
6. 线性 body 与运算
function body 只包含顺序赋值和 function 调用:
final_price(price: Decimal, rate: Decimal) (result: Decimal) {
discounted = price * (1 - rate)
result = decimal.round(discounted, 2)
}
= 用于初始化,也用于修改当前 function 内的 input、局部值或 output。编译器保证读取前已初始化、字段宿主完整、局部类型稳定,并要求退出前给所有输出赋值。
运算规则:
| 运算 | 适用范围 |
|---|---|
+ - * |
同类 Int、Decimal、Float;Int 可无损提升为 Decimal |
/ |
Int/Int 与 Decimal 运算返回 Decimal;Float/Float 返回 Float |
//、% |
仅 Int,整数除法向零截断 |
+ |
也用于两个 Text 的连接 |
== != |
相同的可比较类型 |
< <= > >= |
Int、Decimal、Float |
and or not |
Bool,and 和 or 短路 |
相等比较也适用于成员本身可比较的字段型、选择型、可空值、List、Map 和 MapEntry。List 按位置比较;Map 按插入顺序比较,所以键值相同但插入顺序不同的两个 Map 不相等。Float 遵守 IEEE 754 规则,NaN == NaN 为 false,包括它嵌套在集合或字段中时。
优先级从高到低为:调用和字段访问、一元 -/not、* / // %、+ -、比较、and、or。同级二元运算从左到右,括号可以改变顺序。
Int 溢出、Decimal 除零等静态可知故障在检查阶段报错;运行时才知道的数值故障会终止当前执行并报告 .dever 文件、行和列。
7. 集合与受控重复
Dever 没有 for、while 或用户递归。重复处理使用具名 handler 和标准组合器:
views = each(to_view, users)
active = filter(is_active, users)
user = find(has_id, users, expected_id)
total = sum(prices)
total = sum(line_total, lines, tax_rate)
state = reduce(update_state, values, initial_state)
state = reduce_until(update_until_done, stream, initial_state)
parallel_each(send_one, jobs, 4, context)
| 操作 | 可处理序列 | handler / 结果 |
|---|---|---|
each |
List、Bytes;Stream、AsyncStream 仅限零输出 handler | 零输出时执行动作;单输出时生成 List |
filter |
仅 List | handler 输出 Bool,返回保留原顺序的 List |
find |
仅 List | handler 输出 Bool,返回第一个匹配项或 null |
sum |
仅数值 List | 直接求和,或用单输出数值 handler 投影后求和 |
reduce |
List、Bytes、Stream、AsyncStream | handler 为 (Item, State) -> State,返回最终状态 |
reduce_until |
List、Bytes、Stream、AsyncStream | handler 输出下一状态和 stop: Bool,处理当前元素后可停止 |
parallel_each |
List、Bytes、Stream | 非挂起零输出 handler;第三个参数是请求并发数,范围为 1–256 |
parallel_each |
List、Bytes、AsyncStream | 挂起零输出 handler;第三个参数是任务组容量,范围为 1–65536,同时受全局任务容量约束 |
Bytes 的元素类型是 Int,值域为 0–255。List、Bytes 可使用非挂起或挂起 handler;同步 Stream 只使用非挂起 handler。AsyncStream 的 each、reduce、reduce_until 可使用两类 handler,具体类别由编译器推断。非挂起 handler 不能在挂起执行路径中隐藏阻塞或未限定的 handler effect。each、filter、find 和投影式 sum 最多可以带一个末尾 context,handler 的输入顺序是元素、context。parallel_each 的可选第四个参数也是 context。需要多项 context 时,先组合成一个字段型值。reduce 和 reduce_until 不接收 context;共享数据应放入 State。
空 List 的 sum 返回相应数值类型的零;空序列的 reduce 和 reduce_until 原样返回初始状态。reduce_until 在处理当前元素并得到 stop = true 后停止;如果输入是 Stream,下一个元素保持未消费,可以通过任一 Stream 别名继续读取。
AsyncStream 消费会使当前函数被推断为挂起。组合器本身顺序等待拉取和 handler 完成,不需要额外包装。消费返回、提前停止、故障或取消都会关闭这个异步流,其他别名不能继续消费。parallel_each 先等待空位再拉取,并在等待下一项时观察子任务故障;返回前清理已启动的工作。close(stream) 本身不挂起,会唤醒等待中的拉取。
reduce 示例:
add(value: Int, total: Int) (next: Int) {
next = total + value
}
total(values: List<Int>) (result: Int) {
result = reduce(add, values, 0)
}
reduce_until 的 handler 必须输出下一状态和名为 stop 的 Bool:
take_until_limit(value: Int, total: Int) (next: Int, stop: Bool) {
next = total + value
stop = next >= 100
}
除 parallel_each 外,List 和 Bytes 按位置顺序处理,Stream 按拉取顺序处理,挂起 handler 逐项等待完成。完全非挂起的 parallel_each 创建不超过请求并发数的作用域线程。处于挂起调用链时,命名的非挂起 handler 复用 runtime 的 blocking worker,实际并发受该 worker 上限约束,且不能再次触发并发;挂起 handler 则复用有界 Group,先等待任务容量再取下一项。parallel_each 的 Stream 仍由单一生产端顺序拉取,不会先把整个流收集到内存。handler 的业务错误保留原类型并由调用方继续传播,不转换成普通字符串。handler 副作用的完成顺序不保证,调用返回前会等待所有已启动工作结束。handler 输入和 context 不能包含 Stream。
8. 静态 handler
function 可以接收受限的静态 handler:
transform_all(
transform: handler(value: Int) (result: Text),
values: List<Int>
) (results: List<Text>) {
results = each(transform, values)
}
handler 只能是签名匹配的具名 function,或者当前 function 收到的 handler 参数。它不是普通运行时值,不能存入局部值、字段、List、Map、Stream,不能比较,也不能作为输出返回。
9. 挂起推断与结构化并发
所有 function 使用同一种声明语法。普通调用保持顺序完成;如果调用链包含网络、非阻塞定时器、AsyncStream 或其他挂起操作,编译器会沿静态调用图推断挂起 effect,并在生成的 Rust 内部插入 .await。Dever 源码不声明 async,也不写 await:
sleep_ok(result: dever.time.SleepResult.Done) () recover("this operation may continue after timer failure") {}
sleep_ok(result: dever.time.SleepResult.Failed(message)) () recover("this operation may continue after timer failure") {}
delayed_value(value: Int) (result: Int) {
sleep_ok(dever.task.sleep(10))
result = value
}
main() (result: Int) {
task = run(delayed_value(42))
result = wait(task)
}
直接调用始终等待结果;只有 run(call(...)) 显式并发启动。run 同时支持挂起函数和普通函数,普通函数会进入受限 blocking worker,不会堵塞调度线程。它返回局部仿射 Task:Task 不能复制、嵌套进其它值、跨线程传递或作为输出返回,并且必须恰好由一次 wait(task)、stop(task)、timeout 或 race 消费。stop 仅接受零输出 Task,语义是请求取消并等待任务停止;取消不会回滚已经发生的外部副作用。Task 离开异常路径时也会请求取消,不会成为脱离父作用域的后台任务。
动态数量的零输出任务使用 Group:
workers = group(8)
run(workers, write_one(message))
wait(workers)
group(limit) 的 limit 范围是 1–65536。达到 limit 后,新的 run(group, call(...)) 会等待已有任务完成,不会无限排队。Group 也是局部仿射值,最终必须由 wait(group) 或 stop(group) 消费;第一个子任务失败时,其余子任务会被取消并等待结束。
CPU 计算和阻塞调用必须显式分类:parallel(pure_call(...)) 只接受 pure 非挂起 function;blocking(blocking_call(...)) 只接受 effect 分析已识别为阻塞系统边界且不含未知 handler effect 的非挂起 function。文件、同步睡眠和标准输出属于阻塞边界;TCP 调用会被推断为挂起。两种边界的参数都在提交前按源码顺序求值,工作由有界线程池承担。挂起函数不能原地执行阻塞调用、隐藏并发的集合 handler 或 effect 未知的非挂起 handler 参数;必须使用 blocking 或调整调用结构。
有界 Channel 用于任务通信:
messages = channel(Text, 64)
send(messages, "ready")
message = receive(messages)
close(messages)
Channel 容量范围是 1–65536。send、receive 会使当前调用链被推断为挂起;队列满时发送方等待,队列空时接收方等待。receive 返回 T?:收到值时返回该值,Channel 关闭且缓存值耗尽后返回 null。Dever 不产生嵌套可空类型,因此 Channel<T?> 的接收结果仍是 T?,发送的 null 与关闭后的空结果都表现为 null。close(channel) 幂等,并唤醒等待中的发送方和接收方;关闭前已经缓存的值仍可读完。Channel<T> 和 AsyncStream<T> 的 T 必须可跨线程传递。
跨挂起点仍存活或进入 run、Channel、parallel、blocking 的普通值必须可跨线程传递。Task 和 Group 是受编译器跟踪的局部仿射值,不能嵌套、传参或逃逸。完全非挂起的入口不初始化 Tokio;推断为挂起的入口创建唯一的有界 Tokio 多线程运行时。默认调度线程数按 CPU 数量确定且最多 64,CPU/阻塞 worker 默认最多 8,同时存活的 run/Group 子任务默认最多 4096,硬上限为 65536。
dever.task.sleep(milliseconds) 是非阻塞定时等待,要求非负毫秒数,成功无输出,失败默认传播;显式捕获使用 dever.time.SleepResult。已有 dever.time.sleep 保持同步阻塞语义;挂起调用链需要同步睡眠时必须使用 blocking(...) 隔离。运行时由生成入口统一启动,业务源码不手工创建或嵌套 runtime。
timeout(task, millis, fallback) 等待任务;millis 必须为正。到期先停止并排空任务,再执行零输入的静态 fallback。fallback 必须与任务的命名输出签名完全相同,支持零个、一个或多个输出。程序故障继续传播,不触发 fallback。race(first_task, second_task, ...) 至少接收两个输出签名相同的 Task,等待第一个完成,再停止并排空其余任务;它也不吞掉程序故障。两者会使调用链挂起,并消费传入的全部 Task。
unavailable() (value: Text) {
value = "请求超时"
}
# lookup 的输出声明为 (value: Text)。
pending = run(lookup())
value = timeout(pending, 1000, unavailable)
first_task = run(lookup_primary())
second_task = run(lookup_secondary())
value = race(first_task, second_task)
stream(channel)、stream(list)、stream(bytes) 创建按需拉取的 AsyncStream;元素必须可传递,Bytes 产生 Int。消费流时由编译器推断挂起。Channel 流先读完关闭前的缓存再结束,发送的 null 仍是一个实际元素。关闭流释放其 Channel 别名,不关闭调用方另持有的 Channel。
dever.task.ticks(millis) 创建 AsyncStream<Int>,周期必须为正;第一个值在一个周期后产生,序号从 1 开始。消费过慢时跳过错过的周期,不积压 tick 或预先创建任务。用 each/reduce_until 消费,关闭流释放定时器。
10. 失败处理
可恢复失败使用 choice 中显式标记的 error 变体。编译器不根据 Failed 等名称猜测错误:
type MessageResult {
Ready(text: Text)
error Failed(message: Text)
}
read_message(bytes: Bytes) (message: Text) {
message = dever.bytes.to_text(bytes)
}
required_port(value: Int < 1) (port: Int) {
fail(MessageResult.Failed("port must be positive"))
}
required_port(value: Int >= 1) (port: Int) {
port = value
}
普通调用默认传播失败,因此 message = dever.bytes.to_text(bytes) 不需要 ?、try 或 await。调用链的错误集合由编译器推导;未处理的失败到达 HTTP、Task 或 CLI 根边界时,由运行时统一回滚事务、记录一次并终止对应执行边界。
需要主动检查成功和全部错误变体时,使用 result(call) 捕获,然后交给穷尽分句:
decode(result: dever.bytes.DecodeResult.Decoded(text)) (message: Text) recover("invalid input is shown to the user") {
message = text
}
decode(result: dever.bytes.DecodeResult.Failed(reason)) (message: Text) recover("invalid input is shown to the user") {
message = reason
}
main() (message: Text) {
message = decode(result(dever.bytes.to_text(dever.bytes.from_text("hello"))))
}
fail(error_variant) 主动产生一个领域失败并立即结束当前调用;只能传入带 error 标记的变体。result(call) 只关闭该次调用的默认传播,捕获结果仍必须被穷尽处理、继续返回或显式恢复。Dever 不提供 exception、throw 或 catch。
错误必须传播、传给同样受检查的处理函数,或转换成明确错误。读取消息、打印日志、覆盖变量或交给空函数都不算处理。主动降级需要声明 recover("具体原因"),同一函数所有分句必须声明相同理由;例如可选本地配置缺失时采用固定默认值。理由必须非空,编译器不判断这项业务策略是否合理。详见第 18 节。
编程错误或不可恢复运行时故障会终止当前执行,并带源码位置;普通 function 不能捕获这类故障。
11. 常用核心操作
无需 package 前缀的核心名称:
Bool Int Decimal Float Text Id Bytes Secret List Map MapEntry Stream AsyncStream Channel
true false null other
each parallel_each reduce reduce_until filter find sum
append first get put remove entries length close
run wait stop group parallel blocking channel send receive timeout race stream
List 与 Map 操作:
extended = append(values, value)
first_value = first(values)
label = get(labels, "env")
labels = put(labels, "env", "development")
labels = remove(labels, "env")
pairs = entries(labels)
size = length(values)
append、put 和 remove 返回新值,不修改原集合。first(List) 和 get(Map, key) 返回可空值。entries(Map) 返回按插入顺序排列的 MapEntry List。length 接受 List、Map、Text 或 Bytes;Text 按 Unicode codepoint 计数。核心 close 接受 Stream、AsyncStream 或 Channel,且没有输出。
12. 标准 package
标准 package 本身也使用 Dever 编写。下表链接到当前公开源码;其中的 public 是编译器随附标准库的内部边界,不是应用源码语法范本:
| package | 已提供的主要接口 |
|---|---|
text |
trim、lower、upper、at、slice、index_of、contains、starts_with、ends_with、split、replace、join、is_empty、Unicode codepoint 转换 |
int |
parse、to_text |
decimal |
parse、to_text、from_int、round |
float |
parse、to_text、from_int |
math |
sqrt、sin、cos、log、pow、Float 分类 |
dever.id |
from_text、to_text |
dever.bytes |
Text/Bytes 转换、from_ints、length、at、slice、concat |
dever.process |
arguments |
dever.crypto |
token、password_hash、password_verify、sha256、hmac_sha256、constant_time_eq |
dever.time |
now、parse_datetime/format_datetime、parse_date/format_date、parse_time/format_time、duration、add、subtract、difference、底层时钟与 sleep |
dever.task |
非阻塞 sleep 和周期 ticks |
dever.io |
标准输出、文件创建/打开/读写/关闭、分块 Stream |
dever.net |
TCP 连接、监听、读写、超时、连接与数据 Stream |
dever.json |
JSON parse、stringify、节点/属性/数组元素访问 |
dever.http |
Hyper HTTP/1、HTTP/2 多路复用、流式客户端和静态 handler |
数值或索引解析失败通常返回可空值;文件、网络、Bytes、JSON、HTTP 和 ORM 的可恢复错误默认沿调用链传播。普通调用返回成功值,result(call) 才产生显式选择型结果;具体以 function 输出类型为准。
UTC 时间和类型化 wire
DateTime 内部是 Unix epoch 有符号整数毫秒;Date 是 UTC 午夜对应的 epoch 毫秒,Time 是午夜起 [0, 86400000) 毫秒,Duration 是有符号毫秒。数据库表示、排序和 Model fingerprint 不随外部格式变化。
dever.time.now() 返回 UTC DateTime。parse_datetime(Text) 接受 RFC3339 及显式 offset,format_datetime(DateTime) 输出 UTC YYYY-MM-DDTHH:MM:SS[.sss]Z;零毫秒省略小数,非零固定三位。输入最多三位小数,拒绝闰秒、子毫秒精度和 UTC 转换后超出四位年份的日期。parse_date/format_date 使用 YYYY-MM-DD;parse_time/format_time 使用 HH:MM:SS[.sss]。日历无效或格式化值不在可表示范围内会失败。
duration(millis: Int) 构造 Duration;add(DateTime, Duration)、subtract(DateTime, Duration) 和 difference(later: DateTime, earlier: DateTime) 使用 checked i64 算术,最后一个返回 Duration。算术溢出显式失败;合法 i64 结果仍可能超出四位日历格式化范围。确定性转换与算术是 pure,读取当前时间有 time effect。普通调用传播失败,显式 result(call) 使用 DateTimeResult、DateResult、TimeResult、DurationResult 或 TextResult,成功分支均为 Read(value),错误分支为 Failed(message)。
共享 wire schema 从已检查类型生成具体编解码器。Bool/Int/Float 使用 JSON 标量;Decimal/Id/Uuid 使用字符串,不经过 Float;ModelId 使用 i64 整数且 schema 保留 Model 的 nominal identity;DateTime/Date/Time 使用上述字符串格式,Duration 使用整数毫秒。支持 List、nullable 和没有 private 字段或字段范围约束的 record。缺失 nullable 字段得到 null,缺失必填字段、未知字段、重复键、错误标量和非有限 Float 均失败;Int 只接受 i64 范围内的整数字面量。Choice、Map、Related 和资源不属于当前 wire 合同。
边界 JSON 最多 16 MiB、64 层容器、65536 个值(含根和容器)。Json 保留验证后的原始 JSON 和数字拼写,不经过动态浮点转换。Secret 仅允许 API/setting 输入策略生成,普通输出和 Job payload 禁止;不同策略具有不同 schema identity。业务函数接收具体类型,边界节点不会传入业务代码。API response、Adapter setting 和 Job payload 复用此 codec;API 输入的后续扩展也必须复用这个 owner。
JSON
JSON 解析使用扁平、带类型的 Document,不产生动态对象。dever.json.parse 直接返回 Document;result(dever.json.parse(...)) 使用 ParseResult 捕获失败。这些公开 type 位于 dever.json.value,契约是:
type Value {
Null
Boolean(value: Bool)
Number(raw: Text)
String(text: Text)
Array(children: List<Int>)
Object(members: Map<Text, Int>)
}
type Document {
root: Int
nodes: Map<Int, Value>
}
type ParseResult {
Parsed(document: Document)
error Failed(position: Int, message: Text)
}
type WriteResult {
Written(text: Text)
error Failed(node: Int, message: Text)
}
调用签名:
dever.json.parse(Text) -> dever.json.value.Document
dever.json.stringify(dever.json.value.Document) -> Text
dever.json.node(dever.json.value.Document, node_id: Int) -> dever.json.value.Value?
dever.json.property(dever.json.value.Document, object_id: Int, name: Text) -> Int?
dever.json.element(dever.json.value.Document, array_id: Int, index: Int) -> Int?
Document 用整数 ID 保存一棵 JSON 树:root 是根节点 ID;nodes 的 ID 从 0 连续递增;子节点 ID 小于父节点 ID。Object 将属性名映射到子节点 ID,Array 保存有序子节点 ID。property 和 element 返回节点 ID,再通过 node 取得 Value;缺失、类型不符或越界时返回 null。JSON 的 null 则是实际存在的 Value.Null,两者不同。
数字保留原始 JSON 文本,避免先丢失精度。解析失败位置是从 0 开始的 Unicode codepoint 偏移,包括输入末尾位置。重复键、非法转义、未配对代理项、多余输入和超过 256 层的嵌套会传播 ParseResult.Failed。手工构造 Document 时,stringify 会检查根节点、连续 ID、子节点引用和输出规模,失败时传播 WriteResult.Failed;两者都可用 result 显式捕获。当前函数和失败类型见 JSON 标准包及 JSON 值类型。
HTTP
dever.http 使用 Hyper HTTP/1 和 HTTP/2 引擎,业务 handler 由 Dever 编译为静态 Rust async 调用。HTTP/1.1 支持持久连接、流水线、Content-Length 和 chunked;HTTP/2 在同一连接上并发处理多个请求流。
dever.http.default_limits() -> Limits
dever.http.default_http2_limits() -> Http2Limits
dever.http.header(name: Text, value: Text) -> Header
dever.http.header_value(List<Header>, name: Text) -> Bytes?
dever.http.serve(route, dever.system.Listener, Limits) -> ()
dever.http.send(address: Text, port: Int, Request, Limits) -> Response
Request 包含 method: Text、target: Text、headers: List<Header>、body: Bytes;Response 包含 status: Int、headers、body。Header 包含 name: Text 和 value: Bytes,因此重复头和非 UTF-8 头值可以保留。接收到的名称为小写,列表保留同名头的值顺序,不承诺不同名称之间的原始排列。header 构造文本头;header_value 忽略名称大小写并返回第一项,找不到返回 null。
| Limits 字段 | 默认值 | 约束 |
|---|---|---|
| header_bytes | 8192 | 至少 8192;协议解析缓冲区和头部预算 |
| body_bytes | 1048576 | 非负;单条请求/响应正文的上限 |
| timeout_ms | 5000 | 正数;分阶段服务端期限,客户端单次请求总期限 |
| connections | 32 | 1–65536;服务端并发连接数或每个客户端池的连接上限,另受任务资源约束 |
| http2 | null | null 使用 HTTP/1;Http2Limits 使用 HTTP/2 |
开启 HTTP/2 沿用原来的服务和客户端函数:
http_limits() (limits: dever.http.Limits) {
limits = dever.http.default_limits()
limits.connections = 2
limits.http2 = dever.http.default_http2_limits()
}
| Http2Limits 字段 | 默认值 | 约束 |
|---|---|---|
| streams | 16 | 每连接并发流上限;connections × streams 不超过 65536,另受运行时任务容量约束 |
| stream_window_bytes | 65535 | 单流接收窗口,1–2147483647 |
| connection_window_bytes | 262144 | 连接总接收窗口,65535–2147483647 |
HTTPS 的双方配置必须协商 ALPN h2;明文双方使用 HTTP/2 prior knowledge,不发送 h2c Upgrade,不自动降级协议。窗口固定,不自动扩大;帧和引擎发送缓冲设置为 16 KiB。窗口限制协议接收流量,不是整个进程内存上限:已收集的请求正文、每流待发块、TLS/系统缓冲和应用持有的值还需要内存。小机器应一起设置 connections、streams、body_bytes 和 chunk_bytes。
HTTP/2 的 Request.target 仍为 /path?query 或 OPTIONS *。引擎负责伪头;入站 authority 在没有 Host 时规范化为 Host,已有 Host 必须与 authority 一致。应用不能传入 Connection、Keep-Alive、Proxy-Connection 等逐连接头字段;请求 TE 只允许 trailers,响应不允许 TE。单流中止不关闭相邻流;连接级故障影响该连接的全部流。
serve 和 send 的正文在限额内完整缓冲;流式服务使用下节的 serve_live。单帧正文共享字节存储,多帧逐步合并,不为每个微小帧积累元数据队列。HTTP 引擎负责报文定界;应用不得传入 Content-Length、Transfer-Encoding 或 Upgrade。响应状态为 200–599,204/205/304 的正文必须为空;HEAD 由引擎抑制响应正文。HTTP/1 接收头数量沿用 Hyper 默认上限 100,HTTP/2 使用解压后的头列表字节预算;发送头预算为引擎生成的头预留 80 字节。
route 的签名为 handler(request: dever.http.Request) (response: dever.http.Response);是否挂起由 route 的调用图推断:
route(request: dever.http.Request) (response: dever.http.Response) {
response = dever.http.Response {
status = 200
headers = [dever.http.header("Content-Type", "text/plain; charset=utf-8")]
body = dever.bytes.from_text(request.target)
}
}
start(listener: dever.system.Listener) () {
dever.http.serve(route, listener, dever.http.default_limits())
}
在 Adapter 中先用 dever.net.listen(...) 创建 Listener,再调用 serve;编译器会自动等待网络结果。serve 持续接收连接,可作为 server = run(dever.http.serve(...)) 启动,再用 stop(server) 取消并等待子任务清理。调用者持有的 Listener 别名仍由调用者关闭。普通应用的 HTTP 入口用 模块 API 声明,无需自行编写服务启动函数。
- 服务端先预留任务容量,再接受连接;没有无界应用队列或每连接独占线程。每条 HTTP/1 连接按顺序执行 handler。
- 握手超时或底层 I/O 错误关闭该连接;正文超时返回 408,handler 超时返回 504,正文超限返回 413。HTTP/1 随后关闭连接,HTTP/2 结束对应流。写入停滞超过期限关闭连接。
- 期限和取消在异步挂起处生效;已开始的 blocking 操作仍遵守既有“等待完成”的契约,需要操作自身的超时。
- 对端协议错误影响对应流或连接。无效服务配置、accept 故障、handler 程序故障及无效响应沿 serve 的任务作用域传播,并保留 Dever 故障位置。
- send 为单次请求,驱动 future 随请求一起完成或取消;需要复用连接时使用下文的 client/request。成功返回 Response,网络/协议/输入失败默认传播;通过
result捕获时为ResponseResult.Read(response)或Failed(message),不自动重试。 - send 的 address 是数值 IPv4/IPv6,port 为 0–65535;请求必须有一个 Host 头。target 使用
/path?query,或 OPTIONS 的*,不接受代理 absolute-form 或 CONNECT。 - 普通
serve/send不接受升级;WebSocket 使用下节的 HTTP/1 专用入口。HTTPS 使用下文的 TLS 服务和客户端;当前不支持 CONNECT、非空 trailer、服务器推送或 WebSocket-over-HTTP/2。
流式 HTTP 与 SSE
serve_live 的 handler 是 handler(request: Request, reply: dever.system.HttpReply) ()。它可以发送普通响应、流式正文和 SSE;HTTP/1 还可升级为 WebSocket。handler 内部是否挂起由编译器推断。HTTP/1 每连接最多一个活跃 handler;HTTP/2 每请求流独立拥有 handler 和清理范围,并受 streams 限额约束。
dever.http.default_live_limits() -> LiveLimits
dever.http.serve_live(route, Listener, Limits, LiveLimits) -> ()
dever.http.respond(HttpReply, Response) -> ()
dever.http.start(HttpReply, status: Int, List<Header>) -> ()
dever.http.write(HttpReply, Bytes) -> ()
dever.http.finish(HttpReply) -> ()
dever.sse.start(HttpReply, List<Header>) -> ()
dever.sse.send(HttpReply, Event) -> ()
LiveLimits 默认 chunk_bytes = 65536、idle_ms = 60000、heartbeat_ms = 15000;块大小必须为正,0 < heartbeat_ms < idle_ms。Limits.body_bytes 仍约束请求正文和 respond 的完整正文,流式输出按块限制,不累计整条长连接的字节数。响应通道仅容纳 1 块;还需计入当前写入、协议引擎、操作系统及应用自身的缓冲,不代表每条连接总共只用一块内存。
响应只能开始一次。respond 提交完整响应;start 提交流式响应头,随后 write 按调用顺序发送,finish 结束正文。204/205/304 使用 respond。重复开始、超大块、关闭后写入、向 SSE 通道直接写普通正文均传播 Failed,可用 result 捕获。写入等待受 Limits.timeout_ms 限制;对端停止消费时不会无限排队。
handler 返回会结束正文;正文被消费完或丢弃、HEAD 抑制正文、连接断开或 stop(server) 会取消相应 handler 并等待其子任务清理。已经交给通道的块不因取消而自动重发。HttpReply 是线程安全资源别名,不能在所属会话结束后继续使用。handler 没有提交响应就返回属于程序故障;提交响应头之后的程序故障会截断正文并沿服务任务传播。
首个响应头沿用普通请求期限;提交流式头之后不再使用整段 handler 的 5 秒期限。普通流在 idle_ms 内没有输出会关闭;SSE 在空闲 heartbeat_ms 后输出注释心跳。心跳不占用额外任务;客户端不读取时仍受 HTTP 停滞写入期限约束。
dever.sse.Event 字段为 event: Text、data: Text、id: Text?、retry_ms: Int?。空 event 使用浏览器默认事件名;data 按 CR/LF/CRLF 分行且保留尾部换行;空 id 会重置浏览器的事件编号。event 禁止换行,id 禁止换行和 NUL,retry_ms 非负。编码后的完整事件必须放入一块,心跳要求块上限至少 3 字节。sse.start 持有 Content-Type,并在应用未指定时添加 Cache-Control: no-cache。应用从请求 Last-Event-ID 头读取重连位置,负责历史保存和重放;库不缓存历史事件。
WebSocket
dever.websocket 复用 tokio-tungstenite 的 RFC 6455 握手、掩码、分片重组、UTF-8 校验和关闭协议,不使用自写帧解析器。
dever.websocket.default_limits() -> Limits
dever.websocket.accept(HttpReply, Limits) -> dever.system.WebSocket
dever.websocket.connect(address: Text, port: Int, target: Text, Limits) -> dever.system.WebSocket
dever.websocket.open(url: Text, ClientTls, Limits) -> dever.system.WebSocket
dever.websocket.send(WebSocket, Message) -> ()
dever.websocket.receive(WebSocket) -> ReceiveState
dever.websocket.messages(WebSocket) -> AsyncStream<MessageEvent>
dever.websocket.close(WebSocket, code: Int, reason: Text) -> ()
连接成功直接得到 WebSocket;用 result 捕获时使用 ConnectResult,分支为 Connected(socket) 和 Failed(message)。Message 包含 Text(value: Text)、Binary(value: Bytes)、Ping(value: Bytes) 和 Pong(value: Bytes)。receive 直接返回 ReceiveState.Read(message) 或正常关闭的 End,错误默认传播,捕获时使用 ReceiveResult。messages 在正常关闭时结束,故障产生最后一个 MessageEvent.Failed。持续接收可使用 each 或 reduce_until,无需循环或递归语法。
WebSocket Limits 默认 message_bytes = 1048576、idle_ms = 60000、write_ms = 5000,均为正数。message_bytes 限制完整数据消息及分片累计;控制帧最多 125 字节。每连接读取缓冲区为 4 KiB,协议写缓冲有上限。方向锁允许同时读写;idle_ms 是一次 receive(含等待方向锁)的期限,write_ms 约束一次写入、客户端握手或关闭握手。超时读取会关闭资源;已开始写帧时取消会关闭连接,尚在等待写锁的取消不影响正在写入的另一个任务。
客户端 connect 的 address 是数字 IP,target 为 /path?query。open 接受完整的 ws:// 或 wss:// URL,支持 DNS,WSS 使用传入的 ClientTls 校验证书和目标名称;不接受 URL 用户信息或 fragment。握手期限包含域名解析、TCP 和 TLS,不自动重试。服务端 accept 从当前 HTTP 请求升级,失败时应用仍可返回普通错误响应。升级后连接仍占服务端名额,并由原 handler 作用域拥有;handler 结束或父任务停止都会关闭它,即使其他地方还持有别名。
应用需持续调用 receive/messages 才能读取协议帧;收到 Ping 时引擎先发送 Pong,再交给应用。库不建立额外的定时 Ping 任务,可通过 send(Ping(...)) 实现应用保活。close 校验合法状态码和最多 123 UTF-8 字节的原因,等待对端关闭握手;关闭会唤醒等待中的读写。当前不提供关闭帧元数据、子协议协商或压缩扩展。
这些底层操作用于 Adapter,类型和函数定义见 HTTP、SSE和 WebSocket标准包。
HTTP 连接池与流式客户端
dever.http.default_pool_limits() -> PoolLimits
dever.http.client(origin: Text, ClientTls, Limits, PoolLimits) -> dever.system.HttpClient
dever.http.request(HttpClient, Request) -> Response
dever.http.open(HttpClient, Request) -> StreamResponse
dever.http.open_stream(HttpClient, StreamRequest) -> StreamResponse
dever.http.close_client(HttpClient) -> ()
client 返回固定 origin 的共享连接池;需要捕获时使用 ClientResult,成功项为 Ready(client)。origin 如 https://example.com:443,不能含用户信息、业务路径、query 或 fragment;支持 DNS。请求 target 仍用 /path?query,没有 Host 时由 origin 补上。每个目标建立自己的池,不保留不断增长的全局主机表;不自动重放已发送请求或跟随重定向。HTTP/2 连接停止接收新流时,协议引擎确认尚未发送的请求会在原期限内重新排队。
| PoolLimits 字段 | 默认值 | 约束 |
|---|---|---|
| idle_ms | 10000 | 正数;归还的连接闲置多久后关闭,即使没有下一个请求也会回收 |
| chunk_bytes | 65536 | 正数;流式上传块上限及每次下载返回块上限 |
| read_ms | 60000 | 正数;一次上传取块或下载拉取的等待期限 |
Limits.connections 限制物理连接数,空闲和正在排空的连接也计入上限。HTTP/1 的请求占用上限等于 connections;HTTP/2 的请求上限为 connections × streams,每连接另限 streams。每池一个作用域拥有驱动和有界队列;HTTP/2 的协议子任务也归连接作用域。池属于创建它的任务作用域,作用域结束后,即使外部还持有别名,也不能继续使用。直接调用 create_client(...) 会在当前任务内完成;不要通过一个立即结束的 run 子任务创建长期共享的池。
request 完整收集响应,正文受 body_bytes 限制,排队、建连、发送、接收合计受 timeout_ms 限制。open 发送普通 Request 并返回响应头与流;open_stream 的 StreamRequest 把 body 改为 AsyncStream<dever.io.ReadEvent>,其余字段与 Request 相同。成功均为 Read(response: StreamResponse),其中 body 也是该异步流;Chunk(bytes) 代表数据,终止错误最多产生一次 Failed(message),正常 EOF 直接结束。
流式客户端只按需读取,不收集整段上传或下载正文;总长度不限于 body_bytes,单块及等待时间受 PoolLimits 限制。等待请求槽位、DNS/TCP/TLS 和响应头受 timeout_ms 限制,响应流随后按 read_ms 读取,适用于 SSE 等长连接。HTTP 出站长度和分块编码仍由引擎拥有,转发入站响应时须移除 Content-Length、Transfer-Encoding 等受保留的头。
HTTP/1 响应体完全读完才归还连接,提前 close(response.body)、故障或取消会关闭该连接。HTTP/2 的连接可同时服务多个请求;完成或关闭正文释放该流,不中止邻流。仅持有未消费的正文会继续占用请求名额,活跃连接不会被空闲回收。close_client 关闭所有池别名、唤醒等待者并等待驱动清理;下载块共享字节存储,应用保留的块仍计入应用自身内存。
TLS、共享上下文与优雅停机
dever.tls.system() -> dever.system.ClientTls
dever.tls.client(ca_pem: Bytes) -> dever.system.ClientTls
dever.tls.server(cert_pem: Bytes, key_pem: Bytes) -> dever.system.ServerTls
dever.http.serve_tls(route, Listener, Limits, ServerTls) -> ()
dever.http.serve_live_tls(route, Listener, Limits, LiveLimits, ServerTls) -> ()
system 使用随二进制提供的公共 CA;client 仅信任指定 PEM CA,返回 Ready(config) 或 Failed(message)。server 读取应用提供的证书链和私钥 PEM,返回同样形状的结果。TLS 复用 rustls/tokio-rustls,校验证书链、有效期和目标名称;客户端和服务端配置可跨任务共享,并使用有界会话缓存。TLS 服务沿用 HTTP 的握手期限和连接上限;serve_live_tls 提供 HTTPS 流式响应和 SSE,HTTP/1 模式还支持 WSS 升级。HTTP 单独派生 ALPN 配置,不改变其他 TLS 使用者。
需要共享应用状态时,声明具体 Context,并传入系统 serve 原语的可选末尾参数。编译器要求 handler 的最后输入与 Context 类型一致且可跨任务传递;Context 按普通值语义复制,Channel、HttpClient 等资源字段共享同一资源。
type Context {
prefix: Text
requests: Channel<Int>
}
serve(listener: dever.system.Listener, context: Context) () {
dever.system.http_serve(route, listener, dever.http.default_limits(), context)
}
route(request: dever.http.Request, context: Context) (response: dever.http.Response) {
send(context.requests, 1)
response = dever.http.Response {
status = 200
headers = []
body = dever.bytes.from_text(context.prefix + request.target)
}
}
http_serve_live、http_serve_tls 和 http_serve_live_tls 也接受这个末尾参数;live handler 的输入顺序为 request、reply、context,TLS 配置位于 context 之前。这里直接绑定静态 handler,不引入闭包或动态路由解释器。
优雅停机先调用 dever.net.close_listener(listener) 并处理结果,再等待 server Task。HTTP 停止接收新连接,HTTP/2 同时发送 GOAWAY,已开始的响应继续完成;WebSocket/SSE 会话仍由现有 handler 拥有。客户端收到 GOAWAY 后不再向该连接派发新请求,也不自动重放已发送请求。使用 timeout(server, grace_ms, expired) 给排空设置上限,expired 是零输入、零输出的静态 handler;到期先停止剩余任务并等待清理。需要立即停止时使用 stop(server)。自动 API 服务的启动与停机由核心负责,配置见 模块 API。
模块 API
普通应用由编译器从 API、CMD 和 Job 声明生成入口。没有命令参数时,run 和编译产物按 config/setting.json 的 runtime mode 启动可用服务;CMD 必须显式传名称和一个 JSON 对象,不能借 CMD 参数改变部署模式。run/build 拒绝手写 module/main.dever。
module/user/account/app.dever:
type Greeting { message: Text }
greet(name: Text) (greeting: Greeting) {
greeting = Greeting { message = "你好," + name }
}
module/user/account/api.dever:
get greeting = app.greet
public post greeting = app.greet
cmd greeting = app.greet
前两项对应 GET、POST /user/account/greeting;CMD 名称为 user.account.greeting,输入是单个 JSON 对象,输出同样使用 {"code":0,"message":"ok","data":...}。HTTP 声明默认要求认证,只有明确的登录、健康检查等单条 HTTP 声明可加 public;public cmd 和 public rest 非法。public 入口忽略浏览器自动携带的旧 Cookie,使过期会话不会阻止重新登录;客户端显式提交的无效 Authorization 仍返回 401,不能降级成匿名请求。get/post/put/delete <action> = app.<function> 只绑定本领域无歧义的 App 函数,继承其输入输出签名,输出名不必叫 response。GET 的完整调用链不能写数据库或入队 Job。普通写 HTTP/CMD/Job 由入口建立整体事务;涉及外部 I/O、Worker、等待、并发或密码计算的写入口必须显式加 long,并将写库与入队放进 transaction 函数。long 不建立入口级事务,已提交阶段不因后续失败撤销;写入时须在事务内重查账户、租户或成员状态。编译器只验证声明,不根据调用图自动切换事务模式。API role 不写函数体或旧 get_ 前缀。GET/DELETE 从 query 读取标量输入,POST/PUT 从 Content-Type: application/json 的对象正文读取;命令也读取严格 JSON 对象。缺少必填值、额外或重复字段均拒绝。输入输出使用同一静态 wire codec,当前不支持 Choice、Bytes 等类型。
简单领域使用 api.dever;复杂领域可同时使用 api.dever 与 api/<scope>/<resource>.dever,topic 依次进入 URL,例如 module/user/account/api/admin/session.dever 中的 get status = app.status 对应 /user/account/admin/session/status。sites.<key>.path 匹配这里的 API 父目录:admin 匹配 api/admin/**,不匹配同级文件 api/admin.dever,也不改写 URL。每条 HTTP API 必须且只能归属一个站点;目录遗漏、path 前缀重叠和同一路径同方法重复都会在运行前报错。rest 可为同领域唯一 Model 生成 GET 列表/详情、POST、PUT、DELETE;多 Model 时写 rest model 或 rest model.<topic> 消歧。列表只接受有界 page、size 和至多一个显式 search 条件,不开放任意查询。自动 REST 只序列化公开字段;private owner user_id: ... = dever.auth.user_id() 在创建时写入可信身份,并把同一 owner 条件放入全部读写 SQL。客户端不能提交 owner 或无 input 的服务端字段。需要不同资源策略或跨 Model 编排时仍使用 App,不把业务规则塞进 REST 声明。
HTTP 绑定的 App 调用链可用 dever.api.request_id()、method()、path()、client_address()、header(name)、cookie(name)、secret_cookie(name) 读取请求元数据,并用 set_header(name, value)、set_cookie(name, value, options)、set_secret_cookie(name, secret, options) 设置响应元数据;无需传递 Context 参数。client_address() 只返回直接 TCP peer 的地址,不信任转发 Header。secure_cookie_options() 提供 Path=/、SameSite=Lax、Secure、HttpOnly 的起点,业务可构造 CookieOptions 调整 Domain 和 Max-Age。Secret Cookie 使用 v1. 加无填充 base64url 存储原始 Secret 字节,读写不会把 Secret 转成业务 Text;非此格式的值读作缺席。敏感请求头不能经 header 读为 Text,Secret Cookie 必须使用 Secure 与 HttpOnly;响应 Header 不允许覆盖 Set-Cookie、Content-Type、Content-Length 等运行时字段或注入换行。Header/Cookie 先暂存在请求作用域,只有 handler 成功并且响应序列化成功后才发布;失败会丢弃待发布元数据;普通入口回滚未提交事务,long 入口不会撤销先前已经提交的 App 事务。Cookie 写请求和使用 Cookie 认证的非安全方法必须携带 Origin,并要求 Origin 与 Host 都精确匹配 sites.<key>.origin 配置的外部 HTTP/HTTPS scheme、规范化主机和有效端口;运行时不从内部明文监听协议猜外部 HTTPS,也不信任转发 Header。CMD/Job 调用链不能使用 HTTP 请求能力,脱离请求的并发任务也不能调用这些能力。
POST App 可以直接接收一个顶层 Upload 及普通标量,此时请求必须是 multipart/form-data。Runtime 按 http.upload 的总大小、单文件、字段、part、header 和 filename 上限流式写入 data/tmp;filename 仅作展示值,不能成为路径。Upload 是 affine 请求资源,不能嵌套、复制、作为输出或进入 GET/CMD/helper;App 可读取 metadata 后显式 close_upload,或一次性传给同领域 Port,再由 Adapter 调用 dever.storage.put。未消费资源及解析失败、handler 失败和取消路径会清理临时文件。
监听、站点和认证 Provider 都只在 config/setting.json 配置:
{
"http": { "host": "127.0.0.1", "port": 8181 },
"auth": {
"providers": {
"session": {
"verify": "user.account.verify",
"jwtSecret": "replace-with-a-deployment-secret-of-at-least-32-bytes",
"ttlSeconds": 3600,
"cookie": "cms_session"
}
}
},
"sites": {
"admin": {
"path": "admin",
"auth": "session",
"hosts": ["admin.example.com"],
"origin": "https://admin.example.com"
},
"front": {
"path": "front",
"auth": "session",
"hosts": ["www.example.com"],
"origin": "https://www.example.com"
}
},
"log": { "level": "info" }
}
Provider 使用固定 HS256 token,校验算法、签名、有效期、Provider、站点、subject 和 session;密钥只放部署配置,不进入源码。Bearer 和配置指定的 Secure Cookie 二选一,不能同时提交;使用 Cookie 认证的非安全方法以及任何 Cookie 写入都必须通过上述精确同源检查。需要浏览器 Cookie 的站点必须配置 origin;它是部署入口的外部 origin,不是内部监听地址。Provider 的 verify 必须引用一个公开普通 App 函数:输入为 dever.auth.Claims,输出为该 App 自己定义的公开身份 record,字段严格且只能是 id: Text、user_id: <ModelId>?、tenant_id: <ModelId>?。verify 只负责验证账号、session、成员和租户关系;它只能读 global Model,不能返回权限列表、读取原始 HTTP 请求、再次读取身份、写数据库或入队 Job。
身份建立后,App 可读取 dever.auth.id()、session()、user_id()、tenant_id() 与 dever.site.key();dever.auth.owns_user(model_id) 用于显式资源所有权比较。所有非 public HTTP API 都在读取业务输入和调用 App 前自动校验精确权限 component.domain.site.action;REST 固定生成 read/create/replace/delete,HTTP method 只是目录元数据,同一权限键不能重复声明。源码没有 auth.require,也不能自报权限。登录使用 dever.auth.issue_cookie(subject, session, tenant),登出使用 dever.auth.clear_cookie();两者自动采用当前 Provider 在 setting.json 中配置的 Cookie 名和 TTL。
user_id()、tenant_id() 默认返回 Int?;当赋值或函数参数明确期待某个 model.id? 时,返回对应的可空名义 ID,不需要再次查询会话表。编译器逐条检查受保护 API 的调用链和自动 REST 字段绑定:该 ID 必须与当前站点 verify 身份 record 的对应字段类型一致,owns_user 的参数也必须匹配其 user_id。这不是通用的 Int 到 ModelId 转换,非空 ID 仍须通过 null 分支处理取得。只有源码、没有部署配置的检查延后 Provider 匹配;配置检查和构建必须完成匹配。public、CMD、Job 和普通应用测试不会因此获得身份读取能力。
核心在控制库维护全局 _dever_permission 目录,服务启动时同步当前编译产物并把已删除权限标为 inactive。角色、角色权限和用户角色存放在当前身份对应的授权库:租户用户使用租户库,平台用户使用控制库。App 可通过 dever.auth.permissions()、save_role(...)、grant_role(...)、revoke_role(...)、disable_role(...) 实现很薄的后台管理适配;当前站点和租户由隐藏上下文确定。这些管理能力只能由受保护的写 API 到达,public 和 GET 调用链会被静态拒绝。新租户迁移完成后使用 dever tenant owner <root> <tenant-id> <site> <user-id> 建立保留的全权限 Owner,普通 save/grant/revoke/disable 均不能修改该保留角色。底层 issue 与通用 Cookie API 保留给非浏览器协议组合。身份、租户和权限都不接受客户端参数或 Header 自报,Domain 保持纯业务规则。dever.api.Error 的 Invalid、Unauthorized、Forbidden、NotFound、Conflict、TooManyRequests 使用固定安全消息映射到 400/401/403/404/409/429;其它失败只记录脱敏日志并返回通用 500。
只有 CMD、没有 HTTP 声明的应用无需 http、sites 或 auth 配置。正常响应使用 HTTP 200,正文为 {"code":0,"message":"ok","data":...};错误时 code 与 HTTP 状态一致且 data 为 null。多租户只有 database 隔离,不存在 mode 开关:tenant.database 选择控制连接,global type 使用平台库,其他持久化 Model 和 Job 使用可信身份或显式 dever tenant migrate 选择的租户物理库。缺少租户、租户未迁移、schema fingerprint 过期都会失败,绝不回退平台库。租户组件从 tenant Model 和 tenant Job 自动推导;混合组件整体按租户组件处理,它拥有的全部 Job 使用租户队列。public API 不能触达租户组件,API/CMD/Job 执行前统一检查组件状态。运维通过 dever tenant component disable|enable <root> <tenant-id> <component> 控制租户功能,平台组件或未知组件不能切换。
13. Model、ORM 与数据库
13.1 Model 文件与字段
Model 同时支持 .dever 和 .dever.md,复合扩展名不影响类型名或表结构。Markdown 中的 Seed、索引、关系、迁移和 SQL 声明按 Markdown 规范分别说明;CMS 的两套实现位于 examples/cms/dever 与 examples/cms/md。
简单领域在 <component>/<domain>/model.dever 中定义一个与 domain 同名的 Model;复杂领域可使用 model/<topic>.dever,记录名与 topic 对应。文件继续使用普通 record type,不增加 model 或 public 关键字;主记录、choice 和生成的 ID 类型由 Model role 自动提供。以下 user/account/model.dever 对应 user.account.model.Account:
type AccountStatus {
Active = "启用"
Disabled = "禁用"
}
type Account {
uuid: Uuid generated unique
email: Text(254) unique
display_name: Text(1, 64)
status: AccountStatus default AccountStatus.Active index
organization_id: organization.team.model.id?
}
index(status, created_at)
每个 Model 自动增加只读 id 和 created_at: DateTime。id 的类型是该 Model 专属的 <model-role>.id,例如 user.account.model.id;不同 Model 的 ID 不能混用。created_at 和 generated 字段不出现在创建输入中。
Model 字段支持 Bool、Int、Float、Decimal(P, S)、Text、Bytes、Uuid、DateTime、Date、Time、Duration、Json、无载荷 choice、Model ID,以及它们的可空形式。Text(max)/Bytes(max) 指定最大长度,Text(min, max)/Bytes(min, max) 同时指定最小和最大长度。结构化值不会自动序列化为列,需要时显式使用 Json。
默认值和 Seed 共用编译期存储校验:Int 必须是 i64 范围内的整数,Float 必须有限,Decimal 精度和小数位不能溢出,Text 长度按 Unicode 标量计数。非法常量不会等到生成 Rust 或访问数据库后才报错。
generated当前用于由 ORM 生成 UUIDv7 的非空Uuid。default接受与字段类型匹配的静态常量或无载荷 choice 变体。index和unique定义单字段索引;顶层index(a, b)、unique(a, b)定义复合索引。target.model.name.id字段自动成为外键并自动建立普通索引,但不会自动加载目标记录。- choice 的右侧 Text 是显示名称;数据库保存左侧稳定变体名,
Choice.options按声明顺序提供选项 Map。
自动 REST 的字段来源直接写在 Model 字段同行,不改变 App 中的普通 ORM:create = expr 只处理 POST,replace = expr 只处理 PUT,search = expr 处理可选 GET 列表筛选。表达式引用 input 时读取客户端同名值并做纯转换;不引用 input 时由服务端上下文提供,客户端同名字段会被拒绝。表达式只能调用同领域 pure Domain、无 effect 标准函数或精确白名单的只读 request/auth/site 能力,不能访问 Model、Job、时间、响应修改或并发。search 最多一个,必须是公开、非空、可索引 wire 标量;owner 与 search 同时存在时自动生成复合索引。
13.2 CRUD、条件和分页
编译器为 Model 生成唯一一套短函数,不需要手写 Repository 或第二套 CRUD:
created = model.create({
email = "reader@example.com"
display_name = "Reader"
})
page = model.list({
where = status == model.AccountStatus.Active
and (display_name == "Reader" or display_name == "Admin")
order = [created_at.desc, id.desc]
page = 1
size = 20
})
changed = model.update(created.id, {
status = model.AccountStatus.Disabled
})
可用操作为 create、create_many、get、first、list、cursor、count、exists、stream、update、delete 和 upsert。这些操作只能在所属领域 App 内以 model.<operation>(多 Model 时为 model.<topic>.<operation>)调用;main、API 和其他领域必须调用该领域 App。创建、更新和查询块只能直接出现在对应调用位置,由目标 Model 提供字段命名空间;它们不是运行时 Map 或可保存的查询对象。
where 支持 and、or、not、括号、比较、null,以及 in、between、contains、starts_with、ends_with。查询结构、字段和排序全部在编译期确定,运行值始终通过数据库参数绑定。
list 默认第一页、默认 20 条,返回包含 items/page/size/total/pages 的 Model Page;cursor 返回 items/next/has_more 且不执行 count;stream 按行拉取并保持有界内存。配置中的 max_page_size 同时限制分页、关系子列表和原生 SQL 多行结果,不提供无界 all()。
13.3 关联
外键字段会合成同名去掉 _id 的 to-one 关系。反向 to-many 在目标 Model 文件中显式声明:
relation articles = news.article.model.author_id
查询只有写入 with 才加载关系:
accounts = model.list({
order = [id.asc]
with = [organization, articles]
})
关系字段使用 Related<T> 的 Unloaded/Loaded(value) 两态,业务代码通过分句穷尽处理。to-one 使用受控 JOIN;to-many 按当前父结果批量查询并归组,不逐条查询。with 只接受一层静态关系名,并只用于 first、list 和 cursor;stream 不加载关系。多对多关系使用显式中间 Model。
13.4 事务和数据库绑定
事务是专用 function 声明,不向业务代码暴露 Database、Transaction、begin、commit 或 rollback:
transaction register(email: Text, display_name: Text) (account: model.Account) {
account = model.create({
email = email
display_name = display_name
})
}
编译器沿完整调用图推导数据库 effect。普通写 HTTP、CMD 和 Job 在入口建立整体事务,响应编码成功后才提交;同一入口内的显式 transaction 复用外层事务。long 入口不建立整体事务,业务写库和持久入队必须跨入显式 transaction;自动 REST 写操作保持原来的事务边界。最外层 transaction 成功时提交;error、fault 或取消时先回滚当前事务再传播原失败。同一连接的嵌套 transaction 复用外层事务。无数据库操作、跨连接,以及事务内的并发、流式游标、HTTP、文件传输、外部 Worker 和密码计算都会在构建前被拒绝。blocking(call()) 仅在目标不包含这些操作并满足原有非挂起、无数据库约束时可用。事务外可以等待或编排多个独立事务;写入前需在事务内重新核验状态。long 中先前已经提交的事务不会因为后续阶段或响应序列化失败而回滚;重试的幂等性、失败状态和外部产物清理由业务定义,数据库回滚不撤销外部调用。
long 只修饰 HTTP、CMD 或 Job 入口,不修饰 App 函数、transaction 或 rest;与 public 同用时写 public long。声明形式如下(App 与请求类型按各自领域定义):
post create = app.create
long post generate = app.generate
long cmd generate = app.generate
public long post login = app.login
Job 保留原来的重试和超时声明:
long job generate(input: app.Request) () retry(3) timeout(30000) {
app.run(input)
}
long 表示入口不自动持有整体事务,不代表允许长事务、自动异步、自动延长超时或按运行秒数分类。普通自动事务中出现外部操作,或 long 调用链存在事务外写库,均在编译期报 C014;仅添加外部调用不能静默改变原子性。GET 即使声明 long 也不能写库或入队。
数据库错误默认传播,保留 dever.database.Error 的类别和消息:Pool、PoolExhausted、Connection、Timeout、Cancelled、Database、Constraint、NotFound、InvalidData、Migration。要主动捕获一个返回 Account 的 App 调用,可以声明下面的结果类型;result(load_account(...)) 的成功载荷和完整错误集合必须与调用一致。
type ReadResult {
Found(value: user.account.model.Account)
error Failed(error: dever.database.Error)
}
Failed 包装原始错误 choice,事务回滚和任务等待不会把它改成普通 fault。捕获后仍须按语言规则传播、返回或通过明确的 recover 策略处理;错误不会因读取或记录日志而被视为已处理。启动阶段的配置与迁移失败发生在业务入口之前,仍会阻止进程启动。
Model 默认按 component 名选择连接,例如 user.account.model 优先使用 database.user;该键不存在时使用 database.default。Model 文件需要固定连接时,在类型声明前写:
database report
显式连接不存在会直接失败,不回退 default。普通 CRUD 不接收数据库句柄,也不能按运行时字符串切换连接。
配置顶层存在 tenant 时,普通 Model 默认为租户 Model;平台账号、租户目录等共享数据在声明前写 global type Account { ... }。tenant 只接受 database、max_pools 和 idle_timeout_ms;database 指向保存租户就绪状态、组件开关和全局权限目录的控制连接。SQLite 连接为租户 Model 配置 tenant_directory,PostgreSQL 配置最多 43 个 lower_snake_case 字节的 tenant_database_prefix。业务请求不会自动建库,必须先执行 dever tenant migrate <project-root> <positive-id>。PostgreSQL migration 会通过该连接的配置 URL 幂等执行 CREATE DATABASE <prefix>_<tenant-id>,部署角色必须具有建库权限;随后才连接租户库并迁移,失败不会写 ready marker。一个事务不能混用 global 与 tenant Model。
13.5 Schema、迁移和 Seed
当前 Model 是目标 schema。启动时 ORM 比较数据库 catalog、内部 history 和二进制中的规范化 schema:空库直接建立最终表;安全且意图唯一的变化自动应用;收紧长度、精度或约束前先检查已有数据。失败不会截断、舍入或删除数据。
简单字段重命名使用 from:
type User {
display_name: Text(64) from nickname
}
已有数据库把 nickname 改名并保留数据;全新数据库只创建 display_name。复杂或破坏性变化使用稳定命名的 migrate,例如:
migrate remove_legacy_code {
drop legacy_code
}
命名迁移成功后只执行一次,已执行内容被修改时拒绝启动。SQLite 需要重建表的变化和 PostgreSQL DDL 都由同一逻辑 schema 计划产生。
需要在结构变化前清理旧数据,或在结构变化后回填数据时,在命名迁移中使用 before、after:
migrate normalize_display_name {
before {
sqlite = "UPDATE user SET display_name = ?1 WHERE display_name = ?2"
postgres = "UPDATE user SET display_name = $1 WHERE display_name = $2"
parameters = ["Editor", "Legacy editor"]
}
after {
sqlite = "UPDATE user SET display_name = ?1 WHERE display_name = ?2"
postgres = "UPDATE user SET display_name = $1 WHERE display_name = $2"
parameters = ["Administrator", "Admin"]
}
}
每块必须声明两个方言和参数列表,只接受一条 INSERT、UPDATE 或 DELETE,不接受多语句、DDL、事务控制和保留的内部表名。值通过参数绑定;参数支持 Bool、Int、Text、null 和指数形式 Float,无类型上下文的小数不隐式转换成 Float。参数序号必须连续且覆盖参数列表。
已有表先验证 catalog 和历史,再按源码顺序执行未应用迁移的全部 before、自动结构变更、全部 after,最后执行 Seed 并记录历史。任一步失败都会回滚该 Model 的数据、结构和历史。全新数据库直接建立最终结构,不执行历史数据变换。已应用迁移的内容和顺序不能改变;变换只能引用当时已存在的表,不保证跨 Model 原子升级。
初始化数据直接写在 Model 文件的无版本 seed 块中:
seed {
{
email = "admin@example.com"
display_name = "Administrator"
status = UserStatus.Active
}
}
每条 Seed 必须完整提供一个唯一键。ORM 只插入缺失记录,不覆盖或删除线上数据;修改既有初始化数据使用命名迁移。schema、迁移和 Seed 在同一连接的事务与启动锁内执行。
13.6 Typed native SQL
内置查询无法清楚表达的复杂读取可以在 Model 文件中声明静态、参数化的原生 SQL:
type UserSummary {
status: UserStatus
total: Int
}
sql summary(status: UserStatus) (summary: UserSummary?) {
sqlite = "SELECT status, COUNT(*) FROM user WHERE status = ?1 GROUP BY status"
postgres = "SELECT status, COUNT(*) FROM user WHERE status = $1 GROUP BY status"
}
两种方言都必须是 Text 字面量。参数按声明顺序绑定,禁止字符串插值、动态列名和动态行 Map。结果可以是具体 Model、同文件中仅供 SQL 使用的私有 record,或同领域 App 定义的 View,并支持可空值和 List。App View 用 app.ArticleView 引用,字段必须公开且为支持的存储标量;不能包含 Secret、嵌套 Model,也不能引用其他领域的 View。SQL 结果普通 record 不接受字段范围约束,数据库解码不能证明这些约束。调用形式与内置操作一致,例如 model.summary(status),并复用当前事务、连接池、statement cache 和具体行 decoder。
GET 允许两种方言均被证明只读的简单自有表投影:SELECT 字段 FROM 自有表,可带字段与编号参数的等值 WHERE(以 AND 连接)、自有字段 ORDER BY 和编号参数 LIMIT。函数、CTE、子查询、其他表或未识别的 SQL 仍按可能写入处理,不能绑定 GET;不会仅凭 SELECT 前缀放行。
13.7 setting.json
数据库配置直接放在 database 对象中:
{
"database": {
"default": {
"type": "sqlite",
"path": "data/db/app.db",
"max_connections": 1,
"max_page_size": 100
},
"report": {
"type": "postgres",
"url": "postgres://user:password@db.example.com/report",
"tls": "system",
"min_connections": 0,
"max_connections": 4,
"max_page_size": 100,
"wait_timeout_ms": 5000,
"io_timeout_ms": 30000
}
}
}
database.default 必须存在。PostgreSQL 的 tls 必须明确为 system 或 disabled,证书校验失败不会自动降级明文;wait_timeout_ms 限制连接池等待,io_timeout_ms 独立限制 SQL、事务和迁移 I/O。两者省略时分别为 5000 和 30000 毫秒,可配置范围都是 1–300000。启动时创建 data/db、data/upload、data/log、data/cache 和 data/tmp;配置缺失或无效时直接失败,不生成默认配置。
14. 文件、TCP 与 Stream
文件和 TCP 操作成功时返回资源或数据状态,错误默认传播。dever.io.open/create 返回 File;read 返回 ReadState.Read(bytes) 或 End;write 和资源 close 成功无输出。用 result(call) 捕获时,分别使用 OpenResult、ReadResult、WriteResult、CloseResult。读取可能短读;文件 create 只创建新文件,不覆盖已有文件。
type CountResult {
Counted(total: Int)
error Failed(message: Text)
}
sum_byte(value: Int, total: Int) (next: Int) {
next = total + value
}
consume(event: dever.io.ReadEvent.Chunk(bytes), total: CountResult) (next: CountResult) {
next = add_chunk(total, bytes)
}
consume(event: dever.io.ReadEvent.Failed(message), total: CountResult) (next: CountResult) {
next = CountResult.Failed(message)
}
add_chunk(total: CountResult.Counted(value), bytes: Bytes) (next: CountResult) {
next = CountResult.Counted(reduce(sum_byte, bytes, value))
}
add_chunk(total: CountResult.Failed(message), bytes: Bytes) (next: CountResult) {
next = CountResult.Failed(message)
}
streaming(result: dever.io.ReadStreamResult.Streaming(stream)) (total: CountResult) {
total = reduce(consume, stream, CountResult.Counted(0))
close(stream)
}
streaming(result: dever.io.ReadStreamResult.Failed(message)) (total: CountResult) {
total = CountResult.Failed(message)
}
opened(result: dever.io.OpenResult.Opened(file)) (total: CountResult) {
total = streaming(dever.io.chunks(file, 4096))
}
opened(result: dever.io.OpenResult.Failed(message)) (total: CountResult) {
total = CountResult.Failed(message)
}
dever.io.chunks(file, limit) 返回同步 Stream;dever.net.chunks(socket, limit) 返回 AsyncStream。显式捕获创建失败时分别使用 dever.io.ReadStreamResult、dever.net.ReadStreamResult。两种流都产生 dever.io.ReadEvent.Chunk;创建时 limit 非正数或源已关闭会直接失败。dever.net.connections(listener) 直接返回 AsyncStream<ConnectResult>。
关闭 File、Socket 或 Listener 的任一别名后,其他别名上的操作会返回失败。close(stream) 是幂等的零输出核心操作:它关闭该 Stream 的全部别名并释放 Stream 持有的源别名,但不会关闭调用方另外持有的 File 或 Socket。Stream 正常结束后所有别名都只会继续得到结束;终止性读取错误最多产生一个 ReadEvent.Failed,然后结束。资源最后一个别名离开作用域时会被释放;需要确定关闭结果时仍应调用对应 package 的 close 并处理它的选择型结果。
TCP 的 connect、connect_timeout、listen、accept、read、write 都会被推断为挂起;直接调用时顺序等待,需要并发时使用 run(...)。port、timeout、close、close_listener、chunks、connections 不挂起。所有网络等待复用程序的 Tokio runtime;Socket 支持同时读写,每个方向按操作串行,关闭会唤醒等待方。
connect 支持主机名,DNS 使用共享有界 blocking 通道,没有连接超时承诺。connect_timeout 要求数值 IPv4/IPv6 和正毫秒数。timeout(socket, milliseconds) 设置随后开始的每次读写操作的期限,包括等待同方向锁及全部写入;读超时不消费后续数据。一次写入已经写出部分字节后,超时、故障或取消会关闭 Socket,绝不自动重发整条消息。尚未写出字节的取消不关闭 Socket。文件操作仍是同步边界,在挂起调用链中使用 blocking 隔离。
关闭 AsyncStream 会结束所有流别名并释放生产者。它不会关闭调用方另外持有的 Socket 或 Listener;需要同时结束这些别名时显式调用 dever.net.close 或 close_listener。停止持有唯一资源别名的消费任务后,该资源随任务清理释放。
15. 完整可运行示例
完整业务示例是 CMS,同时维护普通源码和 Markdown 两套实现。它展示账号、站点、权限、租户隔离、文章编辑与发布、文件上传和定时任务。每套都是独立项目,源码在 module/,测试在 test/。
在仓库根目录检查两套实现:
dever check examples/cms/dever
dever check examples/cms/md
dever fmt examples/cms/dever --check
dever fmt examples/cms/md --check
dever test examples/cms/dever --filter news.article.publish
dever test examples/cms/md --filter news.article.publish
这些测试使用临时 SQLite,不连接部署数据库。实际运行 CMS 会使用部署配置和数据,先按示例说明完成密钥、账号、租户数据库和 Owner 配置。
只学习某项能力时,用随 skill 提供的独立小项目:
| 场景 | 项目 |
|---|---|
| HTTP、自动 REST、身份核验 | http-rest |
| 持久 Job、事务入队、虚拟时钟测试 | jobs |
| Python、JavaScript、Go 接入与 Port fake | external-lib |
各项目的命令和验证范围见 示例指引。新建最小项目使用 dever new,不从历史代码猜语法。
examples/old/ 只保存历史语言回归材料,不是当前应用模板,不能直接按应用执行 check/run/build。其中的 main.dever、package 和旧可见性写法不用于新项目。
16. 语言边界与平台支持
不提供的语法
Dever 用分句、类型化失败和集合操作表达控制流程。以下写法不属于当前语言,检查时会明确报错:
if/else、switch、match和条件表达式;条件永久使用 function 分句。for、while、break、continue和用户递归;重复使用集合/Stream 组合器。- class、继承、interface、method、annotation、macro、unsafe 和进程内裸指针 FFI;受管 C ABI 通过隔离的
external execWorker 提供,不新增业务源码中的指针类型。 - lambda、闭包、一般化一等 function、用户泛型 type/function。
- exception、
throw和catch;可恢复错误使用推导式默认传播、result(call)和fail(...)。
当前能力边界
Linux x86_64 已提供签名安装包、机器共享编译服务和配套 skill,也能生成 Linux ARM64 应用。API/CMD、Model、权限、数据库租户隔离、Job、应用测试和外部 Lib 都使用同一套语言合同;运行、构建不隐式下载依赖。
当前限制如下:
- 通用动态 Json 值、Set 和 BigInt;持久化 JSON 使用 Model 的
Json字段,普通 JSON 处理使用dever.json.Document。 - CONNECT、非空 trailer、服务器推送、WebSocket-over-HTTP/2,以及 WebSocket 子协议、压缩扩展和关闭帧元数据。
- table/field 多租户隔离模式,以及按租户业务配置动态选择已编译 Adapter;当前只支持 database 隔离和进程级 Adapter 选择。
- 跨 GPU 厂商的设备验收、macOS/Windows 受管外部程序和完整宿主发行;当前也不提供 ARM64 宿主编译器安装包。GPU 支持不包含驱动安装、设备调度、模型分发或自动放宽容器权限。
- 编译器自举。
未支持的能力明确失败,不使用默认值伪装支持。具体安装与执行条件见 安装指引。
17. 开发建议
- 先把领域状态建模为字段型和选择型 type。
- 函数按完整业务职责命名,分支写成穷尽、互斥的分句。需要表达独立规则或复用时再抽取,不机械拆成碎片函数。
- 需要重复时选择
each、filter、find、sum、reduce或reduce_until;只有副作用可并行且顺序不重要时使用parallel_each。 - 用可空值表达简单缺失;需要区分多种失败原因时定义选择型结果。
- 在系统边界保留 Bytes、资源和显式失败,不隐式吞掉 I/O、JSON 或 HTTP 错误。
- 每次修改后先执行
fmt --check和check,再运行或构建入口。
18. 编译器约束与公开接口
普通字段型 type 可以直接构造,无需为每个数据类型增加工厂。需要数值不变量时,把范围写在字段上:
type Port {
value: Int >= 1 and <= 65535
}
default_port() (port: Port) pure {
port = Port { value = 8080 }
}
Port { value = 0 } 编译失败。未知 Int 也不能直接放入该字段,先用已有范围分句验证;验证成功分句中可以直接构造。范围同样适用于 choice 载荷、命名输出和 handler 签名中的输入/输出。仅支持 Int、Decimal 的数值范围,不支持任意用户谓词。赋值、字段修改和返回必须继续满足范围。编译器保留复制前的事实,修改原值不会改变副本的事实;遇到不能证明的运算不会猜测安全或自动插入默认值。
真正需要隐藏表示时,使用 private 字段:
type Session {
private key: Text
label: Text
}
当 Session 声明在 App 中时,其他领域可以使用它,但不能读取、修改 key,也不能构造含私有字段的 Session;普通 record 的 private 仍属于声明 package。private 不适用于 choice 载荷。不要给普通数据全部加 private,否则会人为增加构造和读取包装函数。它是源码访问边界,不是敏感数据加密机制。
Model 可以声明 private password_hash: Text(255)。只有同 component/domain 的 App 能读写私有字段,生成 CRUD 正常持久化;增加 private 本身不改变数据库列或迁移 fingerprint。含 private 字段的完整 Model、Page、Cursor、Related 及其嵌套容器不能穿过 App 参数/输出、API JSON、错误载荷或入口渲染。App 返回显式 View;循环 Model 关联不会绕过检查。普通 record 的 private 语义保持不变。
Secret 是独立内置标量,可以传参、赋值和存入 record/容器,但不可比较、拼接、渲染、普通 JSON 输出、作为 Map key、Model 列或错误载荷。App 可接收 Secret,不能输出它。检查器拒绝嵌套泄漏;原生运行时 Debug 固定显示脱敏占位,持有的字节在释放时清零。不提供 Secret-to-Text/Bytes 通用转换。
dever.crypto.token(bytes) 从操作系统生成 16–1024 字节的 Secret;password_hash(Secret) 返回 Argon2id v19 PHC Text,固定内存 19456 KiB、2 轮、1 lane、32 字节结果;password_verify(Secret, Text) 对错误密码返回 false,对损坏/超限 PHC 返回类型化失败。验证先检查资源上限,再执行哈希。token/hash/verify 失败分别使用 TokenResult/HashResult/VerifyResult,成功分支 Ready,失败分支 Failed(message: Text),错误不携带输入。哈希和验证是阻塞操作,挂起调用链使用已有 blocking(...) 边界。
dever.crypto.sha256(Bytes) 与 hmac_sha256(Secret, Bytes) 返回 Bytes;constant_time_eq(Bytes, Bytes) 对相同长度内容进行常量时间比较,长度不是秘密。仅 compiler-owned Test source 提供 secret(Text) 固定输入构造,生产源码没有该入口。所有部署配置仍只来自 config/setting.json。
pure 写在函数输出声明后,禁止文件、网络、时钟、进程参数读取、安全随机源、标准输出、Stream 操作和并发等效果。检查沿函数调用和具名 handler 传递;调用具体纯 handler 可以通过,声明为 pure 的泛化函数不能调用未限定效果的 handler。纯函数仍可能产生算术 fault,pure 不承诺不会失败。
领域之间不声明依赖清单。源码通过 App 名称引用跨领域类型、函数和 handler;编译器根据这些真实引用自动建立依赖图并检查循环。新增或删除引用只修改实际使用位置,不需要同步维护另一份声明。
主动恢复的例子:
configured_or_default(result: domain.PortResult.Ready(port)) (value: domain.Port) pure recover("optional local configuration uses port 8080") {
value = port
}
configured_or_default(result: domain.PortResult.Failed(message)) (value: domain.Port) pure recover("optional local configuration uses port 8080") {
value = domain.Port { value = 8080 }
}
例中的 domain.Port 是包含 value: Int >= 1 and <= 65535 的 record;domain.PortResult 有 Ready(port: Port) 和 error Failed(message: Text) 两个分支。recover 允许这个函数结束时消费失败义务,应放在实际决定恢复策略的函数上,不能为通过编译给全部函数加同一个声明。
失败义务会跟随 record、choice 载荷和集合。未知集合的局部查找不能证明剩余元素已处理;可能覆盖旧值的 Map 更新也不能静默丢掉旧失败。可用完整遍历处理元素、继续返回原集合,或在真实恢复边界声明策略。空遍历不能消费没有传给 handler 的错误上下文,短路跳过的调用也不能算处理。分析是保守的,未实现任意程序的语义等价证明。
应用接口快照自动收录 App 能力、App 合同类型以及 Model 合同;topic 文件名和其他角色的私有实现不会进入快照。通过 API 基线约束后续变化:
dever api my_app --output my_app/dever.api
dever check my_app
首次生成需要新路径;已存在的文件不会覆盖。check、run、build 自动检查项目根已有的 dever.api,变化时打印增删项并失败。快照记录公开名称、类型、可见字段、构造边界、约束、效果和恢复策略。正常修改私有实现不扩大公开接口。更新基线需要单独生成新文件并审查差异,不会自动“修复”基线。基线必须由项目审查或权限保护;如果允许 AI 同时任意修改源码和基线,它就不是授权边界。未提供基线时不会限制接口变化。
check、run、build 共用应用硬检查 C016:拒绝从生产入口不可达的 App/Domain 函数,以及符合现有判定条件的私有空转发和 Model CRUD 包装。对于生产入口可达的 App/Domain,还拒绝同一源码文件内契约和实现完全相同、没有副作用(包括内部调用)的私有函数,以及参数未变且前一次结果仍可复用的重复纯调用。有副作用、契约不同、参数或前一次结果已被修改的情况不按这两条重复规则拒绝。检查先于 Worker 和运行库准备,check 保持静态、离线。test 按自己的测试入口检查,不套用生产应用硬检查,测试调用不能使生产死代码通过 check。
编译器还会建议清理有证据的重复范围判断(W002)、相同私有实现(W003)、可复用的纯调用(W004)和没有增加契约的私有转发(W005)。未使用赋值沿用 W001,不叠加第二套规则。指定应用入口时,W007 提醒没有外部使用需求的公开函数;普通库检查不推测未知调用方。这些警告本身不改变执行、不作为硬错误;上述应用检查会另外拒绝符合 C016 判定条件的重复和转发,普通库和测试保留警告。CLI 已用相同主位置和关联源码证据报 C016 时,不再重复显示对应 W003/W004/W005;其他警告保留。检查不按行数或函数个数强迫抽象。官方源码执行同样的契约检查,CLI 默认只展示当前用户源码的建议;官方硬错误不会隐藏。
契约分析有明确的工作上限(当前每轮相应证明最多 16,384 个工作单元,名义类型嵌套最多 128 层)。超大嵌套失败结构或事实证明超过上限时报 C015,不会跳过检查后放行。共享结构使用缓存与写时复制,普通重复引用不会直接展开成指数数量的副本。
这些约束能拒绝确定的违规,并提示部分冗余,不能证明一个功能有业务价值,也不能完全杜绝过度设计。