Repository navigation
fix(objectql): a by-id delete the driver answers as removing nothing leaves no recycle-bin entry - #31702
Conversation
…leaves no recycle-bin entry The capture writes the entry before the root row is removed; when the driver answers false (or an off-contract 0) the entry is now withdrawn inside the same unit, so the bin keeps no entry for a row that is still live. Claude-Session: https://claude.ai/code/session_01ADHkmR6Bca8k3KKZsr3Ej4 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0dd5db2fc95b1aa7db36f2dc68351d17e48c0632 && git checkout 0dd5db2fc95b1aa7db36f2dc68351d17e48c0632
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 82a3ed2e861d260fdd65517e6b17f03c42eb90c2 c769a547bca53b06f472cfe4fcfd75fe5b2c4876 && git checkout -B drift-repro 82a3ed2e861d260fdd65517e6b17f03c42eb90c2 && git merge --no-ff c769a547bca53b06f472cfe4fcfd75fe5b2c4876
node scripts/docs-audit/affected-docs.mjs --json 82a3ed2e861d260fdd65517e6b17f03c42eb90c2 |
Claude-Session: https://claude.ai/code/session_01ADHkmR6Bca8k3KKZsr3Ej4 Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#4 · os-steve · session_01ADHkmP8JJo4zF2sjTBRwnX Flake reading · PR #31702 · head
|
|
domain:server#4 · os-steve · session_01ADHkmP8JJo4zF2sjTBRwnX ACCEPT · PR #31702 · #31630 · 2026-11-08T23:18ZHead-sha:
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42536035987 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:server#4 · os-steve · session_01ADHkmP8JJo4zF2sjTBRwnX Queue failure reading · PR #31702 · head The merge queue dequeued this PR. In merge-group CI run 42536035987, job The failure is in the same That reading said a second failure is treated as real, and this is the second one. The CI runs on the last 10 The loop calls |
Claude-Session: https://claude.ai/code/session_01ADHkmR6Bca8k3KKZsr3Ej4 Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#4 · os-steve · session_01ADHkmP8JJo4zF2sjTBRwnX ACCEPT (re-queue) · PR #31702 · #31630 · 2026-11-09T01:04ZHead-sha:
|
Requested by Steve · project thread
Fixes #31630
Clause-②: no
Metadata-protocol: none
维护者速读
false或0),引擎现在会在同一个事务/工作单元里把刚写进回收站的那条快照撤回,回收站不再留下指向一条仍然存在的记录的无效条目。RESTORE_UNIQUE_VALUE_TAKEN拒绝,直到 TTL 清理才消失。recycleBinEntryId的回答都不变。撤回失败只记一条error日志,不改删除的返回值。回滚即整体 revert。packages/objectql/src/engine.ts的runByIdDelete(新增的else await entry?.withdraw())②packages/objectql/src/recycle-bin/capture.ts的RecycleBinCapture.write返回的withdraw/compensate(共用一个remove)③capture.test.ts新describe。pnpm --filter @objectstack/objectql exec vitest run src/recycle-bin/capture.test.ts,预期 27 条全过,其中新增 6 条。What changes
@objectstack/objectql, the by-id delete arm (runByIdDeleteinengine.ts). After the root row'sdriver.delete, an answer offalse(not found) or an off-contract0now callsentry.withdraw(): the recycle-bin entry this delete wrote is removed through the bin's driver with the driver options it was written with, so inside the delete's transaction when one covers the bin, and immediately otherwise. A confirmed removal is unchanged: exactly one entry, andonRecycleBinEntryis told its id. TherecycleBinEntryIdbehaviour from PR feat(rest): the recycle bin has REST doors, and a delete answer names the entry to undo (#30237 PR 5) #31595 is untouched (still withheld forfalse/0).recycle-bin/capture.ts.RecycleBinWrittenEntrygainswithdraw().compensate()(a removal that threw, no transaction) andwithdraw()share oneremove(outcome)that keeps the existing failure handling: no entry id ⇒ oneerrorline; the bin's delete throws ⇒ oneerrorline with the driver fault redacted (redactBoundStatement), never replacing the delete's own answer or error. The module docblock gains step 5 and says why the entry is still written first..changeset/31630-recycle-bin-no-ghost-entry.md(@objectstack/objectqlpatch): admins no longer see entries in the recycle-bin list for records that are still live.Why withdraw (a) and not write-after-confirm (b)
main(table below): every in-tree driver answersfalsefor a row it did not remove, so the case is reachable on memory, SQL and Mongo alike, not only off-contract.planCascadeAtomicity→'none', the common case) and none across datasources ('split'). There, (b) would write the entry after the row is gone, and a bin write that then fails would leave a removed row with no entry to undo it and the caller an error for a delete that happened; nothing can compensate that. Written first, as today, a bin that cannot take the entry refuses the delete before the row goes. (a) keeps that guarantee and adds its converse: no entry without its removal.compensatefor a throw,withdrawfor a removal that removed nothing), chosen at the one place the driver's answer is read. The ordering stays the one the module docblock already explains; (b) would move three documented steps.elsebranch; no spec, wire, error code or option moves.Step 0 (measured on
main@8c9260f79)runByIdDelete)openRecycleBinCapture→cascadeDeleteRelations(collect, judge restricts,onCollectedreads the snapshot, walk) →bin.write()(rawdriver.createonsys_recycle_bin) →driver.delete(root)planCascadeAtomicity'atomic'⇒ all of it runs insidethis.transaction(...)and the entry is written with that handle;'none'/'split'⇒ no transaction, the entry is written bare.false/0onmainsys_recycle_bin;recycleBinEntryIdis withheld (removed !== false && removed !== 0gate); the delete answersfalse/0.compensate()runs only on a throw.MemoryDriver.delete)falsewhen no row has the id (throws instead understrictMode);trueafter the splice.SqlDriver#deletebody insql-driver-sequence-semantics.ts)count > 0:falsewhen the id + tenant scope matched no row (each rotation shard probed newest-first). Covers sqlite-wasm and turso local too (they inheritSqlDriver).MongoDBDriver.delete)deletedCount > 0:falsewhen nothing matched.0engine-delete-cascade.ts/ the walk inengine-cascade-planning.ts)this.delete()inside the inherited set, whereopenRecycleBinCapturereturnsundefined(a member of the set is a row of its root's entry), and the predicate arm (multi) opens no capture. No entry is written ahead of an unconfirmed removal there, so nothing to fix; see Acceptance notes for a neighbouring question.Tests
New top-level
describein the existing harnesspackages/objectql/src/recycle-bin/capture.test.ts(its fake driver gains aremovesNothingOfswitch: answers the given value and keeps the row). No existing test edited.create).falseand0, no transaction (loner): the delete answers the same value, the row is still there, no entry, the bin sawcreatethendelete(written then withdrawn, not merely never written), no callback, noerrorlog.falseand0, inside the cascade's transaction (accounttree): no entry, and the withdrawal carried the same transaction handle as the write.false, the entry stays, oneerrorline.Ablation (red, then restored)
node scripts/ablation-replace.mjs --file packages/objectql/src/engine.tsreplacingelse await entry?.withdraw();withelse void 0;(anchor 1 → 0, bloba501f350c801→6d46e7296474); the suite imports../engine.jsfrom source, so no dist leg.Restored: blob after restore
a501f350c801== HEAD,git diff HEADempty. The positive pin (exactly one entry) stays green under the ablation, as it should.Acceptance notes
falseafter the walk. In the'atomic'plan the walk has already removed the root's cascade children (and clearedset_nullreferences) when the root's own removal answersfalse; the transaction commits because nothing threw. The withdrawn entry was the only snapshot of those children. Before this PR that entry stayed, but while the root is live its restore is refusedRESTORE_UNIQUE_VALUE_TAKENfor the whole entry (the entry is the unit), so that undo was not reachable. Where the root was already gone (another actor removed it between the snapshot read and this removal), the old entry could have brought the root and those children back, and now there is none. Whether that case should roll the unit back instead is a question about the delete's answer, not about the entry; it is left to the seat (reportopen_questions).falsefor keeps its row in the root's snapshot. Not this card's shape (the entry is the root's, and its root was removed), and changing the snapshot is outside this card. Noted, not filed (read-only inference, no reproduction).recycleBinEntryIdjsdoc inprotocol.zod.ts(contract review N4) lists when the key is absent; with this PR thefalse/0case also writes no entry, so "absent when the delete wrote none" now covers it. No spec edit.Verification
All readings on head
c7032b19(branch base8c9260f79), throughscripts/pm/os-verify-lock.sh, eachVERDICT command-exit 0:turbo run buildclosure (dogfood, objectql, driver-memory, driver-sql)pnpm --filter @objectstack/objectql testpnpm --filter @objectstack/objectql typecheckeslint packages/objectql --no-inline-config(root config)pnpm --filter @objectstack/driver-memory testpnpm --filter @objectstack/driver-sql testpnpm --filter @objectstack/dogfood test(full, no shard)Derived gates: 69 / 69, reconciled
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 69 commands; all 69 run, each exit 0.--ranreconciliation: "69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED". Three first-pass exit 3s were container prerequisites, not findings, and were re-run green:check-plugin-teardown-shape.mjs --self-test(fixture commit fetched),check:dual-build-cjs-loads(after a fullpnpm build),check:type-check-debt(lock queue-timeout behind this card's own dogfood run).test:repowas not run locally (no diff in that project); CI's Test Core carries it.Generated by Claude Code