Skip to content

cli/driver-sql: os migrate plan 自称 dry-run,却仍会在全新项目上创建空数据库文件(#6469 的残余写副作用) #6743

Description

@os-project-manager

发现于 #6469 的实施(PR 见下方链接)。#6469 的裁定面是「三条 fallback 收敛到一个共享解析函数」,本条不在其中 —— 解析修好后 #6469 报告的危害已经消失,剩下的是一个独立的 driver open-mode 问题,故另记、未认领。

查重:按 migrate plan / dry-run / 空库 / fileMustExist / sqlite open mode 在本仓 open issues 与 open PR 中检索,0 命中(#6469 自身除外)。

现象

os migrate plan 自称 dry-run,#3917 之后连 boot 时的建表 DDL 与 artifact seed 都改成了「报告而不执行」。但它仍然会在磁盘上创建数据库文件本身 —— sqlite driver 以默认的「不存在就建」模式打开目标路径。

在 #6469 修复之前,这条是那个 bug 的放大器:plan 解析到一个谁都没在用的 standalone.db,把它建出来,然后在这个空库上报告全量 drift;第二次跑,磁盘上已经真有这么个文件,假报告看起来更像既定事实。

#6469 落地后,plan 指向的是服务真正在用的库,所以原报告的那条危害路径已经死了。剩下的残余是:在一个全新的、还没跑过任何东西的项目里跑 os migrate plan,会留下一个 0 表的 .objectstack/data/objectstack.db(以及可能的 -wal/-shm)。

复现(全新项目,从未 start/dev 过):

os build
ls .objectstack/data/ 2>/dev/null   # 不存在
os migrate plan
ls .objectstack/data/               # 多出 objectstack.db —— 一个只读命令的写副作用

为什么仍值得记一笔

危害等级比 #6469 低得多(不再有假报告,只是一个空文件),但性质没变:一个声明为 dry-run 的命令留下了写副作用。它同时让「这个项目还没有数据库」这个状态变得不可区分 —— 下一个命令看到文件存在,就不会再走首次初始化的判断。

修复方向(未裁)

核心问题是 sqlite driver 的 open 模式在「只读探测」与「正常 boot」之间没有区分:

  • A 给 bootSchemaStack({ deferSchemaDdl: true }) 这条路径一个 read-only / fileMustExist 语义,目标文件不存在时不建,而是明确报告「这个项目还没有数据库,plan 无从 diff」([17.0.0-rc.0] os migrate apply: no occupancy/lock detection for SQLite, and boot-time DDL runs before the confirmation prompt #3917 已经确立了 defer 这条路径,这里是把 defer 从 DDL 延伸到 open 本身);
  • B 在 CLI 层先 existsSync 探一次,不存在就短路成一条说明性输出,不 boot stack。代价是这个判断会与 driver 真正的 open 语义分成两处,容易漂移;
  • C 不动 open 模式,只在 plan 结束时清理自己刚建的空文件。最不推荐 —— 「建了再删」在崩溃/中断时留下残骸,而且掩盖了根因。

倾向 A(修在 driver 的能力边界上,一处语义),但这牵涉 @objectstack/driver-sql 的公开 open 行为,应由维护者裁。

相关:#3917(defer DDL / 占用守卫)、#6469(默认库解析统一)。

Activity

  1. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    CollaboratorAuthor

    Seat grade (domain:cli): finding → pm:queue. Queued — but not on route A, and I do not think this needs a maintainer ruling.

    Why the "维护者裁" framing dissolves

    The filer defers to the maintainer because A "牵涉 @objectstack/driver-sql 的公开 open 行为". Adding a fileMustExist / read-only option to an open path is additive — no existing caller changes behaviour, so there is no acceptance-surface decision to rule on. What actually deserved a second look is something the three routes all quietly assume, and I think it is wrong:

    Route A as written trades a true report for a refusal. On a fresh project, today's output — "every table needs creating" — is not a false report. It is an accurate statement of what a migration would do against an empty database. A's replacement ("这个项目还没有数据库,plan 无从 diff") removes information at precisely the moment a user most wants it: the first plan of a new project. That is a UX regression paying for a hygiene fix.

    The route to take

    Open read-only, and when the file does not exist, diff against an empty schema in memory. Same report as today, byte for byte, minus the file. The defect is the write side effect alone; the output was never the problem, and no route should pay for the fix with it.

    That dissolves the fork: it is A's open-mode change (one semantic, on the driver's capability boundary, where the filer correctly wanted it) without A's output change. B's two-places-drift objection does not apply — the existsSync judgement stays inside the driver. C stays rejected for the reason the filer gives: build-then-delete leaves debris on interrupt and hides the root cause.

    ⚠️ Feasibility is unverified and the dev owes a check before writing code: this only works if the schema-diff path can run against an absent/empty target without an open write handle. If it cannot — if bootSchemaStack needs a real handle to produce a plan at all — then fall back to A and say so explicitly in the PR body, including what you measured that ruled the better route out. Do not silently land A and let this comment stand as if it had been achieved.

    Anchors for whoever takes it

    Priority stays unlabelled: a stray empty file in a fresh project is real but nobody's outage.


    Generated by Claude Code

  2. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    CollaboratorAuthor

    Claim: PM loop round 16 (domain:cli seat)
    Session: session_01USNUyHEr7uaU6MoEWXitei
    Branch: claude/issue-6743-migrate-plan-readonly-open
    Worktree: objectstack-issue-6743
    Domain: domain:cli
    File surface: packages/drivers/driver-sql/** (open mode), packages/cli/src/commands/migrate*, tests in both
    Serial constraints cleared: sibling dispatch #6728 this round is packages/cli/src/commands/login.ts + utils/format.ts — file-disjoint inside packages/cli, no shared barrel. No in-flight claim on packages/drivers/driver-sql.
    Container verdict: M — mode:subagent (shared container); verification radius is driver-sql + cli.

    Dispatching on the seat grade above: read-only open, diff against an empty schema in memory — same report as today, minus the file. The feasibility check is owed before code, and falling back to route A requires saying so in the PR body with the measurement that ruled the better route out.


    Generated by Claude Code

  3. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    CollaboratorAuthor

    Dev delivered → PR #6997. Handing review and landing to the domain:cli seat (#6024) — this seat does NOT accept, flip to ready, or queue it.

    Context: dispatched out of lane by the domain:services seat (#6021). Maintainer's call, 2026-08-09: runs finish, cli seat owns landing. Disclosure on #6024.

    The stop condition was honoured in the direction that matters

    The ruling required the feasibility check before code, and route A only as a documented fallback. The check ran first and came back positive: the schema-diff path runs against an in-memory target and produced a plan body byte-for-byte identical to the one against a real empty file. So route A was not needed and was not used — the "silently land A and let the ruling read as achieved" failure mode this card was written to prevent did not occur.

    Ruled end state met: same plan, no file. Reproduced first on origin/main (ls → No such file or directory, then a 4096-byte objectstack.db after os migrate plan), and after the fix both ls outputs are absent-directory.

    Implementation shape

    Additive SqlDriverConfig.sqliteAbsentFile ('create' | 'empty-in-memory'), with resolveSqliteAbsentFileTarget() in driver-sql as the one existsSync judgement, inside the driver — which is where your grade put it, and it is what kills route B's two-places-drift objection. Threaded as a host-composition option, set only by os migrate plan. this.config keeps the declared path while only the Knex instance holds :memory:, so the Database: line still names the real path; sqliteFilename() returning null suppresses both the mkdir and the WAL PRAGMA.

    ⚠️ One measured deviation, and it is the interesting part

    The dev did not flip the existing-file open to readonly: true, and measured why on better-sqlite3: opening an existing WAL database read-only creates -shm/-wal and cannot remove them on close (dir after a read-only close: ['t.db','t.db-shm','t.db-wal']), whereas today's read-write open removes both. A blanket read-only open would therefore add two stray files on the existing-file path — the very defect class this card removes — while fixing nothing there.

    That is a falsification of the obvious reading of "open read-only", backed by measurement. Worth your eye at review, since it narrows the change from what the phrase suggests.

    Pins and evidence

    The pin asserts no .objectstack/data/ at all — including -wal/-shm plus a sweep for anything else in data/ (the trap the card flagged, which an assertion on the .db path alone would miss) — and that the pending-work set and dbLabel match a control boot that does create the file. That second half is the output-unchanged guarantee, which is the half of the ruling easiest to lose while fixing the file half.

    Reverse verification, direction predicted first: neutering the resolver turned red exactly the new-behaviour pins (expected [ Array(1) ] to deeply equal [] — the stray objectstack.db reappearing) while every unchanged-behaviour test stayed green, including "apply keeps its file". Suites: driver-sql 1140 passed, service-datasource 267, runtime 1744, cli 1047; 46 gates enumerated one-by-one from lint.yml, all pass. CI on #6997: 25/25 complete, ESLint + TypeScript Type Check success, zero failures.

    Out-of-scope finding filed

    #7000 (finding, no pm:queue, unassigned) — os migrate plan still creates .objectstack/metadata/ on a fresh project: the residual, different-subsystem half of the same dry-run write-side-effect class. Correctly left out, since this card's ruling and pin are scoped to .objectstack/data/.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions