diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index cc13d0b0..3160ffa6 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -19,6 +19,77 @@ operations require explicit approval and evidence. - Do not execute real payments, import bank statements, connect payment providers or write ERP without a separate approved Trellis task. +## Scenario: Deployment Seed Separation + +### 1. Scope / Trigger + +Daily releases preserve operator-maintained configuration. This local candidate +contract does not assert production has been deployed. + +### 2. Signatures + +- Shared entry: `cd server && npm run deploy:init`. +- `DEPLOY_DATA_MODE=existing|initialize` is mandatory; no default. +- deploy.sh passes mode via Compose run `-e`; Compose init calls the same entry. + With `entrypoint: ["sh", "-c"]`, command must be a one-element list containing + the entire shell expression, not a folded scalar subject to argv splitting. + Verify entry reachability and propagation of a nonzero child exit, not only + the YAML text or Compose config rendering. + Approved direct calls use `run --rm --no-deps -T -e DEPLOY_DATA_MODE=existing init` + with established project/file/env selectors and an already healthy database. + +### 3. Contracts + +- Validate mode before subprocess/DB access; then config precheck, migrate deploy, + initialize-only seed:config/seed:trialrun, and read-only configuration checks. +- Existing never invokes either seed, even on missing configuration. Migrations + are not read-only. No implicit repair or mode fallback. +- Require nonzero counts of enabled expenseType, enabled approvalRule and active + reimbursement workflowDefinition. Mirror current reimbursement predicates. + No forced v1, roles, thresholds or values equal to seed defaults. +- Bounded repeatable-read transaction explicitly sets READ ONLY. Output fixed + check codes/counts/maintenance hints only, never row payloads. +- Empty core configuration blocks switching, including intentional disablement. + Nonempty checks do not prove amount coverage, assignees or business readiness. +- Workspace/sequence fallbacks, optional flags/calendar and employee/account + readiness stay outside this gate; do not create missing records. + +### 4. Validation & Error Matrix + +- Missing/invalid mode → deployment.data_mode_required before migrate/seed. +- Subcommand failure → deployment.init_command_failed; no later stages. +- Missing core → REQUIRED_CONFIGURATION_MISSING with safe check list, then + deployment.required_configuration_missing; never auto-seed existing data. +- Query/connection errors → deployment.init_failed, nonzero, no raw exception. +- INIT_PASS proves only this init/check stage, not public release success. + +### 5. Good / Base / Bad Cases + +- Good: existing custom flags/workflows/calendars and versions survive. +- Base: explicitly initialize an empty synthetic database with seed in order. +- Bad: repair missing records by replaying seed, or default to initialize. + +### 6. Tests Required + +- deploy-init.test.js mode/order/errors/read-only checks join the static gate. +- Keep deployment-db-lifecycle and deployment-safety-contract regressions. +- Opt-in deploy-init.integration.test.js uses DEPLOY_INIT_TEST_DATABASE_URL: + loopback, uniquely named hyb_seed_test_* empty synthetic DB. No production + default URL, reset, real archive or remote access. Operator owns exact cleanup. + Missing URL means skipped, not integration PASS. +- Test actual initialize, repeated existing execution, hashes of 17 config + models including timestamps/versions, and each missing-core failure. + +### 7. Wrong vs Correct + +Wrong: values differ from defaults → overwrite them on every deployment. +Correct: preserve values; stop only for approved minimum missing-core checks. +Expense types require authorized configuration repair; do not invent a UI route. +Approval rules and workflow definitions have existing admin pages. +Raw seed commands remain available for separately approved initialization; +this does not prevent an administrator explicitly running seed. Old-image +rollback cannot reverse migrations or initialization data. + ## Scenario: Existing Application Release Must Not Manage Database Containers ### 1. Scope / Trigger @@ -59,7 +130,8 @@ operations require explicit approval and evidence. without a container may incur an extra cached build. This assumes the current build-only worker definition; future image overrides need their own compatibility review, rather than treating a previous container reference as new configuration. - Preserve migration/seed command content and order, safe logs, identity/path + Preserve migrations and initialize-only seed order through the shared + deploy:init entry above, safe logs, identity/path preflights, rollback window, build markers and backup-contract refresh. - Missing timeout tooling or unsupported isolation options fail closed. Do not retry a command without isolation to accommodate an older Compose executable. diff --git a/.trellis/tasks/09-09-existing-deploy-seed-separation/check.jsonl b/.trellis/tasks/09-09-existing-deploy-seed-separation/check.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.trellis/tasks/09-09-existing-deploy-seed-separation/design.md b/.trellis/tasks/09-09-existing-deploy-seed-separation/design.md new file mode 100644 index 00000000..7379c711 --- /dev/null +++ b/.trellis/tasks/09-09-existing-deploy-seed-separation/design.md @@ -0,0 +1,55 @@ +# 已批准实施方案:初始化与日常升级分离 + +## 已核事实 + +- 当前本地基线 b918530。deploy/trialrun/deploy.sh 的 run-init 无条件执行配置预检、migrate deploy、seed:config、seed:trialrun。 +- deploy/trialrun/docker-compose.yml 的 init 默认命令也执行 migrate 与两类 seed;只改 deploy.sh 不能覆盖默认 init 使用路径。 +- seed-config 包括工作流取消激活后 upsert、假期条目删除重建等写入;seed-trialrun 对既有开关强制更新。不能用“本次值与默认相同”替代发布模式隔离。 +- 原生命周期隔离 task 已约定 existing 不操作 db 生命周期、init 和应用采用依赖隔离;本轮保留这些合同。 + +## 拟采用路径 + +使用既有 existing/initialize 模式,不新增第三个部署模式。拟在 server/src/scripts 增加一个小型 init 命令,由 server/package.json 暴露统一入口,供 deploy.sh 和 Compose init 默认 command 共用;不复制两套分支。当前 Dockerfile 已 COPY server,无需为入口修改构建拓扑。模式必须显式传入,空值或非法值在迁移/seed 前失败;不默认 initialize。deploy.sh 用容器环境参数显式传递已验证的模式,Compose 直接调用则要求调用者提供模式。保持 --no-deps;受支持的直接调用文档同样明确依赖隔离,不改变 Compose 服务拓扑。 + +顺序:配置预检 → migrate deploy → initialize 才执行 seed:config 和 seed:trialrun → 两种模式均运行只读基础配置检查 → 成功才进入应用构建/切换。迁移和 seed 子命令非零即停,不重试、不自动改模式。未明确模式的旧直接 init 调用将失败,这是刻意的安全兼容变化,需在交审及操作手册写明。 + +只读配置检查是本项目报销主链路的最低配置门禁,不声称这些都是服务启动必需项。使用短只读事务和错误即停,只输出检查代码/数量及维护入口,不输出记录内容。拟阻断条件限定为以下三类完全缺失:至少一个 enabled expenseType、至少一个启用的 approvalRule、至少一个 entity_type=reimbursement 且 is_active 的 workflowDefinition(与当前提交入口选择条件一致)。不要求固定名称、固定 v1、固定角色、固定金额阈值或内容等于 seed 默认值。 + +这只是“非空”最低门禁,不证明金额区间无空洞、审批人都可解析或全部单据可提交。空库冒充 existing 因缺三类基础配置失败,不自动初始化。管理员故意关闭全部报销基础配置的环境也会停止发布,而不是被强制恢复;这是本方案新增的明确兼容约束,须用户批准。 + +确需新增配置的数据升级另设可审查的版本化变更;本轮不借默认 seed 自动补齐。发现缺项则报告维护路径,由负责人另批初始化或数据修复,不能在 existing 中静默写入。 + +## 风险及回滚 + +停止 seed 不等于停止 migrate,也不保证旧镜像能撤销数据变化。应用回滚、迁移兼容、数据库恢复分开说明。初始化 seed 仍有写入风险,非法模式和 init 失败必须停止切换。最终实施须在独立工作树进行,避免与尚未提交的 Trellis 修复混合。 + +## 已核源码与配置裁决 + +| 对象 | 证据 | 方案裁决 | +| --- | --- | --- | +| 部署入口 | deploy-existing.sh 显式设置 existing;deploy.sh 本地及远端都校验模式,run-init 覆盖 Compose 默认 command | 两条 init 路径统一入口,模式传入容器;保留 V1/V2 Compose 支持 | +| 报销审批规则 | reimbursements.js 的 loadApprovalRuleForAmount 无匹配时 409 | 仅查启用规则非空;不宣称覆盖全部金额 | +| 报销工作流 | reimbursements.js 提交读取 active reimbursement definition;缺失时报业务错误 | 查 active 定义存在,不改审批链,不强制 v1 | +| 费用类型 | reimbursements.js 的 resolveEnabledExpenseTypesByIds 只接收 enabled 类型 | 查至少一个 enabled,不重建默认类型,不验证每种差旅费用 | +| 工作区 | workspaceSettings.js 的 mapWorkspaceSetting(null) 返回 buildDefaultWorkspaceSetting | 非发布硬阻断,不创建记录 | +| 编号 | documentSequence.js 的 resolveDocumentSequenceConfig 及 issueDocumentNumber 有解析兜底和业务时 upsert | 本检查不调用发号函数,不要求初始化序列存在,不更改业务发号写入 | +| 开关 | featureFlags/index.js 结合环境门禁与 DB 记录评估 | 不强制 force_on 或默认 rollout,不把管理员关闭视为缺配置 | +| 日历、预算、部门、用户/主体/银行卡及其他模块 | 属于各自业务就绪与配置范围 | 不扩为全站发布门禁,不读账号/银行资料,不在发布时自动维护 | + +维护提示沿用费用类型、审批规则、流程定义的既有管理入口;若初始化数据根本未建,由运维另审 initialize 或版本化数据修复,不建议在 existing 重跑整套 seed。 + +## 实施文件与验证 + +预计涉及 deploy/trialrun/deploy.sh、docker-compose.yml、server/package.json、新的小型 init/只读检查脚本及定向测试,原 deployment spec/手册和本 task。复用 server/tests/unit/scripts/deployment-db-lifecycle.test.js、deployment-safety-contract.test.js;新测试覆盖真实统一入口执行顺序,不只匹配脚本文本。 + +矩阵:existing/initialize × 成功/非法或缺模式/配置预检失败/迁移失败/seed失败/只读缺项或查询失败。existing 中 seed 调用次数恒为零且成功路径必须真正到达应用步骤;initialize 的两个 seed 必须按序执行,失败不得到达应用步骤。直接 Compose 默认 command 使用同一入口,已有依赖隔离的变异测试继续生效。 + +合成库测试与生产完全隔离:验证 initialize 空库建立配置;人为修改已有开关、工作流、日历及其他 seed 管理表后运行 existing 两次,比较配置字段、版本/时间戳不变,迁移记录单列。只用测试身份/临时数据库,不能以无 Docker 或初始化失败冒充通过。环境前置不足先报告,不借用 ARM 正式归档或生产库。 + +## 审批边界 + +用户已同意本 task 规划方向;以上统一入口、直接调用显式模式和三类非空硬门禁为本轮收敛的待审实施方案。获批前保持 planning;不把此前“好的”解释成批准尚未呈现的兼容变化。实施只在独立工作树,不叠加未提交的治理修复。提交、推送、生产部署另批。 + +后续授权与实施:用户对收敛方案明确回复“是”,已进入独立工作树实施。共享入口为 server/src/scripts/deploy-init.js。只读检查通过 Prisma repeatable-read 事务显式 SET TRANSACTION READ ONLY;费用类型没有专用管理页面,改为提示管理员核对既有 /api/expense-types,缺失数据另批修复,不新增 UI。 + +环境契约同步限定 init 可注入且必须声明 DEPLOY_DATA_MODE,并要求 Compose 缺省展开为空,拒绝默认 initialize;server/worker 仍不允许该 override。此为已批准的模式传递配套,不扩大业务环境变量或放宽校验器。 diff --git a/.trellis/tasks/09-09-existing-deploy-seed-separation/implement.jsonl b/.trellis/tasks/09-09-existing-deploy-seed-separation/implement.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.trellis/tasks/09-09-existing-deploy-seed-separation/implement.md b/.trellis/tasks/09-09-existing-deploy-seed-separation/implement.md new file mode 100644 index 00000000..c346ca5e --- /dev/null +++ b/.trellis/tasks/09-09-existing-deploy-seed-separation/implement.md @@ -0,0 +1,84 @@ +# 规划与执行清单 + +- [x] 由用户采纳的队列进入本 task,官方 create 激活 planning;未运行 start。 +- [x] 确认两个无条件 seed 入口,建立需求/草案;关联既有数据库生命周期隔离合同。 +- [x] 核对仓库支持的部署/init 调用路径、最低配置与现有测试,形成待审方案和最小写入文件清单;不声称核对了仓库外手工命令。 +- [x] 用户明确批准方案;独立执行工作树基于 b918530,官方 start 并回读当前任务。 +- [x] 实施、定向测试及主会话只读差异复核;独立 reviewer 受 inline 限制未执行,保留缺口。 +- [x] 更新既有部署规约和手册;提交、推送、部署未授权、未执行。 + +## 当前状态 + +本地实现和验证完成,未发布。执行工作树为 artifacts/isolated-worktrees/20260909-seed-separation,detached b918530;主工作树仅保留本任务合同与状态,不集成产品差异,既有 Trellis 治理差异不混入本轮实施。错误豁免原任务未延期、未在本轮迁移原因码。不需要用户再操作 ARM。 + +## 本阶段检查 + +`task.py current --source`、`get_context.py --mode phase --step 1.1 --platform codex`、本 task 的 `task.py validate`、Trellis governance 和 `git diff --check`。这些只验证规划结构,不代表发布或数据语义验证。 + +## 实施与检查证据(2026-09-09) + +- 新增 deploy-init 共享入口、模式分支与三类只读配置检查;两个实际部署入口共用。迁移先于配置检查,所以检查失败不能声称零数据库写入,只能保证不自动 seed、不进入后续应用切换。 +- 生命周期 fake 保留外部命令 fail-closed,更新为精确核对共享入口和显式模式,不因删除旧 seed 字符串跳过 init。既有 db/依赖隔离变异仍必须被发现。 +- 定向命令:server 下运行 Vitest 的 deploy-init.test.js、deployment-db-lifecycle.test.js、deployment-safety-contract.test.js。初轮 90/90 通过;后补 init 环境契约四场景,deploy-init 单独 21/21 通过,完整最新矩阵另记录最终输出。 +- 合成数据库使用本机 Unix Docker socket、既存 PG16 alpine 镜像、本轮随机名和回环临时端口;不读生产 env 或正式备份,不用 ARM。测试环境仅显式构造的合成配置。容器与其卷逐轮精确清理,回执均为 removed=true。 +- 集成测试 deploy-init.integration.test.js:空数据库真正执行迁移和 initialize;修改开关为 force_off/department_allowlist、版本 77,修改工作流版本 42 和假期名称;existing 连续两次后 17 类配置全字段(含版本、时间戳)的排序摘要相同。逐类禁用最低配置后 existing 非零且配置摘要不变,迁移名称集合单列核对相同。最终集成运行通过,不将可选测试的默认 skip 写作通过。 +- 首轮集成在初始化成功后因测试快照假设员工角色表有单列 id 而失败;改用全行序列化排序摘要,复验通过。未为迎合测试修改 schema 或业务数据模型。 +- 部署环境校验最初拒绝 init 新 override;按方案只给 init 增加允许/必需项及缺省空值约束。正例、缺模式、默认 initialize、注入错误服务四种契约测试通过;没有放宽其他服务。 +- 已通过:node --check、Bash syntax、Prisma validate、validate-env-contract、task validate、Trellis governance、process contract、git diff --check。docs registry errors=0、warnings=1(22 个未注册文档),本轮未新建 docs 文件,不扩大范围治理该警告。 +- 主会话只读复核覆盖模式先验证、共享入口、只读事务、阶段非零停止、原依赖隔离、维护提示无虚构页面及错误脱敏。inline 不允许派 implement/check 子代理,独立 reviewer 未运行,不宣称独立复核通过。 +- 未执行完整候选发布门禁、镜像构建/生产部署或真人业务验收;集成使用本地 test 环境,不能代替生产配置检查。本轮不修改 seed 内容、不启用 R1/阶段 B、不触及六个 view_all。 + +| 代码 | 发布 | 验收 | 等谁/什么 | +| --- | --- | --- | --- | +| 独立工作树本地实现完成,未提交/未集成 | 未部署 | 新增及合成库验证通过,完整安全回归有一次待核失败 | 预检回归待核、独立复核/交审;提交推送及生产例外/部署另批 | + +### 最终矩阵与保留 finding + +- 最新完整三文件矩阵:94 项中 93 通过、1 失败。失败为既有 deployment-safety-contract 的 unknown migration 场景:模拟 Prisma status 输出未知待执行迁移,但预检错误返回 PASS。不能将初轮 90/90 或分组通过覆盖本次失败。 +- 两个直接相关文件 production-preflight-remote.sh 与 deployment-safety-contract.test.js 经 cmp 与主工作树基线一致,本轮未改;原主工作树基线该用例连续三次定向复验均通过(每次 1 passed/23 skipped)。因此只能记为一次间歇性回归失败、原因未明,不能说已在基线重现或已修复。 +- 新增 deploy-init 单测最终 21/21;合成 PG 集成 1/1;既有生命周期矩阵通过。集成最终再跑与当时 17 项单测共 18/18,后补四项环境契约测试另跑通过。 +- 此 finding 不触发自动修改旧预检、删除断言或生产验证。保留为交审和发布前必须核清的项;本 task 不宣称完整回归全绿或具备部署资格。 + +### 继续核查:已复现管道误判,由既有门禁任务承接 + +用户“继续推进”后仅进行了本地合成核查,未改 production-preflight-remote.sh 或原安全测试,未访问生产。两种证据分开记录: + +1. 直接执行原始短状态文本的 printf | grep -Eiq 管道,在 pipefail 下实际捕获三次 producer=141、consumer=0。这证明 grep 已匹配,但生产者 SIGPIPE 使整个条件返回失败;不是根据三次基线定向通过推断安全。 +2. 从现有源码提取 status_pending_ids 解析和 unknown pending 拒绝分支,在内存构建合成执行片段。用只对相应 printf 返回 141 的函数注入上述已观测状态:原片段返回 ACCEPTED/exit=0;仅将该匹配改为 here-string 的候选片段返回 PROD_MIGRATION_STATUS_UNKNOWN_PENDING/exit=1。此为确定性分支复现,不冒充真实远端完整预检。 +3. 候选 here-string 匹配对同一短文本连续 2000 次,falseNegatives=0。未修改产品文件,不能因此宣布修复已交付。 + +根因:grep -q 提前退出 + 上游 printf + pipefail,使“有待执行迁移”被误判为不存在,跳过 unknown pending 拒绝。此类组合在同一状态解析区共有四处。它是现有发布预检可靠性缺陷,不是 seed 分支或数据库 schema 问题;此前“原因未明”已由上述证据更新,不删除历史失败记录。 + +归属更正(用户已同意):撤回“必须独立新建 task”的建议。预检修复由既有 09-06-release-gate-integrity 的“迁移状态误判修复”阶段承接,具体修复范围与回归清单只在该处维护。本 task 保留上述复现证据和发布前置依赖,不混入预检产品差异;任务复用不等于混合工作树或提交,旧冻结门禁包不变。当前仅更新归属,预检尚未修复。 + +当前结论:seed 本地实现与新增测试成果保留,发布前置缺陷已查明但未修复。整体不得宣布全绿或可部署。 + + +### 门禁关联更新:本地预检修复验证完成 + +09-06-release-gate-integrity 已在独立 preflight-fix 副本完成四处状态谓词修复,主会话复核与确定性回归通过。以 b918530 为共同基线临时组合本 seed 代码和预检修复,109/109 回归通过、临时验证副本已清理;输入指纹与具体证据只在门禁 task 同日结果维护。 + +此前 93/94 异常的根因与本地修复已闭合,不再把它记为原因未明;未覆盖历史失败。seed 运行时代码没有追加更改,原真实合成 PG 验证沿用。两个产品副本未合并到主工作树、未提交/推送/部署;独立复核及完整候选发布门禁仍未完成,生产状态未重新查询。下一步是按现有 task 分别交审改动,不扩展新功能。 +### 交审前入口补验(2026-09-09) + +交审前发现 Compose init 原 folded scalar 配合 entrypoint ["sh", "-c"] 会拆分 shell 参数。Docker Compose v2.24.7 本地无网络合成容器实际复现:原格式未到达标记且退出 2;改为单元素 command 数组后到达标记并原样传播退出 17。config 渲染及 YAML 字符串断言不足以证明命令执行。 + +本次仅修 init command 数组并补 shell 执行回归,仍归本 task,不新建任务。单测 22/22;更新后的 seed + 预检临时组合三文件 110/110 通过,11 个输入文件摘要 a3f91e3d1fcdbc522347725659e3b8b5a0438df62a7eea52b03f05fb3ba6d70a。旧 109/109 结果只对应旧输入,不代替本次证据。临时组合 worktree 和合成容器已清理;共享 JS 数据库逻辑未改,原 PG 集成证据沿用,不声称本次重跑数据库集成。 + +交审包:artifacts/qa-flow/20260909-seed-preflight-review-v1/seed/。包含基线差异、新文件、文件摘要与验证边界;不含依赖、运行时会话及其他 task。与 preflight 包都修改部署 spec,未来集成需保留两个增量,禁止整文件相互覆盖。 + +当前:本地实现及主会话检查完成;独立 reviewer 待复核,未提交、未推送、未部署,完整候选门禁未运行。此段为最新状态,前述失败和阶段记录保留用于追溯。 +## 2026-09-09 独立复核接受与集成准备 + +用户回传独立审查:seed 通过(3 条非阻断备注),preflight 通过(无 finding)。审查者核验冻结 diff 并阅读合同与代码,另做本地 bash SIGPIPE 合成复现;未重跑项目测试、未审完整历史叙述、未构建镜像或访问生产/ARM,未验证后续集成。不得把该结论扩大为独立执行了 110 项测试或批准部署。 + +集成副本:artifacts/isolated-worktrees/20260909-seed-preflight-integration,基线 b918530,归当前门禁 task 协调整合;两个原 task 归属不变。按共同祖先合并部署 spec,零冲突,不用整文件覆盖。原两份冻结包和执行副本保留未改,主工作树不混入产品实现。 + +备注处理: +- F1:集成副本的既有运维手册明确补充,有意停用全部费用类型、审批规则或报销流程也会挡住下次发布;不得自动补齐或改用 initialize 绕过。 +- F2:保留并强化“迁移先执行,配置检查失败时迁移可能已经落库”,不简化成零写入。 +- F3:deploy.sh 的 sh -lc 与 Compose 的 sh -c 环境加载不同,记录为待环境验收关注项;本轮不改 shell 行为。 +- Compose folded scalar 是基线已有问题,本 task 因承诺两入口真实到达而一并修复,不是本次引入。 +- 预检缺陷定性是“该拦时可能拦不住”,未证明生产曾误放行。独立审查在其环境较大输出触发 141,但约 64KB 不是通用安全阈值;此前已有短输出捕获 producer=141/consumer=0 的记录,调度和管道实现都会影响触发,不采纳“日常绝不会触发”的推断。 + +两笔提交准备顺序:先 preflight(门禁 task 三文档、预检脚本、测试及 spec 对应增量),再 seed(原 17 文件范围,包含 F1/F2 手册补充和 seed task 最新状态;spec 只取 seed 增量)。共享 spec 第二笔以第一笔结果为基线。提交、推送、完整候选门禁与部署均未因此授权;当前只做隔离集成和定向验证。 diff --git a/.trellis/tasks/09-09-existing-deploy-seed-separation/prd.md b/.trellis/tasks/09-09-existing-deploy-seed-separation/prd.md new file mode 100644 index 00000000..4d7c59df --- /dev/null +++ b/.trellis/tasks/09-09-existing-deploy-seed-separation/prd.md @@ -0,0 +1,39 @@ +# 日常发布与首次 seed 初始化分离 + +## Goal + +规划 existing 发布不自动执行 seed,保留 initialize 初始化能力、缺配置停止和数据库生命周期隔离 + +## Requirements + +- 日常 existing 发布不再自动运行 seed:config / seed:trialrun,不重写管理员维护的开关、工作流、日历等配置。 +- initialize 保留首次建库和初始化能力;非法模式失败关闭,不以补配置为由自动切入初始化模式。 +- 保留既有迁移门禁、数据库生命周期隔离、健康等待、日志及应用切换门禁。 +- 缺少必需配置时,在应用切换前停止并给出脱敏缺项类别与维护路径;不自动补齐生产数据。 +- 未来必要的数据升级采用明确版本化步骤,不把 seed 当作通用升级器;本轮不搭建新的迁移平台。 + +## Scope + +先核对 deploy.sh、Compose init 默认命令及受支持的调用入口,设计初始化/升级分流和最小只读配置前置检查;实施候选为部署入口、必要的配置检查脚本、定向回归、原部署规约与本 task。 + +## Out of Scope + +用户已批准独立工作树实施与本地验证;不部署,不改业务权限、真实账号、银行卡、审批链,不回填生产数据,不运行生产 seed/migrate,不启用 R1/阶段 B,不重构全部 seed 函数或安装新运行器。真实 PostgreSQL 合成空库允许用于本地验证,不使用生产备份。 + +## Acceptance Criteria + +- [x] 已批准的最终方案覆盖部署脚本与 Compose init 默认路径,不留无条件 seed 旁路。 +- [x] initialize 在合成空库初始化成功;existing 对刻意修改过的关键配置不重写、不递增 seed 版本;重复执行稳定。 +- [x] 只读缺项检查不把“与默认值不同”误判为缺配置;缺项/查询失败在切应用前停止,不自动 seed。 +- [x] 原数据库生命周期隔离回归不退步,existing 不启动或重建数据库。 +- [x] 测试覆盖非法模式、初始化失败、迁移失败、配置缺失及直接 init 入口;不能因提前退出得到零写入假通过。 +- [x] 独立复核能力、剩余发布风险和生产授权分别标明;本地通过不表示公网已生效。 + +## Test Plan + +先复用现有本地合成部署测试,截获外部命令;配置检查增加定向单测及隔离合成库测试。空库验证 initialize,预置自定义开关/工作流/日历的已有库验证 existing 保持原值和版本,migration 对比单列,不将“无 seed”说成“无数据库写入”。具体必需配置清单和测试入口在方案收敛前完成源码核实。 + +## Notes + +- 用户在明确列明统一入口、显式模式及三类非空硬门禁后回复“是”,批准独立工作树实施和本地验证;已通过官方 start 激活。提交、推送和生产部署不在授权内。 +- 关联 09-05-existing-deploy-db-lifecycle-isolation,其“不改 seed 内容和顺序”是旧任务范围约束,不在旧任务内偷偷扩大改动。本任务单独定义新的分流合同。 diff --git a/.trellis/tasks/09-09-existing-deploy-seed-separation/task.json b/.trellis/tasks/09-09-existing-deploy-seed-separation/task.json new file mode 100644 index 00000000..6a088f85 --- /dev/null +++ b/.trellis/tasks/09-09-existing-deploy-seed-separation/task.json @@ -0,0 +1,26 @@ +{ + "id": "existing-deploy-seed-separation", + "name": "existing-deploy-seed-separation", + "title": "日常发布与首次 seed 初始化分离", + "description": "规划 existing 发布不自动执行 seed,保留 initialize 初始化能力、缺配置停止和数据库生命周期隔离", + "status": "in_progress", + "dev_type": null, + "scope": "deployment-seed", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-09", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index 4792a777..2d24b699 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -371,7 +371,7 @@ run_logged_deploy_stage "build-init" "${compose_cmd[@]}" build init echo ">>> 4.4 预检新配置并执行 init" wait_for_database_health -run_logged_deploy_stage "run-init" "${compose_cmd[@]}" run --rm --no-deps -T --entrypoint sh init -lc 'cd /app/server && node -e '\''require("./src/config").getConfig()'\'' && npm run prisma:migrate:deploy && npm run seed:config && npm run seed:trialrun' +run_logged_deploy_stage "run-init" "${compose_cmd[@]}" run --rm --no-deps -T -e "DEPLOY_DATA_MODE=$DEPLOY_DATA_MODE" --entrypoint sh init -lc 'cd /app/server && npm run deploy:init' echo ">>> 4.5 构建并切换应用服务" wait_for_database_health diff --git a/deploy/trialrun/docker-compose.yml b/deploy/trialrun/docker-compose.yml index 6bde25d5..745dc10a 100644 --- a/deploy/trialrun/docker-compose.yml +++ b/deploy/trialrun/docker-compose.yml @@ -104,13 +104,11 @@ services: - ./.env environment: NODE_ENV: production + DEPLOY_DATA_MODE: ${DEPLOY_DATA_MODE:-} BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} volumes: - ${STORAGE_HOST_DIR:-./data/storage}:${STORAGE_DIR} entrypoint: ["sh", "-c"] - command: > - cd /app/server - && npm run prisma:migrate:deploy - && npm run seed:config - && npm run seed:trialrun + command: + - cd /app/server && npm run deploy:init restart: "no" diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index ef8870f4..12ca8bb8 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -78,12 +78,12 @@ const deploymentInjectedEnvKeys = new Set(['BUILD_SHA']); const allowedOverrides = { server: ['NODE_ENV', 'BUILD_SHA'], worker: ['NODE_ENV', 'BUILD_SHA', 'SERVE_CLIENT_DIST', 'JOBS_ENABLED'], - init: ['NODE_ENV', 'BUILD_SHA'], + init: ['NODE_ENV', 'BUILD_SHA', 'DEPLOY_DATA_MODE'], }; const requiredRuntimeOverrides = { server: ['BUILD_SHA'], worker: ['BUILD_SHA'], - init: ['BUILD_SHA'], + init: ['BUILD_SHA', 'DEPLOY_DATA_MODE'], }; const requiredBuildArgs = { server: ['BUILD_SHA', 'VITE_BUILD_SHA'], @@ -389,6 +389,11 @@ for (const serviceName of requiredServices) { } } +const initBlock = extractServiceBlock(composeContent, 'init'); +if (!initBlock || !initBlock.includes(' DEPLOY_DATA_MODE: ${DEPLOY_DATA_MODE:-}')) { + errors.push('init 的 DEPLOY_DATA_MODE 必须显式传入,缺失时为空,禁止默认初始化'); +} + const serverBlock = extractServiceBlock(composeContent, 'server'); if (!serverBlock || !/^\s{6}-\s*["']127\.0\.0\.1:\$\{APP_HOST_PORT:-3000\}:3000["']\s*$/m.test(serverBlock)) { errors.push('服务 server 必须通过 APP_HOST_PORT 将容器 3000 端口只绑定到宿主回环地址'); diff --git a/docs/试运行与自动化验证手册.md b/docs/试运行与自动化验证手册.md index 65f7b1af..4ac1230c 100644 --- a/docs/试运行与自动化验证手册.md +++ b/docs/试运行与自动化验证手册.md @@ -2,6 +2,27 @@ **文档状态**:当前操作手册,用于本地、公网、企业微信和真实 OCR 试运行验证。 +## 日常发布与初始化分离(本地候选,未声明已部署) + +09-09-existing-deploy-seed-separation 将 init 统一为 `npm run deploy:init`: +existing 执行配置预检、迁移与只读基础配置检查,不运行 seed;initialize +额外按序执行两类 seed。模式缺失或非法就停止,不默认初始化。 +批准后的正式发布仍走既有 deploy-existing.sh;受控直接 Compose init +必须显式传入模式并保留 `--no-deps`,不能以 init 名称推断允许建库。 + +费用类型、启用审批规则、活动报销流程任一完全缺失时,停止切换应用并报告 +缺项。审批规则与流程定义由授权管理员在既有页面维护;费用类型缺失按 +授权配置修复处理,不提供不存在的页面入口。不得在 existing 中自动重跑 seed。 +管理员即使是有意停用全部费用类型、全部审批规则或全部报销流程,也会阻断 +下次发布的配置检查;系统不会自动把它们补回来。请在另行批准的配置维护中 +核实停用意图,不要为通过发布检查擅自启用配置或切到 initialize。 +这三个非空检查不证明金额覆盖、审批人解析或付款资料齐全;工作区、编号、 +开关和其他可选模块不强制等于初始化默认值。 + +迁移先于必需配置检查执行,因此检查失败时迁移可能已经落库,不能宣称 +失败时零数据库写入;旧镜像回滚不能撤销数据变化。直接调用未提供模式的旧 +命令将失败;提交、生产例外、部署及数据修复仍分别审批。 + **当前状态(2026-07-18)**:最后有部署和公网复检证据的来源基线仍为 `df26922`;未做当前公网只读检查时,不据此断言服务器此刻仍运行该提交。`07-18-small-team-internal-readiness` 只收敛本地候选,完成上限为 `LOCAL_CANDIDATE_PASS`;它不代表已部署、可公网测试、企业微信真机通过或真实账号/数据验证。旧 7 天试运行只有 Day 1–Day 4 后中断,缺人工业务闭环和 Day 7 Go/No-Go;下一轮必须待当前可信基线部署并复核后,新建 Trellis task,从 Day 1 连续启动。正式能力继续保持 PDF-only OCR,不接真实支付、不接 ERP、不开放图片 OCR、不导入银行流水。 本文用于 hyb-oa 本地、企业微信和公网试运行。默认策略是:能自动化的先自动化,自动化无法覆盖的才请求人工配合。 diff --git a/scripts/qa/run-release-gate.sh b/scripts/qa/run-release-gate.sh index e80228c0..9cab4d85 100755 --- a/scripts/qa/run-release-gate.sh +++ b/scripts/qa/run-release-gate.sh @@ -61,7 +61,7 @@ stage_static() { node "$ROOT_DIR/deploy/trialrun/validate-env-contract.js" ( cd "$ROOT_DIR/server" - npm test -- tests/unit/scripts/deployment-db-lifecycle.test.js + npm test -- tests/unit/scripts/deployment-db-lifecycle.test.js tests/unit/scripts/deploy-init.test.js npm run prisma:validate ) ( diff --git a/server/package.json b/server/package.json index 66433e0b..b466dfd4 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "prisma:check-generated": "node src/scripts/check-prisma-client-generated.js", "prisma:migrate:dev": "prisma migrate dev", "prisma:migrate:deploy": "prisma migrate deploy", + "deploy:init": "node src/scripts/deploy-init.js", "seed:config": "node src/scripts/seed-config.js", "apply:ceo-finance-fallback": "node src/scripts/apply-ceo-finance-fallback.js", "seed:dev-users": "node src/scripts/seed-dev.js", diff --git a/server/src/scripts/deploy-init.js b/server/src/scripts/deploy-init.js new file mode 100644 index 00000000..b9193f66 --- /dev/null +++ b/server/src/scripts/deploy-init.js @@ -0,0 +1,99 @@ +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); + +function executeCommand(command, args) { + const result = spawnSync(command, args, { + cwd: path.resolve(__dirname, "../.."), + env: process.env, + stdio: "inherit", + }); + if (result.error || result.status !== 0) { + throw new Error("deployment.init_command_failed"); + } +} + +async function inspectRequiredConfiguration(prisma) { + return prisma.$transaction(async (transaction) => { + await transaction.$executeRaw`SET TRANSACTION READ ONLY`; + const checks = [ + { + code: "expense_types", + count: await transaction.expenseType.count({ where: { enabled: true } }), + maintenance: "管理员核对 /api/expense-types;缺失数据需另批修复", + }, + { + code: "approval_rules", + count: await transaction.approvalRule.count({ where: { enabled: true } }), + maintenance: "/system/approval-rules", + }, + { + code: "reimbursement_workflow", + count: await transaction.workflowDefinition.count({ + where: { entity_type: "reimbursement", is_active: true }, + }), + maintenance: "/system/workflow-definitions", + }, + ]; + return checks; + }, { isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 }); +} + +async function checkRequiredConfiguration() { + const { createPrismaClient } = require("../prismaClient"); + const { prisma, pool } = createPrismaClient(); + try { + return await inspectRequiredConfiguration(prisma); + } finally { + try { + await prisma.$disconnect(); + } finally { + await pool.end(); + } + } +} + +async function runInitialization({ + mode, + runCommand = executeCommand, + checkConfiguration = checkRequiredConfiguration, + report = (result) => process.stdout.write(JSON.stringify(result) + "\n"), +}) { + if (mode !== "existing" && mode !== "initialize") { + throw new Error("deployment.data_mode_required"); + } + report({ stage: "validate_config", mode }); + await runCommand(process.execPath, ["-e", 'require("./src/config").getConfig()']); + report({ stage: "migrate", mode }); + await runCommand("npm", ["run", "prisma:migrate:deploy"]); + if (mode === "initialize") { + for (const script of ["seed:config", "seed:trialrun"]) { + report({ stage: script, mode }); + await runCommand("npm", ["run", script]); + } + } + report({ stage: "check_required_configuration", mode }); + const checks = await checkConfiguration(); + const missing = checks.filter((check) => check.count === 0); + if (missing.length) { + report({ status: "REQUIRED_CONFIGURATION_MISSING", checks: missing }); + throw new Error("deployment.required_configuration_missing"); + } + report({ status: "INIT_PASS", mode, checks }); +} + +if (require.main === module) { + runInitialization({ mode: process.env.DEPLOY_DATA_MODE }).catch((error) => { + const allowed = new Set([ + "deployment.data_mode_required", + "deployment.init_command_failed", + "deployment.required_configuration_missing", + ]); + process.stderr.write(JSON.stringify({ + status: "INIT_FAILED", + code: allowed.has(error.message) ? error.message : "deployment.init_failed", + }) + "\n"); + process.exitCode = 1; + }); +} + +module.exports = { runInitialization, inspectRequiredConfiguration }; diff --git a/server/tests/unit/fixtures/deployment-command-fake.cjs b/server/tests/unit/fixtures/deployment-command-fake.cjs index d273acbf..4a43d709 100644 --- a/server/tests/unit/fixtures/deployment-command-fake.cjs +++ b/server/tests/unit/fixtures/deployment-command-fake.cjs @@ -109,7 +109,8 @@ function main() { if (LIFECYCLE.has(call.operation)) { if (call.operation === 'up' && call.targets.includes('db')) fs.writeFileSync(path.join(root, 'db-started'), '1'); if (call.operation === 'run') { - if (call.targets.join() !== 'init' || !call.containerArgs.join(' ').includes('npm run prisma:migrate:deploy && npm run seed:config && npm run seed:trialrun')) { + if (call.targets.join() !== 'init' || call.containerArgs.join(' ') !== '-lc cd /app/server && npm run deploy:init' || + !args.includes('DEPLOY_DATA_MODE=' + process.env.DEPLOY_DATA_MODE)) { throw new Error('unexpected_init_command'); } if (config.initFails) process.exit(23); diff --git a/server/tests/unit/scripts/deploy-init.integration.test.js b/server/tests/unit/scripts/deploy-init.integration.test.js new file mode 100644 index 00000000..e42abd87 --- /dev/null +++ b/server/tests/unit/scripts/deploy-init.integration.test.js @@ -0,0 +1,108 @@ +const { spawnSync } = require("node:child_process"); +const { createHash, randomBytes } = require("node:crypto"); +const path = require("node:path"); + +const testUrl = process.env.DEPLOY_INIT_TEST_DATABASE_URL; +const configurationModels = [ + "expenseType", "bankInstitution", "approvalRule", "documentSequence", + "documentCategory", "workflowDefinition", "leaveType", "leavePolicy", + "featureFlag", "holidayCalendar", "holidayCalendarDay", "attendancePolicy", + "budgetControlRule", "department", "employee", "employeeRole", "workspaceSetting", +]; + +describe.skipIf(!testUrl)("real init against an explicitly supplied empty synthetic database", () => { + let prisma; + let pool; + let environment; + const originalUrl = process.env.DATABASE_URL; + + beforeAll(async () => { + const parsed = new URL(testUrl); + if (parsed.hostname !== "127.0.0.1" || !/^\/hyb_seed_test_[a-z0-9]+$/.test(parsed.pathname)) { + throw new Error("synthetic_loopback_database_required"); + } + process.env.DATABASE_URL = testUrl; + ({ prisma, pool } = require("../../../src/prismaClient").createPrismaClient()); + const tables = await prisma.$queryRaw`SELECT count(*)::int AS total FROM information_schema.tables WHERE table_schema = 'public'`; + expect(tables[0].total).toBe(0); + environment = { + PATH: process.env.PATH, NODE_ENV: "test", DATABASE_URL: testUrl, + JWT_SECRET: randomBytes(32).toString("hex"), + TRIALRUN_ADMIN_EMAIL: "seed-test@example.invalid", + TRIALRUN_ADMIN_PHONE: "19900000000", + TRIALRUN_ADMIN_PASSWORD: randomBytes(24).toString("hex") + "Aa1!", + TRIALRUN_ADMIN_NAME: "Synthetic Admin", + TRIALRUN_ADMIN_EMPLOYEE_NO: "SEEDTEST", + TRIALRUN_EXTRA_FORCE_ON_FLAGS: "", + }; + }); + + afterAll(async () => { + try { + if (prisma) await prisma.$disconnect(); + } finally { + if (pool) await pool.end(); + if (originalUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = originalUrl; + } + }); + + function run(mode) { + return spawnSync(process.execPath, ["src/scripts/deploy-init.js"], { + cwd: path.resolve(__dirname, "../../.."), + env: { ...environment, DEPLOY_DATA_MODE: mode }, + encoding: "utf8", timeout: 120000, maxBuffer: 4 * 1024 * 1024, + }); + } + + async function snapshot() { + const hashes = {}; + for (const model of configurationModels) { + const rows = await prisma[model].findMany(); + const serialized = rows.map((row) => JSON.stringify(row)).sort(); + hashes[model] = createHash("sha256").update(JSON.stringify(serialized)).digest("hex"); + } + return hashes; + } + + it("initializes an empty database and preserves customized configuration through repeated existing runs and missing-config failures", async () => { + const initialized = run("initialize"); + expect(initialized.status).toBe(0); + expect(initialized.stdout).toContain('"status":"INIT_PASS"'); + const flag = await prisma.featureFlag.findFirstOrThrow(); + await prisma.featureFlag.update({ + where: { id: flag.id }, + data: { override_mode: "force_off", rollout_type: "department_allowlist", department_ids: [], version: 77, note: "synthetic customization" }, + }); + const workflow = await prisma.workflowDefinition.findFirstOrThrow({ where: { entity_type: "reimbursement" } }); + await prisma.workflowDefinition.update({ where: { id: workflow.id }, data: { version: 42, name: "Synthetic custom workflow" } }); + const holiday = await prisma.holidayCalendarDay.findFirstOrThrow(); + await prisma.holidayCalendarDay.update({ where: { id: holiday.id }, data: { name: "Synthetic custom holiday" } }); + const expected = await snapshot(); + const migrations = await prisma.$queryRaw`SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL ORDER BY migration_name`; + expect(migrations.length).toBeGreaterThan(0); + for (const attempt of [1, 2]) { + const existing = run("existing"); + expect(existing.status, "existing attempt " + attempt).toBe(0); + expect(existing.stdout).toContain('"status":"INIT_PASS"'); + expect(existing.stdout).not.toContain('"stage":"seed:'); + expect(await snapshot()).toEqual(expected); + } + for (const [model, field, where] of [ + ["expenseType", "enabled", {}], + ["approvalRule", "enabled", {}], + ["workflowDefinition", "is_active", { entity_type: "reimbursement" }], + ]) { + const enabled = await prisma[model].findMany({ where: { ...where, [field]: true }, select: { id: true } }); + await prisma[model].updateMany({ where, data: { [field]: false } }); + const beforeFailure = await snapshot(); + const failed = run("existing"); + expect(failed.status).toBe(1); + expect(failed.stdout).toContain("REQUIRED_CONFIGURATION_MISSING"); + expect(failed.stdout).not.toContain('"status":"INIT_PASS"'); + expect(await snapshot()).toEqual(beforeFailure); + await prisma[model].updateMany({ where: { id: { in: enabled.map((row) => row.id) } }, data: { [field]: true } }); + } + expect(await prisma.$queryRaw`SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL ORDER BY migration_name`).toEqual(migrations); + }, 240000); +}); diff --git a/server/tests/unit/scripts/deploy-init.test.js b/server/tests/unit/scripts/deploy-init.test.js new file mode 100644 index 00000000..937a1cf3 --- /dev/null +++ b/server/tests/unit/scripts/deploy-init.test.js @@ -0,0 +1,142 @@ +const { runInitialization, inspectRequiredConfiguration } = require("../../../src/scripts/deploy-init"); +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); +const fs = require("node:fs"); +const YAML = require("yaml"); +const os = require("node:os"); + +const checks = [ + { code: "expense_types", count: 2 }, + { code: "approval_rules", count: 1 }, + { code: "reimbursement_workflow", count: 1 }, +]; + +describe("deployment init mode separation", () => { + it.each(["valid", "missing", "unsafe_default", "wrong_service"])("enforces the init-only environment contract: %s", (scenario) => { + const root = path.resolve(__dirname, "../../../.."); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "hyb-seed-env-")); + try { + let content = fs.readFileSync(path.join(root, "deploy/trialrun/docker-compose.yml"), "utf8"); + if (scenario === "missing") content = content.replace(' DEPLOY_DATA_MODE: ${DEPLOY_DATA_MODE:-}\n', ""); + if (scenario === "unsafe_default") content = content.replace('${DEPLOY_DATA_MODE:-}', '${DEPLOY_DATA_MODE:-initialize}'); + if (scenario === "wrong_service") content = content.replace(" NODE_ENV: production", " NODE_ENV: production\n DEPLOY_DATA_MODE: existing"); + const file = path.join(directory, "compose.yml"); + fs.writeFileSync(file, content); + const result = spawnSync(process.execPath, [path.join(root, "deploy/trialrun/validate-env-contract.js")], { + env: { ...process.env, ENV_CONTRACT_COMPOSE_PATH: file }, encoding: "utf8", + }); + expect(result.status).toBe(scenario === "valid" ? 0 : 1); + if (scenario !== "valid") expect(result.stdout + result.stderr).toContain("DEPLOY_DATA_MODE"); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it.each(["existing", "initialize"])("executes the complete %s path in order", async (mode) => { + const calls = []; + const report = vi.fn(); + await runInitialization({ + mode, report, + runCommand: async (command, args) => calls.push(args.join(" ")), + checkConfiguration: async () => { calls.push("check"); return checks; }, + }); + expect(calls).toEqual([ + '-e require("./src/config").getConfig()', + "run prisma:migrate:deploy", + ...(mode === "initialize" ? ["run seed:config", "run seed:trialrun"] : []), + "check", + ]); + expect(report).toHaveBeenLastCalledWith({ status: "INIT_PASS", mode, checks }); + }); + + it.each([undefined, "", "repair", " existing", "INITIALIZE"])("rejects invalid mode %s before any access", async (mode) => { + const runCommand = vi.fn(); + const checkConfiguration = vi.fn(); + await expect(runInitialization({ mode, runCommand, checkConfiguration })).rejects.toThrow("deployment.data_mode_required"); + expect(runCommand).not.toHaveBeenCalled(); + expect(checkConfiguration).not.toHaveBeenCalled(); + }); + + it.each([0, 1, 2, 3])("stops initialize when subcommand %s fails", async (failureIndex) => { + const runCommand = vi.fn().mockImplementation(async () => { + if (runCommand.mock.calls.length === failureIndex + 1) throw new Error("synthetic_failure"); + }); + const checkConfiguration = vi.fn(); + const report = vi.fn(); + await expect(runInitialization({ mode: "initialize", runCommand, checkConfiguration, report })).rejects.toThrow("synthetic_failure"); + expect(runCommand).toHaveBeenCalledTimes(failureIndex + 1); + expect(checkConfiguration).not.toHaveBeenCalled(); + expect(report.mock.calls.some(([value]) => value.status === "INIT_PASS")).toBe(false); + }); + + it.each(checks.map((check) => check.code))("stops for missing %s without repair", async (missingCode) => { + const runCommand = vi.fn(); + const report = vi.fn(); + await expect(runInitialization({ + mode: "existing", runCommand, report, + checkConfiguration: async () => checks.map((check) => ({ ...check, count: check.code === missingCode ? 0 : check.count })), + })).rejects.toThrow("deployment.required_configuration_missing"); + expect(runCommand).toHaveBeenCalledTimes(2); + expect(report.mock.calls.some(([value]) => value.status === "INIT_PASS")).toBe(false); + }); + + it("does not succeed or seed on a read failure", async () => { + const runCommand = vi.fn(); + const report = vi.fn(); + await expect(runInitialization({ + mode: "existing", runCommand, report, + checkConfiguration: async () => { throw new Error("query_failure"); }, + })).rejects.toThrow("query_failure"); + expect(runCommand).toHaveBeenCalledTimes(2); + expect(report.mock.calls.some(([value]) => value.status === "INIT_PASS")).toBe(false); + }); + + it("uses a bounded read-only transaction and the existing route predicates", async () => { + const transaction = { + $executeRaw: vi.fn().mockResolvedValue(0), + expenseType: { count: vi.fn().mockResolvedValue(4) }, + approvalRule: { count: vi.fn().mockResolvedValue(2) }, + workflowDefinition: { count: vi.fn().mockResolvedValue(1) }, + }; + const prisma = { $transaction: vi.fn(async (callback) => callback(transaction)) }; + const result = await inspectRequiredConfiguration(prisma); + expect(transaction.$executeRaw.mock.calls[0][0].join("")).toBe("SET TRANSACTION READ ONLY"); + expect(transaction.expenseType.count).toHaveBeenCalledWith({ where: { enabled: true } }); + expect(transaction.approvalRule.count).toHaveBeenCalledWith({ where: { enabled: true } }); + expect(transaction.workflowDefinition.count).toHaveBeenCalledWith({ where: { entity_type: "reimbursement", is_active: true } }); + expect(prisma.$transaction.mock.calls[0][1]).toEqual({ isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 }); + expect(result.map((check) => check.count)).toEqual([4, 2, 1]); + }); + + it("shares one entry with Compose and has no implicit initialize default", () => { + const root = path.resolve(__dirname, "../../../.."); + const compose = YAML.parse(fs.readFileSync(path.join(root, "deploy/trialrun/docker-compose.yml"), "utf8")); + expect(compose.services.init.command).toEqual(["cd /app/server && npm run deploy:init"]); + expect(compose.services.init.environment.DEPLOY_DATA_MODE).toBe("${DEPLOY_DATA_MODE:-}"); + const result = spawnSync(process.execPath, ["src/scripts/deploy-init.js"], { + cwd: path.join(root, "server"), + env: { PATH: process.env.PATH, DEPLOY_DATA_MODE: "" }, encoding: "utf8", + }); + expect(result.status).toBe(1); + expect(JSON.parse(result.stderr).code).toBe("deployment.data_mode_required"); + expect(result.stdout).toBe(""); + }); + + it("passes the whole Compose command to the shell and preserves child failure", () => { + const root = path.resolve(__dirname, "../../../.."); + const compose = YAML.parse(fs.readFileSync(path.join(root, "deploy/trialrun/docker-compose.yml"), "utf8")); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "hyb-seed-argv-")); + try { + fs.writeFileSync(path.join(directory, "npm"), '#!/bin/sh\n[ "$1" = run ] && [ "$2" = deploy:init ] && [ "$DEPLOY_DATA_MODE" = existing ] || exit 99\nprintf ENTRY_REACHED\nexit 17\n', { mode: 0o700 }); + const command = compose.services.init.command.map((argument) => argument.replace("/app/server", directory)); + const [executable, ...argumentsBeforeCommand] = compose.services.init.entrypoint; + const result = spawnSync(executable, [...argumentsBeforeCommand, ...command], { + env: { PATH: directory + ":" + process.env.PATH, DEPLOY_DATA_MODE: "existing" }, encoding: "utf8", + }); + expect(result.stdout).toBe("ENTRY_REACHED"); + expect(result.status).toBe(17); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +});