docs(spikes): Deno 2.9.7 + TS 7 compatibility spike and ADR-004

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:24:08 +08:00
1 parent fe3613ee8f
commit cf85481d95
147 files changed
+15213

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
# ADR-004:运行时切换为 Deno 2.9.7(锁定)+ TypeScript 7.0.2
- 状态:提议(2026-09-27,Deno 准入 spike 完成后)。采纳后替代 ADR-001,ADR-001 的状态措辞由协调者修改。
- 关联:REQ §9、§10.2;plan §1;验证记录 `docs/spikes/deno-2.9.7.md`;代码 `spikes/deno/`
## 背景
用户要求运行时改成 Deno 最新稳定版并锁定版本,同时语言版本用 TypeScript 7.0.2;前端继续用 Vite 7 + React Router 7。ADR-001 当初选 Node 有三条理由:本机没有 Deno、依赖以 Node 为主要目标、工具链沿用 uhd_web。这次 spike 逐项核对了这三条:Deno 2.9.7 已装在 `/usr/local/bin/deno`;OpenWorkflow、Pi、Hono、Kysely/pg、pino、vitest、Vite/React Router 在 Deno 下的语义和 Node 一致;真实仓库 7 个包加 server 的测试和类型检查也都跑过。
## 决定
1. **运行时:Deno 2.9.7**,本机、CI、镜像三处锁同一版本。镜像用 `denoland/deno:2.9.7`,已确认有 amd64 和 arm64。依赖统一用 `deno install` + `deno.lock` 管理,彻底去掉 pnpm。
2. **语言版本:TypeScript 7.0.2**,由 `npm:[email protected]/tsc` 提供。它是 Go 原生实现,入口名就叫 `tsc`,包里没有 `tsgo`。它是 CI 的**类型门禁**。另外加一道 `deno check`,覆盖 Deno 专有的解析:import map、`jsr:`、`Deno.*` 全局。这一道同时也是 `deno compile` 实际使用的检查,它内置的 TS 是 6.0.3。
3. **依赖目录 `nodeModulesDir: "auto"`。** Vite、React Router、vitest、tsc 7 都要从 node_modules 解析依赖,所以必须保留这个目录。
4. **workspace 成员同时放 `deno.json` 和精简的 `package.json`。** deno.json 负责 name/exports/tasks;package.json 只写 name/type/exports,作用是让 `deno install` 把成员软链到 `node_modules/@umt/*`,这样 Vite、vitest、tsc 7 不需要配置 alias 或 paths 就能解析。npm 依赖的版本**只在根 `deno.json` 的 `imports` 里登记一次**,相当于统一目录。
5. **不改动的部分:** Hono 4(入口换成 `Deno.serve`)、Kysely 0.29 + pg 8(业务库)、postgres.js(只给 OpenWorkflow 用)、pino 10、vitest 5(通过 `deno run -A npm:vitest` 运行)、biome(通过 `npm:@biomejs/biome` 运行)、Vite 7 + React Router 7。
6. **生产形态:单容器,一个 `deno compile` 二进制。** 它按 `--role api|worker|all` 运行 API 和 worker,并托管 web 静态产物。镜像基础是 `debian:trixie-slim`,spike 实测 260 MB。
## 理由
- spike 的结论是通过或有变通,没有不通过的项。和 Node 的差异都能在工程层面处理:不带扩展名的 import、Kysely 迁移导入路径、TS7 移除了 `baseUrl`、compile 打包体积。
- 能拿回 Deno 的运行时权限模型,正好落实 ADR-001 列为代价的 REQ §10.2 要求。生产二进制在编译时就固定 `--allow-net/--allow-read=<白名单>/--allow-env/--allow-sys`,不给 `--allow-run`,除非后续确实需要 ffprobe 这类外部程序,那时按程序名列白名单。
- 测试和工具链继续沿用现有资产(vitest 用例、biome 规则、Vite 配置),迁移的改动主要是机械替换。
## 代价 / 风险
- 类型检查分两套:TS 7.0.2 和 Deno 内置的 6.0.3。只要不用 TS7 独有语法就不会有分歧,spike 里两套结果一致。
- `jsr:` 包 tsc 7 解析不了。规则是:业务代码只用 npm 发布的包;`@std/*` 只能出现在 `deno check` 覆盖、tsc 排除的文件里,目前没有这类文件。
- compile 默认会把整个 node_modules 嵌进二进制(440 MB),必须加 `--node-modules-dir=none --exclude-unused-npm`。代码里如果有动态 `import()`,要用 `--include` 显式带上。
- 有安装脚本的依赖默认不执行脚本。`protobufjs`、`@google/genai`、`msw` 被跳过后都不影响使用,和原来 pnpm 的 `allowBuilds` 结果一致。以后新增这类依赖时,要用 `deno approve-scripts` 显式批准。
## 迁移清单
### 1. 根目录
新增 `deno.json`,示意如下,版本照搬现有锁定:
```jsonc
{
"workspace": ["./packages/*", "./apps/server", "./apps/web"],
"nodeModulesDir": "auto",
"imports": {
"hono": "npm:[email protected]", "hono/": "npm:/[email protected]/",
"@hono/zod-openapi": "npm:@hono/[email protected]", "@hono/swagger-ui": "npm:@hono/[email protected]",
"kysely": "npm:[email protected]", "kysely/": "npm:/[email protected]/",
"pg": "npm:[email protected]", "@types/pg": "npm:@types/[email protected]", "postgres": "npm:[email protected]",
"openworkflow": "npm:[email protected]", "openworkflow/": "npm:/[email protected]/",
"@earendil-works/pi-agent-core": "npm:@earendil-works/[email protected]",
"@earendil-works/pi-agent-core/": "npm:/@earendil-works/[email protected]/",
"@earendil-works/pi-ai": "npm:@earendil-works/[email protected]", "typebox": "npm:[email protected]",
"zod": "npm:[email protected]", "pino": "npm:[email protected]", "pino-pretty": "npm:[email protected]",
"cheerio": "npm:cheerio@…", "fast-xml-parser": "npm:fast-xml-parser@…", "nunjucks": "npm:nunjucks@…",
"@xmldom/xmldom": "npm:@xmldom/xmldom@…", "xpath": "npm:xpath@…", "@types/nunjucks": "npm:@types/nunjucks@…",
"vitest": "npm:[email protected]", "typescript": "npm:[email protected]", "@types/node": "npm:@types/node@24",
"@biomejs/biome": "npm:@biomejs/[email protected]"
},
"compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true,
"noFallthroughCasesInSwitch": true, "verbatimModuleSyntax": true },
"tasks": {
"dev": { "dependencies": ["dev:server", "dev:web"] },
"dev:server": "cd apps/server && deno task dev",
"dev:web": "cd apps/web && deno task dev",
"db:up": "docker compose -f docker-compose.dev.yml up -d --wait",
"db:migrate": "deno run -A --env-file=.env packages/db/scripts/migrate.ts latest",
"db:reset": "deno run -A --env-file=.env packages/db/scripts/migrate.ts reset",
"types": "deno types > types/deno.d.ts",
"typecheck": "deno task types && deno task typecheck:tsc && deno task typecheck:deno",
"typecheck:tsc": "for p in packages/* apps/server; do deno run -A npm:[email protected]/tsc --noEmit -p $p/tsconfig.json || exit 1; done && cd apps/web && deno run -A npm:@react-router/[email protected]/react-router typegen && deno run -A npm:[email protected]/tsc -p tsconfig.json",
"typecheck:deno": "deno check apps/server/src/main.ts packages/*/src/index.ts",
"lint": "deno run -A npm:@biomejs/[email protected] check .",
"format": "deno run -A npm:@biomejs/[email protected] check --write .",
"test": "for p in packages/* apps/server apps/web; do (cd $p && deno run -A npm:[email protected] run) || exit 1; done",
"check:tokens": "cd apps/web && deno task check:tokens",
"build": "cd apps/web && deno task build && cd ../server && deno task compile",
"ci": "deno task typecheck && deno task lint && deno task test && deno task check:tokens && deno task build"
}
}
```
- lint/fmt:**保留 biome**(规则已调好,Deno 下验证可用),打开 `correctness/useImportExtensions` 来约束 import 必须带扩展名。`deno lint` 和 `deno fmt` 不纳入 CI,避免两套规则并存。
- 删除:`pnpm-workspace.yaml`、`pnpm-lock.yaml`、根 `package.json`、`tsx` 依赖、`apps/server/scripts/build.mjs`(esbuild,由 `deno compile` 取代)、`@hono/node-server`、`esbuild` devDependency。`.gitignore` 增加 `types/deno.d.ts`。
- `tsconfig.base.json`:保留(tsc 7 用),`types` 加一项 `"./types/deno.d.ts"`,或者只在 server 的配置里 include 它;其他选项在 TS7 下都已验证 0 错误。**不要**在根目录放 `tsconfig.json`,否则 `deno check` 会读它的 `lib`,导致 `Deno` 名字丢失。
- `docker-compose.dev.yml` 不变。
### 2. 每个成员
`packages/<x>/deno.json`:
```json
{ "name": "@umt/<x>", "version": "0.0.0", "exports": { ".": "./src/index.ts" } }
```
`packages/<x>/package.json` 精简为以下内容,去掉 dependencies、scripts、`workspace:*`:
```json
{ "name": "@umt/<x>", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
```
- 各包的 `tsconfig.json`(extends base)保留,用于 tsc 7。`vitest.config.ts` 保留,不用改。
- `apps/server/deno.json`:
- `tasks.dev`:`deno run -A --watch --env-file=../../.env src/main.ts`
- `tasks.start`:`deno run --allow-net --allow-env --allow-sys --allow-read --env-file=../../.env src/main.ts`
- `tasks.compile`:`deno compile --frozen --node-modules-dir=none --exclude-unused-npm --allow-net --allow-env --allow-sys --allow-read=/app --output ../../dist/umt-server src/main.ts`
- `apps/web`:**保留 package.json**。它的 scripts 由 `deno task` 直接执行,会用 Deno 运行 `node_modules/.bin`,不依赖 node。改动两处:`gen:tokens` / `check:tokens` 里的 `node` 换成 `deno run -A`;`tsconfig.json` 删掉 `"baseUrl": "."`(TS5102),`paths` 保持 `"~/*": ["./app/*"]` 不变。`@umt/contracts` 的依赖写成 `"workspace:*"`,Deno 认这个写法。
### 3. 代码改动
| 点 | 改法 |
|---|---|
| 不带扩展名的相对 import(src 和 test 共约 400 行) | 一次性执行 `deno lint --sloppy-imports --fix --rules-include=no-sloppy-imports packages apps/server`(已验证能自动改成 `./x.ts` / `./dir/index.ts`),之后由 biome `useImportExtensions` 守住 |
| `apps/server/src/main.ts` | `@hono/node-server` 的 `serve` 换成 `Deno.serve({ port, hostname, signal: ac.signal }, app.fetch)`。关服时执行 `ac.abort(); await server.finished`,`closeAllConnections` 可以删掉。实测挂着 SSE 长连接也能在 0.2 秒内退出。`package.json with { type: "json" }` 读版本号的写法改成读 `deno.json` |
| 静态托管(单容器) | `import { serveStatic } from "hono/deno"`。写法是 `app.use("/*", serveStatic({ root }))`,再加 `app.get("*", serveStatic({ root, path: "index.html" }))`。path 不能写绝对路径,否则 404 |
| Kysely 迁移 | 已经在用 `kysely/migration`,不变。import map 需要有 `"kysely/"` 前缀映射 |
| `packages/tasks/test/crash-recovery.test.ts` 和 agent 的崩溃恢复测试 | 原来是 `spawn(process.execPath, ["--import","tsx", WORKER])`,改成 `spawn(process.execPath, ["run","-A", WORKER])`,并去掉 tsx。现状在 Deno 下 tasks 已经 15/15;agent 失败的根因是不带扩展名的 import,做完上一行的修复就好 |
| `packages/db/scripts/*.ts` | 由 `tsx --env-file-if-exists` 改成 `deno run -A --env-file=...`(Deno 的 `--env-file` 在文件不存在时只告警) |
| logger | pino 保留。顺带修两个 Node 下也存在的脱敏漏洞:加 `formatters.bindings` 脱敏 child bindings;`redact()` 遇到 `Error` 先按 message/stack 序列化,否则 `err` 会变成 `{}` |
### 4. Node 专有 API 风险点(spike 中实测)
| API | Deno 2.9.7 现状 | 建议 |
|---|---|---|
| `worker_threads`(pino transport、`new Worker(url,{type:"module"})`) | 可用 | 新代码可以直接用 Web Worker(`new Worker(url, { type: "module" })`) |
| `process.on("SIGTERM")` / `process.exit` / `process.env` / `process.pid` | 可用,Deno 2 下 `process` 是全局对象 | 入口层统一改成 `Deno.addSignalListener`、`Deno.exit`、`Deno.env`,库代码保留 `node:process` 也行 |
| `fs.statfs`(磁盘余量) | 可用,bsize/bavail 正确 | 不变 |
| `child_process.spawn` / `kill(SIGKILL/SIGTERM)` | 可用 | 生产要 `--allow-run=<程序名>`。新代码可以用 `Deno.Command` |
| `process.execPath` | 是 deno 本身 | 凡是"用当前运行时起子进程"的地方,参数要改成 `run -A <file>`(见 §3) |
| `AsyncLocalStorage`、`node:crypto`、`node:util parseArgs`、`os.*` | 可用 | 不变 |
| `require` / `__dirname` | 仓库里只有 2 处 `__dirname`(脚本) | 改成 `import.meta.dirname` |
### 5. 测试
- **继续用 vitest 5**,运行方式是 `deno run -A npm:[email protected] run`,用例不用改。成员有精简 package.json 之后,`@umt/*` 走 node_modules 链接就能解析,不需要 alias。
- 暂不切 `deno test`。如果以后要切,最小改动是:import 换成 `@std/testing/bdd` 的 describe/it/beforeAll/afterAll 加 `@std/expect` 的 expect;`vi.fn/vi.spyOn` 换成 `@std/testing/mock` 的 `spy/stub`;`describe.skipIf(c)` 换成 `describe({ name, ignore: c, fn })`;同时要接受资源泄漏检查(sanitizer)。
### 6. Dockerfile(单容器,spike 已构建并验证)
```dockerfile
FROM denoland/deno:2.9.7 AS build
WORKDIR /app
COPY . .
RUN deno install --frozen
RUN deno task check:tokens && cd apps/web && deno task build
RUN cd apps/server && deno task compile # → /app/dist/umt-server
FROM debian:trixie-slim
RUN useradd -r -u 1000 umt
WORKDIR /app
COPY --from=build /app/dist/umt-server /app/umt-server
COPY --from=build /app/apps/web/build/client /app/public
ENV WEB_ROOT=/app/public HOST=0.0.0.0 PORT=4380
USER umt
EXPOSE 4380
ENTRYPOINT ["/app/umt-server"]
CMD ["--role", "all"]
```
- 不要用 `deno run` + `nodeModulesDir: auto` 当运行时入口。spike 实测运行时会尝试重建 node_modules 链接,报 EEXIST,镜像还有 813 MB。如果非要用 `deno run`,必须加 `--node-modules-dir=manual --cached-only`。
- 迁移作为同一个二进制的子命令(`--role migrate`),或者单独 compile 一个 `umt-migrate`,在部署时先执行。
### 7. CI
```bash
curl -fsSL https://deno.land/install.sh | sh -s v2.9.7 # 或 denoland/setup-deno,deno-version: 2.9.7
deno install --frozen
deno task ci # typecheck(tsc 7.0.2 + deno check)→ biome → vitest → tokens → web build + compile
```
需要 PG 的测试(tasks/db)在 CI 里单独起一个一次性 PG 服务,通过 `TASKS_TEST_DATABASE_URL` 注入。spike 显示,和开发者共用的 55433 库会互相干扰。
## 预估改动面
- 新增 9 个 `deno.json`,改 8 个 `package.json`,新增根 `deno.json`,删除 pnpm 相关的 3 个文件和 `build.mjs`。
- 约 400 行相对 import 补扩展名,可自动修复。server `main.ts` 约 30 行。2 个崩溃恢复测试的 spawn 参数。db scripts 3 个。logger 约 15 行。web 的 tsconfig 和 package.json 各 1 到 2 行。
- 文档:README、plan §1、ADR-001 标记 superseded。
- 业务逻辑(domain/contracts/adapters/agent/tasks 的实现)**不用改**。
+196
View File
@@ -0,0 +1,196 @@
# Deno 2.9.7 + TypeScript 7.0.2 切换准入验证记录
- 日期:2026-09-27;宿主:Linux x86_64,Docker 可用;Node v24.15.0 仍在机器上,用来做对照。
- 代码:`spikes/deno/`,是独立的 Deno workspace,不属于 pnpm workspace。原始输出在 `spikes/deno/logs/*.txt`。
- PG:`docker run -d --name umt-deno-pg -p 55435:5432 -e POSTGRES_PASSWORD=spike postgres:16-alpine`,验证结束后已删除。
- 结论见 `docs/adr/ADR-004-deno-runtime.md`。
## 0. 安装与版本
```text
$ curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s v2.9.7
Deno was installed successfully to /usr/local/bin/deno
$ deno --version
deno 2.9.7 (stable, release, x86_64-unknown-linux-gnu)
v8 15.0.245.2-rusty
typescript 6.0.3
```
**注意:Deno 2.9.7 内置的 TypeScript 是 6.0.3,不是 7。** `deno check`、`deno test`、`deno compile` 做类型检查时都用 6.0.3。TS 7.0.2 只能从 npm 装(`npm:[email protected]`),见 §g。
## 1. 结论表
| 项 | 结论 | 要点 |
|---|---|---|
| workspace + `nodeModulesDir: "auto"` + `npm:` imports + `deno install` | 通过 | 12 秒装完;生成的 `deno.lock` 是 v5 格式。有安装脚本的包(protobufjs、@google/genai、msw)默认被跳过,和 pnpm `allowBuilds` 的效果一样 |
| a. openworkflow 0.10.1 + postgres.js | 通过 | 迁移建表、step.run/sleep/waitForSignal、幂等键、kill -9 后被另一个 worker 接管、双 worker 40 个 run 无重复,语义和 Node 下一致 |
| b. pi-agent-core / pi-ai 0.87.1 | 通过 | fake provider 事件流、白名单(`bash` 被拒)、abort、SIGKILL 后 JSONL 恢复("结果未知",不重复提交)都跑通。`NodeExecutionEnv` 在 Deno 下可以用 |
| c1. Hono 4 + `Deno.serve` + SSE | 通过 | SSE 头和分帧正确;SIGTERM 触发 `AbortController` 后,即使有 SSE 长连接挂着,服务也在约 0.2 秒内退出 |
| c2. Kysely 0.29 + pg 8 / postgres.js | 两种都通过,**推荐 pg** | 项目真实的 12 个迁移在 Deno 下全部成功。postgres.js 方言对 `JSON.stringify` 过的 jsonb 会双重编码,所以业务库继续用 pg;OpenWorkflow 自己带 postgres.js |
| d. pino 10 | 通过 | 自定义 destination、formatters/hooks 脱敏、`pino.transport`(worker_threads)、pino-pretty 都能用,**不用换**。LogTape 作为备选也验证通过 |
| e. vitest 5 | 通过(需两处调整) | `deno run -A npm:vitest run` 能跑 node:http、child_process kill、fs 临时目录、statfs。真实仓库各包:server 15/15,contracts、db、domain、testkit 和 Node 一样;tasks 用干净 PG 跑 15/15;agent 有 1 例失败,原因是 import 没写扩展名 |
| e'. `deno test` + `@std/expect` | 通过 | 只改 2 行 import 就能跑同一套用例 |
| f. 前端 Vite 7 + React Router 7 | 通过 | `deno task build`(执行 package.json 里的 script)能构建,`vite dev` / `react-router dev` 在 5181 端口 curl 200;token 脚本 `deno run -A` 能生成也能检查。全程没有调用 node(用假 node 放在 PATH 最前面验证过) |
| g. TypeScript 7.0.2 | 通过(需变通) | 可执行入口是 `npm:[email protected]/tsc`,**包里没有 `tsgo`**。真实仓库 8 个 tsconfig 在 TS7 下都是 0 错误;web 要删掉 `baseUrl`(TS7 已移除这个选项)。tsc 解析不了 `jsr:`,Deno 全局类型要靠 `deno types` 提供 |
| h. `deno compile` / 镜像 | 通过(需变通) | 默认打包会把整个 node_modules 塞进去,二进制 440 MB;加 `--node-modules-dir=none --exclude-unused-npm` 后是 107 MB。`denoland/deno:2.9.7` 有 amd64 和 arm64 两个平台。单容器镜像 260 MB,API、SSE、SPA 回退和 SIGTERM 退出都验证过 |
## 2. 分项记录
### workspace 结构
```text
spikes/deno/
deno.json workspace: packages/x, packages/contracts, apps/server, web;nodeModulesDir: auto;imports 用 npm:/jsr:
packages/x/ deno.json 成员(name/exports),模拟 packages/*
packages/contracts/ 仓库 contracts 的快照;同时有 deno.json 和精简的 package.json
apps/server/ deno.json 成员:ow/、pi/、http.ts、db.ts、log-*.ts、node-compat.ts、test/
web/ apps/web 的只读复制;package.json 成员
tsconfig.tsc.json 只给 TS 7 用(不能叫 tsconfig.json,见 §g)
Dockerfile 单容器验证
```
**成员被 Vite/vitest/tsc 解析的前提:** `package.json` 成员会被 `deno install` 软链到 `node_modules/@umt/*`,所以 Vite、vitest、tsc 这些走 node 解析的工具都能找到它。只有 `deno.json` 的成员(比如 `@spike/x`)**不会**被链接,这些工具找不到。已验证同一个目录可以同时放 `deno.json` 和 `package.json`:Deno 的 name/exports 以 deno.json 为准,链接照样生成。
### a. OpenWorkflow
```text
$ deno run -A src/ow/cli.ts tables
biz.side_effect_ledger / openworkflow.openworkflow_migrations / step_attempts / workflow_runs / workflow_signals
# 正常路径:signal 发出后 run 完成,步骤依次是 submit→signal-wait→copy→register→sleep→cleanup;用同一个 idempotencyKey 再投递,返回的是同一个 run
# kill -9:copy-file(6s)执行中 kill -9 worker A,启动 worker B,约 36 秒后完成(租约过期接管)
1fdbe2ec copy-file A start
1fdbe2ec copy-file B start / end → register B → cleanup B (attempts=4)
# 双 worker:40 个 burst run
{"total":"40","runs":"40","a":"24","b":"16"}
# SIGTERM:Deno.addSignalListener → worker.stop() → "[A] worker stopped gracefully"
```
改动只有三处:`process.on("SIGTERM")` 换成 `Deno.addSignalListener`,`node:fs` 写 pidfile 换成 `Deno.writeTextFileSync`,其余文件在顶部补 `import process from "node:process"`。其实不改也能跑,因为 Deno 2 对 npm/Node 代码提供了全局 `process`,`process.on` 也验证可用,见 §h。
### b. Pi
```text
$ deno run -A src/pi/fake-run.ts
[tool_end] inventory__query isError=false → {"inLibrary":false}
[tool_end] download__submit isError=false → {"accepted":true,"idempotencyKey":"dl:Dune:1080p"}
[tool_end] bash isError=true -> "Tool \"bash\" is unavailable"
[run_end] status=completed fake calls: 3
$ SUBMIT_DELAY_MS=2000 deno run -A src/pi/abort-resume.ts crash # 进程自己 SIGKILL
$ deno run -A src/pi/abort-resume.ts resume
open operations at attach: [{"lane":"main","kind":"run",...}]
[tool_end] (recovery) download__submit "[Tool execution was interrupted ... external outcome is unknown.]"
SUBMITTED in this process [] ← 没有重复提交
```
### c. Hono + Deno.serve + SSE,Kysely
- `Deno.serve({ port, signal })` 直接接 `app.fetch`;`streamSSE` 输出 `event/data/id`。SIGTERM 时 abort signal,`server.finished` 会 resolve。
- 挂着 SSE 长连接时关服:不管应用层有没有主动关 SSE,进程都在约 0.21 秒内退出(`logs/hono-shutdown.txt`)。Node 那边原来需要 `closeAllConnections()`,这里不需要。
- 静态托管:用 `hono/deno` 的 `serveStatic({ root })`,SPA 回退要写成 `serveStatic({ root, path: "index.html" })`。**写绝对路径 `path: "/app/public/index.html"` 会 404**。
- Kysely 0.29 的迁移 API 要从 `kysely/migration` 导入,import map 里需要加 `"kysely/": "npm:/kysely@^0.29.6/"`。
```text
pg migrate: ["0001_init:Success","0002_index:Success"] rows: string:1/Dune/{"q":"1080p"}/true 50 并发/池 4: 158ms
postgresjs migrate: 同上 rows: string:1/Dune/"{\"q\":\"1080p\"}"/true ← jsonb 被双重编码 170ms
# 真实迁移(只读引用 packages/db/migrations):
pg migrate: ["0001_init:Success", … ,"0012_organization:Success"] error: null
```
### d. 日志
```text
{"level":30,...,"token":"[REDACTED]","url":"https://x/rss?passkey=[REDACTED]&a=1","msg":"fetch https://x?api_key=[REDACTED]"}
{"level":40,...,"component":"worker","password":"p","err":{},"msg":"child"} ← 见下方"顺带发现"
{"level":30,...,"secret":"[REDACTED]","msg":"via pino.transport (worker_threads)"}
[18:04:42] INFO (987284): via pino-pretty transport
```
**顺带发现(和运行时无关,Node 下复现结果相同):** 现有 `apps/server/src/logger.ts` 的脱敏有两个漏洞:① `logger.child({ password })` 的 bindings 不经过 `formatters.log`,需要再加一个 `formatters.bindings`;② `redact()` 把 `Error` 当普通对象处理,结果 `err` 变成 `{}`,应该先按 `err.message/stack` 序列化,再做脱敏。
LogTape(npm 版 `@logtape/logtape`)写一个 redacting sink 包装约 5 行,输出 JSON Lines,也可用。**结论:保留 pino。**
### e. 测试
```text
$ deno run -A npm:[email protected] run # spike 自带用例
FAIL: Cannot find package '@spike/x' ← deno.json 成员不在 node_modules 里,Vite 解析不到
# vitest.config.ts 加上 resolve.alias 之后,连跑 3 次都是 5/5 通过(约 0.7s)
$ cd apps/server && deno run -A --no-lock npm:vitest run # 真实仓库,只读
Test Files 4 passed (4) Tests 15 passed (15)
```
真实各包在 Deno 和 Node 下的对照(`logs/vitest-real-packages.txt`):
| 包 | Deno | Node | 说明 |
|---|---|---|---|
| contracts / db / domain / testkit | 361 / 11 / 122 / 1 全过 | 同 | |
| adapters | 108 过 1 败 | 108 过 1 败 | 两边一样,是在改代码导致的,和运行时无关 |
| tasks | 在共享的开发 PG 上不稳 | 在共享的开发 PG 上也不稳 | 换成干净的 PG(55435)后 Deno 下 **15/15**,包括子进程 kill -9 的恢复用例 |
| agent | 26 过 1 败 | 27 全过 | 子进程用 `process.execPath` 启动,在 Deno 下这个路径就是 deno,原生加载 `contracts/src/index.ts` 时碰到 `export * from "./agent"` 这种不带扩展名的 import |
`deno test` 的写法(`logs/deno-test.txt`):只需把
```ts
import { afterAll, beforeAll, describe, expect, it } from "vitest";
```
换成
```ts
import { afterAll, beforeAll, describe, it } from "@std/testing/bdd";
import { expect } from "@std/expect";
```
`ok | 1 passed (6 steps)`。资源泄漏检查(sanitizer)也没有报错。要注意的差异:没有 `vi.fn/vi.mock`(改用 `@std/testing/mock` 的 `spy/stub`);没有 `describe.skipIf`(改成 `it({ ignore: cond })`);`toMatchObject`、`toEqual` 等常用 matcher 在 `@std/expect` 里都有。
### f. 前端
```text
$ deno run -A npm:react-router build
error: ... react-router/package.json did not have a bin property ← CLI 在 @react-router/dev 包里
$ deno run -A npm:@react-router/[email protected]/react-router build → ✓ built in 690ms,SPA Mode: Generated build/client/index.html
$ deno task build (执行 package.json 的 "react-router build";PATH 最前面放了会 exit 99 的假 node,构建仍然成功)
$ deno run -A npm:vite@7 dev --port 5181 --strictPort
GET / -> 200 228202B <title>UHD 媒体自动化控制台</title>;GET /app/root.tsx -> 200 text/javascript
$ deno run -A scripts/gen-design-tokens.mjs [--check] → 生成 / "登记表是最新的",exit 0;check-design-tokens.sh 通过
```
`deno task` 运行 `node_modules/.bin/*` 时用的是 Deno,不会去调系统的 node。
### g. TypeScript 7.0.2
- 包结构:`[email protected]` 的 `bin` 只有 `{ tsc }`,`lib/tsc.js` 只是个壳,真正执行的是平台包 `@typescript/typescript-linux-x64/lib/tsc`(Go 原生二进制)。Deno 下没有 `process.execve`,会走 `execFileSync` 分支,exit code 能正确透传。
- 命令:`deno run -A npm:[email protected]/tsc --noEmit -p tsconfig.tsc.json`。
- 结果对照(`logs/typecheck-compare.txt`、`logs/tsc7-real-repo.txt`、`logs/tsc7-web.txt`):
| | tsc 7.0.2 | deno check(TS 6.0.3) |
|---|---|---|
| spike 代码 | 0 错误,1.5s(约 12s CPU,多核并行) | 0 错误,2.6s |
| 同一批严格错误(noUncheckedIndexedAccess 等) | 报出,结果一致 | 报出,结果一致 |
| `jsr:` 包(如 `@std/expect`) | **解析不了**(TS2307) | 能解析 |
| `Deno.*` 全局 | 需要 `deno types > types/deno.d.ts` 加进 include | 内置 |
| 真实仓库 7 个包 + server(现有 tsconfig) | **全部 0 错误** | 不带扩展名的 import 未解决前:contracts 16、domain 7、db 3、tasks 9、agent 11、adapters 6、server 8 个解析错误;加 `--sloppy-imports` 后全部 0 错误 |
| web(React Router typegen 之后) | 删掉 `baseUrl` 后 0 错误(TS5102: Option 'baseUrl' has been removed) | 不适用 |
- **坑:** 只要 workspace 根目录有 `tsconfig.json`,`deno check` 就会读它的 `compilerOptions.lib`。如果里面写的是 `["ES2023","DOM"]`,Deno 会报找不到 `Deno` 名字,也不认识 `import.meta.main`。所以给 TS7 用的配置要换个文件名(`tsconfig.tsc.json`),或者写到各包目录里,不要放在根目录叫 `tsconfig.json`。
- 不带扩展名的 import 可以自动修:`deno lint --sloppy-imports --fix --rules-include=no-sloppy-imports <dir>`,验证过能改成 `./a.ts` 和 `./dir/index.ts`(`logs/sloppy-fix.txt`)。仓库 src+test 里大约有 400 行这样的 import。
### h. 容器与 Node 专有 API
```text
deno compile -A worker.ts → 440 MB(把整个 node_modules 443M 都嵌进去了,包括前端依赖和 TS 原生二进制)
deno compile --bundle → 141 MB
deno compile --node-modules-dir=none --exclude-unused-npm → 107 MB 三种都能正常消费 OW 任务并优雅停止
$ docker manifest inspect denoland/deno:2.9.7 → OCI index,linux/amd64 + linux/arm64(Debian 13 trixie,默认 root 用户)
$ docker build(spikes/deno/Dockerfile:deno install → web build → deno compile → debian:trixie-slim)
image size 260MB;health 200;/ 200 text/html;/settings(SPA 回退)200;assets js 200;SSE 3 个事件;docker stop(SIGTERM)0.31s
```
第一版 Dockerfile 用的是 `deno run --cached-only` 加 `USER deno`,结果失败:`File exists (os error 17), symlink '../../packages/contracts' -> '/app/node_modules/@umt/contracts'`。原因是 `nodeModulesDir: auto` 在运行时还会尝试重建 node_modules 链接,而且镜像有 813 MB。**生产镜像应改用 compile 出的二进制;如果坚持用 `deno run`,就加 `--node-modules-dir=manual`,并在构建阶段用 root 装好依赖。**
Node API 探测(`src/node-compat.ts` → `logs/node-compat.txt`):`node:util parseArgs`、`fs.statfs`、`os.*`、`process.memoryUsage`、`AsyncLocalStorage`、`node:crypto`、`worker_threads`(`new Worker(url, {type:"module"})`)、`child_process.spawn` 加 SIGTERM、`process.on("SIGTERM")` 全部正常;`process.execPath` 是 `/usr/local/bin/deno`。
## 3. 清理
`umt-deno-pg`、`umt-deno-app` 两个容器和 `umt-deno-spike` 镜像已删除;compile 产物、`web/build`、会话目录已删除;5181、5199、58080、55435 端口都没有监听进程。`spikes/deno/node_modules`(443 MB)被 .gitignore 忽略,需要时执行 `deno install` 重建。