Repository navigation
feat(objectql): restore a recycle-bin entry under its original ids and emit data.record.restored (#30237 PR 3) - #31220
Conversation
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y
…e-bin restore Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y
…isposition Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y
…-2sxr1i Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y
…in with RESTORE_PARENT_IN_RECYCLE_BIN A detail whose master-detail parent is not live but is the record of another recycle-bin entry now refuses with its own 409 code, naming that entry (parentEntryId) beside parentObject / parentId. Unlike the other three refusals it is not permanent: restoring the parent's entry first clears it. The code joins RECYCLE_BIN_RESTORE_REFUSAL_CODES (parentInRecycleBin) and the error-code ledger under @objectstack/objectql; ADR-0148 D2 gains the bullet and Consequences the sentence. Replaces the interim RESTORE_PARENT_PURGED answer for this case. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y
📓 Docs Drift CheckThis PR changes 2 package(s): 54 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 173 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 4c8ff42ffca4c8d4158616c2f4cbc1667bfd44cd && git checkout 4c8ff42ffca4c8d4158616c2f4cbc1667bfd44cd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cba7d8ae719f22e80ae7b336e4ee3a15d5c3057b 32307090b166c9c1ca73da26fa100ecbf3055b6e && git checkout -B drift-repro cba7d8ae719f22e80ae7b336e4ee3a15d5c3057b && git merge --no-ff 32307090b166c9c1ca73da26fa100ecbf3055b6e
node scripts/docs-audit/affected-docs.mjs --json cba7d8ae719f22e80ae7b336e4ee3a15d5c3057b
|
|
domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx Contract review — CONTRACT_REVIEW_TIERHead-sha: Round 1 · 2026-11-07T18:20Z Scope, per the narrowed rule of 2026-11-07: the spec hunks ( Verdict: CHANGES — one must-fix and one should-fix, both local to Conforms (checked):
Must-fix — a parent that is in the bin as a DETAIL of another entry is refused as purged. Should-fix — a transient refusal can hide a permanent one. Nits (optional): the four code strings and the 409 are hard-coded in Dev questions, seat calls:
The patch round's delta review is Fable over |
…ry, and answer permanent restore refusals first Contract review round 1 on the recycle-bin restore: - A master-detail parent that went as a DETAIL of another entry's cascade (account <- contact <- task: the task deleted alone, then the account) is no entry's master. parentEntryOf now walks the entries the caller can see by key when the master lookup finds nothing, and searches each snapshot's rows, so the refusal is RESTORE_PARENT_IN_RECYCLE_BIN naming that entry instead of RESTORE_PARENT_PURGED. - judgeRestore judges every row for the permanent refusals first and answers a parent in the bin only when none stands, so a temporary reason never hides a permanent one. - A parent whose object is no longer registered is answered RESTORE_PARENT_PURGED. - A comment says a failed restore's re-link undo publishes a second update event. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y
|
domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx Contract review — delta round 2Head-sha: Delta review · 2026-11-07T19:04Z Scope: Verdict: both round-1 findings are fixed. One should-fix (cost) remains before ACCEPT.
Should-fix — memoize the walk. Nits (optional): an unregistered parent object answers After that patch, the seat reviews the delta in-seat and then posts ACCEPT. The PR is Tier H, so a human merges it. |
Contract review round 2: parentEntryOf walked the whole bin once per (row, field) on the miss path, so an entry of 500 details under one parent held as a cascade row of another entry walked it 500 times. judgeRestore now collects the parents that are neither live nor in the entry across every row and field (each parent probed once), and parentEntriesOf answers all of them with one master lookup per parent object and at most one keyset walk, stopping once every parent is answered. Also: a parent whose object is unregistered is refused with wording that says so; a walk the store cut short throws a store-defect error rather than a refusal it did not establish; the developer messages say "held by" another entry. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y
|
domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx Contract review — ACCEPTHead-sha: ACCEPT · Tier H (docs/adr) · 2026-11-07T19:47Z This is the in-seat delta over
Review record for the PR: round 1 Fable 6711252037 (CHANGES), round 2 Fable delta 6711739005 (one should-fix), round 3 in-seat delta (this). The ADR-0148 D5 table stays a Known gap in the PR body. Landing: Tier H ( |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 42429362317 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
domain:server#2 · marchtian · session_017ihL2fsWoxB3no3RuuUR8y Merge queue dequeue at 00:41Z (
Generated by Claude Code |
…PR 3) Main's SCHEMA_UPGRADE_CONFIRMATION_REQUIRED and this branch's RESTORE_PARENT_IN_RECYCLE_BIN each moved the ApiErrorSchema.code count by one; the merged tree is regenerated with gen:schema && gen:docs. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y
|
domain:server#2 · marchtian · session_017ihL2fsWoxB3no3RuuUR8y Second merge queue dequeue at 01:37Z (
The head moved, so this needs approval again and a re-queue. Generated by Claude Code |
|
domain:server#2 · marchtian · session_017ihL2fBJ6xsLWJsnNJGMMx Contract review — ACCEPT (delta)Head-sha: ACCEPT · delta over
|
Requested by 田騏 · project thread
Part of #30237
Clause-②: yes (widening)
Metadata-protocol: none
Skills-docs: follow-up #30847
维护者速读
ObjectQL.restore(entryId)把一条回收站记录按原 id 写回(主记录先、明细随后),保留创建人和创建时间,不跑新建钩子;删除时被清空的引用在没人改过的前提下重新挂回;汇总字段重算;每行发一个data.record.restored事件;成功后这条回收站记录被删掉。spec 只动了您同意的范围:两个事件枚举加data.record.restored,回收站常量注释和测试钉子翻转;按您 16:53Z 选的 C,新增第四个拒绝码RESTORE_PARENT_IN_RECYCLE_BIN(码表、错误码台账各一行),ADR-0148 D2 加一条、Consequences 加一句。packages/objectql/src/recycle-bin/restore.ts(judgeRestore四个拒绝、writeBack的事务与逐笔撤销);docs/adr/0148-…md的 D2 与 Consequences;packages/spec/src/data/recycle-bin.zod.ts码表;packages/spec/src/api/events.zod.ts与realtime.zod.ts(枚举加值)。pnpm --filter @objectstack/objectql exec vitest run src/recycle-bin/restore.test.ts,应看到 26 个用例全过,含四个 409 和"不跑新建钩子"。docs/adr/,属 Tier H:席位用 Fable 复核 spec、ADR 和实现拒绝的代码,ACCEPT 后请您亲手合并或入队。What changes
ObjectQL.restore(entryId, { context })(new,packages/objectql/src/recycle-bin/restore.ts; the engine keeps a delegation stub, so the facade gate holds with no new registry row):nullwhen no entry is visible or no bin is registered. A malformed entry id is refused400 VALIDATION_ERROR(param: 'entryId'), aspurge(entryId)refuses one.restoreoperation on the business object of the entry's master row, with noastand no id, aspurge(entryId)is dispatched.409in the ADR-0112 envelope withobject,recordId,field,entryIdwhere they apply, and writes nothing; the entry stays:RESTORE_PACKAGE_UNINSTALLED: an object of the entry is no longer registered;RESTORE_PARENT_PURGED: a row's master-detail parent is neither live, nor a row of this entry, nor held by another entry, or its object is no longer registered (parentObject,parentId);RESTORE_PARENT_IN_RECYCLE_BIN: a row's master-detail parent is not live but is held by another entry, as that entry's master or as a row of its cascade (parentObject,parentId,parentEntryId). The parents that are neither live nor in the entry are collected across every row and field first (each probed once), then answered together: one indexed master lookup per parent object, and for the rest (the cascade-row case,account ← contact ← task: the task deleted alone, then the account) at most ONE walk over the entries the caller can see by key (keysetWalk, pages of 200), each snapshot searched, stopping once every parent is answered. All on the refusal path only; a walk the store cut short throws a store-defect error, not a refusal. Unlike the other three it is not permanent: restoring the parent's entry first lets this one through, which two tests drive end to end. It is answered only after every row has been judged for the three permanent refusals, so it never hides one;RESTORE_UNIQUE_VALUE_TAKEN: a live row holds the id or a unique value (unique: true | 'organization'scoped to the row's own organization,'global'unscoped) of a snapshotted row. A value the caller cannot see (a global unique across organizations) is caught by the store's own unique constraint at the write, and answered with the same code.created_by/created_atas the snapshot holds them, no insert hook; then the re-links; then the entry's removal (last, so a restore that did not land keeps its entry). One transaction when every object involved is stored by the bin's driver; otherwise each write already made is undone when a later one fails, and the split is logged atwarn.engine.updatewith the restore's context, as the delete'sset_nullclear went: a single-valued field while it still holds no value; a multi-valued one while it no longer holds the member (the member is appended).recomputeSummariescall per object; a failure surfaces asSummaryRecomputeErrorafter the rows are back, as on insert.data.record.restoredper row, master first, through the engine's ownpublishDataEvent(credential mask,internalomission andorganizationIdoff the row, as every record event).Engine shape (hand-over 6690307585, "How PR 2 shaped the engine"):
OperationContext['operation']gains'restore';ENGINE_OPERATION_VERBS.restore = false, sobeforeRestore/afterRestorestay outside the hook namespace andregisterHookis not narrowed.restorejoinsWRITE_EPOCH_OPERATIONS.opCtx.operationraw, sopurge/transfer/restorederive no row scope whileupdate/deletenow do (#7665 asymmetry) #7809 pin (engine-middleware-operation-vocabulary.test.ts) holds 9 verbs;restoreis a dispatched destructive verb, never with an AST, object-level only.DESTRUCTIVE_OPERATIONSalready refusesrestorefail-closed for every non-system caller. A new pin insecurity-plugin.test.tsholds that for the engine's object-level dispatch, for a caller holding every CRUD bit, both super-user bypasses and both new grant keys, with no row read.Spec (consent records 6709046255, 「A 同意三处(推荐)」, and 6710220841, ruling C 「C 新码,并入 PR 3(推荐)」),
Clause-②: yes (widening):packages/spec/src/api/events.zod.ts:DataEventTypegains'data.record.restored'; its docblock says four events and namesRECORD_RESTORED_EVENT.packages/spec/src/api/realtime.zod.ts:RealtimeEventTypegains the same value; its docblock lists it.packages/spec/src/data/recycle-bin.zod.ts: the "NAMED, NOT YET EMITTED" comment flips to emitted;recycle-bin.zod.test.ts's pin flips to "a member of both enums".packages/spec/src/api/events.test.ts: theDataEventType.optionsequality pin gains the value (seat ruling Q2, recorded on engine: snapshot deletes into the recycle bin, restore under original ids, purge, and the TTL reap (#3146 slice 3) #30237, the mechanical consequence of the consented enum change).packages/spec/src/data/recycle-bin.zod.ts:RECYCLE_BIN_RESTORE_REFUSAL_CODESgainsparentInRecycleBin: 'RESTORE_PARENT_IN_RECYCLE_BIN'; the doc comment says four codes, the first three permanent for the entry, the fourth clearing once the parent's entry is restored.recycle-bin.zod.test.tspins four codes.packages/spec/src/api/error-code-ledger.zod.ts: the code joins the@objectstack/objectqlblock (the block's comment says four);error-code-ledger.test.tspins the four values in order.content/docs/references/api/events.mdx,realtime.mdx,error-code-ledger.mdx,automation-api.mdx,contract.mdx(gen:docs). No export added or removed in spec.ADR (Tier H, ruling 6710220841):
docs/adr/0148-…mdD2 gains a fourth refusal bullet (the parent is in the bin; dated, citing the ruling record), and Consequences gains one sentence: the fourth refusal is not permanent. The status line, D5's reader table and the rest are untouched.Acceptance notes
RESTORE_PARENT_IN_RECYCLE_BIN, notRESTORE_PARENT_PURGED. The interim answer is gone with no fallback; an ablation that stamps the interim code back turns the refusal's tests red.511372165: a parent held as a cascade row of another entry was answeredRESTORE_PARENT_PURGED(must-fix); a parent in the bin could hide a permanent refusal of a later row (should-fix); a parent whose object is unregistered now answersRESTORE_PARENT_PURGED. The refusal codes and 409 stay literal stamps inrestore.ts, the formcheck:error-status-conformancereads (its assignment reader pairserr.code = '...'with the status); a test pins the four codes and 409 by value againstRECYCLE_BIN_RESTORE_REFUSAL_CODES/_STATUS.82b042200: the miss path walked the bin once per (row, field); it now walks it at most once per restore, pinned by a test that counts bin reads (3 for five lines under one parent). An unregistered parent object's refusal now says the object no longer exists, rather than "deleted for good".data.record.updatedevents for that row (the re-link and its undo), since both go throughengine.update; a comment inwriteBacksays so.indexes[].unique) is not probed before the write; the store's constraint catches it at the write and it is answeredRESTORE_UNIQUE_VALUE_TAKEN, the unit undone.@objectstack/client'ssubscribeToRecordsstill lists created/updated/deleted only (an explicit list, not an exhaustive switch); whether it also subscribes torestoredby default is PR 5's / ui: the admin recycle-bin page (list, restore, empty) and the 已删除 · 撤销 toast (#3146 slice 4) #30239's choice.service-knowledgedoes not re-index a restored record; it listens to created/updated only.@objectstack/specminor, but the claim declaresMetadata-protocol: none, and AGENTS.md step 3 /check-changeset-no-majorgrade anonechangepatch. The changeset ispatchfor both packages.Tests
All on head
82b042200(the merge oforigin/main13c6d8f, the ruling-C commit, and the contract-review round 1 and 2 fixes):pnpm --filter @objectstack/objectql exec vitest run src/recycle-bin/restore.test.ts: Tests 31 passed (31). Round trip and dispatch shape,created_by/created_atkept, no insert hook, restored events, roll-up recompute, organization scoping, null and bad-id answers, the security refusal and the system path, re-link for both arities (changed since, row gone, therelinkedValueunits), and the refusals:RESTORE_PACKAGE_UNINSTALLED;RESTORE_PARENT_PURGED(parent purged; parent object unregistered);RESTORE_PARENT_IN_RECYCLE_BIN(parent the master of another entry; grandchild whose parent is a cascade row of another entry, each refused, then the parent's entry restored, then the row restores); five lines under one parent held as a cascade row answered with 3 bin reads; a permanent refusal (id taken) winning over a parent in the bin;RESTORE_UNIQUE_VALUE_TAKEN(a value, a per-organization value in another organization allowed, the id, and the store's own violation with the undo); the four codes and 409 pinned by value against the spec constants.@objectstack/objectqlsuite on82b042200: Test Files 566 passed (566), Tests 11458 passed (11458).ff9ad72fb(the review fixes touch onlyrestore.tsand its test): full@objectstack/plugin-securityTest Files 248 passed, Tests 5821 passed, 45 skipped, with the new pin[#30237] DENIES the engine's object-level restore dispatch fail-closed; full@objectstack/specTest Files 911 passed, Tests 27487 passed, 1 todo.Ablations on
82b042200(scripts/ablation-replace.mjs, each mutation proven on disk, then restored to the HEAD blob with an emptygit diff HEAD). Clean run: Tests 31 passed (31). Each mutated run:expected 11 to be 3)a permanent refusal wins over RESTORE_PARENT_IN_RECYCLE_BINRESTORE_PARENT_PURGED: a parent whose object is no longer registeredRESTORE_PARENT_PURGED: a detail whose master was deleted for goodRESTORE_UNIQUE_VALUE_TAKEN: a live row holds a unique valuea live row holds the idand the permanent-first testRESTORE_PARENT_PURGEDfor the in-bin case)RESTORE_PARENT_IN_RECYCLE_BINtestRESTORE_PACKAGE_UNINSTALLEDinsert(two files:restore.tsplus aninsertarrow held on the engine host)runs no insert hook(receivedbeforeInsert:account,afterInsert:account, ...)'restore'removed from plugin-securityDESTRUCTIVE_OPERATIONS(vitest run src/security-plugin.test.ts -t 30237, onff9ad72fb)promise resolved ... instead of rejecting; clean run 1 passedVerification
On head
82b042200:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 119 commands, each run and recorded asCMD :: exit N; all 119 exit 0.--ran: 119 derived, 119 run, 0 NOT-MEASURED, 0 UNRUN.pnpm --filter @objectstack/objectql run typecheck: exit 0. ESLint onpackages/objectql/src/recycle-bin/: exit 0.ff9ad72fb:pnpm lintexit 0;@objectstack/plugin-securityand@objectstack/spectypecheck exit 0; a whole-workspacepnpm build(119 tasks) ran before the gate rounds, and@objectstack/objectqlwas rebuilt for each later head.🤖 Generated with Claude Code
https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y