Skip to content

fix(driver-sqlite-wasm): persist RETURNING writes — stop() 返回即落盘,冷启动 e2e 解封 (#4518) - #4564

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-4518-bootstack-durable-flush
Aug 2, 2026
Merged

fix(driver-sqlite-wasm): persist RETURNING writes — stop() 返回即落盘,冷启动 e2e 解封 (#4518)#4564
os-zhuang merged 1 commit into
mainfrom
claude/issue-4518-bootstack-durable-flush

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4518

结论先说:不在 harness,在驱动

issue 给了三个候选方向(查 stop() 链路 / harness 里显式 flush() / 换 persist 策略),实测下来一个都不是——三者都建立在"flush 该发生却没发生"这个前提上,而真实情况是驱动压根不认为自己脏了,所以再怎么 flush 都是空转。

用探针把链路拆开看:

PROBE connection cfg {"filename":"/tmp/probe-4518-…/v.sqlite","persist":"on-write"}
PROBE[after-write-before-stop] suspend_note = [[0]]   ← 内存里读得到,盘上 0 行
PROBE[after-write-before-stop] sys_user     = [[1]]   ← 但早期某次写是落盘了的
PROBE[after-stop]              suspend_note = [[0]]   ← stop() 之后仍然 0

两条线索合在一起就指向答案了:persist 其实是 on-write(不是 issue 猜的 on-disconnect,default-datasource-driver-factory.ts 对文件型 sqlite-wasm 就是这么配的),也就是说每次写都该 flush;但盘上只留下了 boot 早期的一次快照(所以文件大小两次完全一致 1073152)。既然 on-write 都不生效,stop() 那条链路是否走全就无关紧要了——它最后调用的 close()flush() 第一行就是 if (!this.dirty) return

根因

packages/plugins/driver-sqlite-wasm/src/knex-wasm-dialect.ts_query 用**「这条语句返不返回行」挑执行分支,然后只在无行返回的那条分支上**调 markDirty():

if (isReadMethod(obj.method, obj.returning) || PRAGMA) {
  const stmt = db.prepare(obj.sql);   // ← INSERT … RETURNING * 走这里
  
  return obj;                          // ← 没有 markDirty
}
db.run();
connection.markDirty(obj.method);      // ← 只有这条分支标脏

INSERT … RETURNING * 是返回行的,于是走上面那条分支。而 ObjectQL 的写路径就是RETURNING *(它要把落库后的整行还给调用方),所以受影响的是几乎全部业务数据——这也是为什么它表现成"harness 冷启动不了"而不是"某个驱动 bug"。knex.raw('INSERT …') 这类不带 Knex method 的语句同样掉进这条分支,同样丢。

on-disconnect 也救不了它:最后那次 flush 用的是同一个 dirty 标志位。两种持久化策略一起漏。

sys_user 之所以还在,是因为 better-auth 的插入没走 RETURNING;这也是当初这个 bug 能长期潜伏的原因之一——objectstack dev 那种"跑到被杀"的用法里,身份/配置类数据看起来是持久的。

修法:让"是否变更了数据库"只有一个答案

不在 harness 里补 flush()(那是把驱动的错误结论当成既定事实去绕开),而是把分类执行彻底拆开:

export function statementMutatesDatabase(sql: string, method?: string): boolean
  • 同时看 Knex method 和 SQL 文本,所以既不会因为"走了返回行的分支"漏掉,也不会因为"没带 method"漏掉;
  • _query 重构成两段:① EXECUTE(三种执行形态,都不做持久化决策)→ ② PERSIST(唯一一处决策)。这样"某条分支忘了说自己写过"在结构上不再可能;
  • 事务控制语句仍然只交给 noteTransactionControl,继续把 flush 推迟到事务真正关闭(sql.js 的 export() 会 close+reopen,事务中导出会把事务冲掉 —— sqlite-wasm: authenticated record insert fails with "COMMIT; - cannot commit - no transaction is active" #1494);
  • 顺带把会改文件的 PRAGMA 赋值(auto_vacuumuser_version)也算成写。

同时 WasmSqliteConnection.markDirty(method?) 改成 markDirty()。它原本会拿调用方给的 method 再过一遍自己的白名单——"这条语句写了吗"于是有两个判断点,而这两个点恰好就是分歧发生的地方。现在方言分类、连接执行,一个决策一个归属。

结果:stop() 返回 ⇒ 数据已落盘

这条保证现在是结构性的,不需要调用方配合。packages/verifydatabaseFile 的 TSDoc 补了这句话,顺带写明"冷启动看到表没有行 = 驱动 bug,不是 fixture 忘了等"。

测试

flow-durable-suspend.dogfood.test.ts 的 KNOWN GAP 注释删掉了,换成它当初就是为之而写的那个用例:

✓ ordinary business rows survive the restart — `stop()` returning means DURABLE
✓ the `paused` row survives the restart with its continuation intact
✓ the cold kernel RESUMES the run and takes the right branch
✓ the resumed result is itself durable — a THIRD boot still reads it

第一条是刻意排在最前的:当初把这个问题识别成驱动缺陷、而不是挂起存储缺陷的关键事实,就是普通业务记录也活不过重启。留着它,以后回归时能直接告诉你坏在哪一层。第四条则堵住"resume 只在 WASM 堆里成功"这种假绿。

驱动层新增 sqlite-wasm-driver-returning-persist.test.ts,逐条钉住每种执行形态(INSERT … RETURNINGUPDATE … RETURNINGknex.raw、事务内提交、on-disconnect),外加对 statementMutatesDatabase 本身的分类断言。

回滚验证(把两个源文件 stash 掉重新 build):

驱动:  Tests  6 failed (6)
        AssertionError: expected [] to deeply equal [ 'a1' ]
dogfood: Tests  4 failed | 6 passed (10)
        AssertionError: the note written by the first kernel is gone:
          {"error":"Record 8XE_7uCP_utEcCQb not found in suspend_note", …}: expected 404 to be 200
        AssertionError: {"code":"RESOURCE_NOT_FOUND","message":"No suspended run 'run_c7e3…'"}: expected 404 to be less than 300

注:on-disconnect 那条用例一开始是"假绿"的——建表 DDL 本来就会标脏,而 on-disconnect 直到 close 才 flush,于是那次导出顺手把没标脏的行也带上了。加了一次显式 flush() 把标志位清掉,才复现出 on-write 在生产里的真实处境(每次 flush 都清标志,之后再没人置位)。这一条现在也随回滚失败。

全量:

@objectstack/driver-sqlite-wasm   Test Files  15 passed (15)   Tests  214 passed (214)
@objectstack/dogfood              Test Files  76 passed | 1 skipped (77)   Tests  442 passed | 3 skipped (445)
@objectstack/driver-sql           Test Files  54 passed | 4 skipped (58)   Tests  623 passed | 38 skipped (661)
@objectstack/service-datasource   Test Files  11 passed (11)   Tests  197 passed (197)
@objectstack/runtime              Test Files  74 passed (74)   Tests  1046 passed (1046)
@objectstack/verify               Test Files   2 passed (2)    Tests    7 passed (7)
typecheck (driver-sqlite-wasm / verify)  clean
eslint --no-inline-config (driver-sqlite-wasm, qa/dogfood, verify)  clean

已 merge 最新 origin/main(a7163ea)并按 AGENTS.md §9 重装 + 全量 build 后复跑。

影响面提醒

文件型 wasm SQLite 现在真的会on-write 落盘,写入次数会明显多于从前——从前几乎为零。:memory: 不受影响。

🤖 Generated with Claude Code


Generated by Claude Code

… e2e (#4518)

A file-backed sqlite-wasm database flushed its schema at boot and then
recorded nothing else: every table on disk, every subsequent row only in
the WASM heap. `bootStack({ databaseFile })` therefore could not cold-boot,
which blocked #4470's third minimal form.

Root cause is in the Knex dialect, not the harness. `_query` picked its
execution branch from "does this statement return rows" and then set the
dirty flag only on the other, row-less branch. `INSERT ... RETURNING *`
returns rows — and that is the shape ObjectQL writes with — so it executed
on the row-returning branch and never marked the database dirty. The
`on-disconnect` flush is gated on the same flag, so both persist strategies
dropped the write; `knex.raw('INSERT ...')` (no Knex `method`) was lost the
same way.

"Does this statement change the database?" is now one exported predicate,
`statementMutatesDatabase(sql, method)`, classifying by method AND SQL text
and applied at a single funnel after execution — independent of which branch
ran it. Transaction control still routes to `noteTransactionControl` so
flushes stay deferred until a transaction closes (#1494); mutating PRAGMA
assignments now count as writes. `WasmSqliteConnection.markDirty()` loses
its method argument: re-filtering there made the same decision in two places
that could disagree, which is precisely how the branches diverged.

Tests: a new driver-level suite pins every execution branch (all six fail
when the fix is reverted), and `flow-durable-suspend.dogfood.test.ts` loses
its KNOWN GAP — it now suspends, shuts the kernel down, cold-boots a second
kernel over the same file, resumes there, and proves the result survives a
third boot, plus the plain-record assertion that identified this as a driver
defect rather than a suspended-run one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 2, 2026 3:21am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-sqlite-wasm, @objectstack/verify.

7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-sqlite-wasm)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sqlite-wasm)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-sqlite-wasm)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-sqlite-wasm)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-sqlite-wasm)
  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-sqlite-wasm, @objectstack/verify)
  • content/docs/releases/v15.mdx (via @objectstack/verify)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added the size/l label Aug 2, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 03:24
@os-zhuang
os-zhuang enabled auto-merge August 2, 2026 03:24
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 2, 2026
Merged via the queue into main with commit 24915d2 Aug 2, 2026
21 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-4518-bootstack-durable-flush branch August 2, 2026 03:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[verify] bootStack({ databaseFile }) 的数据活不过进程内重启 —— 表在、行没了,挡住冷启动类 e2e

2 participants