Skip to content

Commit 69c7cd6

Browse files
feat(objectql): restore a recycle-bin entry under its original ids and emit data.record.restored (#30237 PR 3) (#31220)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi1a44tVerUCrhBK4MJbjCZx)_ 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 加一句。 - 为什么改:ADR-0148 的第三步(#30237 拆分的 PR 3)。删除进回收站(PR 1)、彻底删除/清空(PR 2)已上线,没有恢复,回收站就只进不出。 - 风险与回滚:恢复要么整条成功要么什么都不写:唯一值被占、母记录已彻底删除、母记录还在回收站、对象已卸载这四种情况一律 409 拒绝,记录留在回收站;其中"母记录还在回收站"会给出母记录所在条目的 id,先恢复它就能再恢复子记录。权限读取要等 PR 4,在那之前非系统调用者一律被安全插件拒绝,所以这个 PR 上线后普通用户还用不到恢复,只有系统上下文能用。回滚就是 revert 本 PR。 - 先看:`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 和"不跑新建钩子"。 - 需要你做的:本 PR 改了 `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): - Reads the entry raw through the bin's driver, scoped to the caller's organization, and answers `null` when no entry is visible or no bin is registered. A malformed entry id is refused `400 VALIDATION_ERROR` (`param: 'entryId'`), as `purge(entryId)` refuses one. - Dispatches ONE `restore` operation on the business object of the entry's master row, with no `ast` and no id, as `purge(entryId)` is dispatched. - **Judges before it writes** (reads only, as the caller). Each refusal is `409` in the ADR-0112 envelope with `object`, `recordId`, `field`, `entryId` where 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. - **Writes back in one unit of work**: the rows raw through each object's driver, the master first, under their original ids, `created_by` / `created_at` as 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 at `warn`. - **Re-link (ADR-0148 D2, both arities per the #30577 review should-fix 1)** through `engine.update` with the restore's context, as the delete's `set_null` clear 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). - **Roll-ups** recompute from the rows that are back, one `recomputeSummaries` call per object; a failure surfaces as `SummaryRecomputeError` after the rows are back, as on insert. - **Events**: one `data.record.restored` per row, master first, through the engine's own `publishDataEvent` (credential mask, `internal` omission and `organizationId` off 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`, so `beforeRestore` / `afterRestore` stay outside the hook namespace and `registerHook` is not narrowed. - `restore` joins `WRITE_EPOCH_OPERATIONS`. - The #7809 pin (`engine-middleware-operation-vocabulary.test.ts`) holds 9 verbs; `restore` is a dispatched destructive verb, never with an AST, object-level only. - plugin-security is untouched: `DESTRUCTIVE_OPERATIONS` already refuses `restore` fail-closed for every non-system caller. A new pin in `security-plugin.test.ts` holds 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`: `DataEventType` gains `'data.record.restored'`; its docblock says four events and names `RECORD_RESTORED_EVENT`. - `packages/spec/src/api/realtime.zod.ts`: `RealtimeEventType` gains 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`: the `DataEventType.options` equality pin gains the value (seat ruling Q2, recorded on #30237, the mechanical consequence of the consented enum change). - `packages/spec/src/data/recycle-bin.zod.ts`: `RECYCLE_BIN_RESTORE_REFUSAL_CODES` gains `parentInRecycleBin: '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.ts` pins four codes. - `packages/spec/src/api/error-code-ledger.zod.ts`: the code joins the `@objectstack/objectql` block (the block's comment says four); `error-code-ledger.test.ts` pins the four values in order. - Regenerated: `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-…md` D2 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 - **Parent in the bin (ruling C, 6710220841; replaces the seat's interim Q1 = A):** answered by its own code `RESTORE_PARENT_IN_RECYCLE_BIN`, not `RESTORE_PARENT_PURGED`. The interim answer is gone with no fallback; an ablation that stamps the interim code back turns the refusal's tests red. - Known gap: ADR-0148 D5's reader table still lists the refusal codes in words as "unique value taken, parent purged, package uninstalled". That row is outside the ADR hunk ruling 6710220841 consented to, so this PR leaves it (seat, contract review round 1). - Contract review round 1 (6711252037), fixed in `511372165`: a parent held as a cascade row of another entry was answered `RESTORE_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 answers `RESTORE_PARENT_PURGED`. The refusal codes and 409 stay literal stamps in `restore.ts`, the form `check:error-status-conformance` reads (its assignment reader pairs `err.code = '...'` with the status); a test pins the four codes and 409 by value against `RECYCLE_BIN_RESTORE_REFUSAL_CODES` / `_STATUS`. - Contract review round 2 (6711739005), fixed in `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". - A failed restore outside a transaction that had already re-linked a reference publishes two `data.record.updated` events for that row (the re-link and its undo), since both go through `engine.update`; a comment in `writeBack` says so. - The re-link runs as the restoring caller, as the clear ran as the deleting one. Until PR 4 only the system restores, so nothing can be refused there today; PR 4 decides whether a restorer who may not edit the referencing row re-links it. - A composite unique index (`indexes[].unique`) is not probed before the write; the store's constraint catches it at the write and it is answered `RESTORE_UNIQUE_VALUE_TAKEN`, the unit undone. - A plain (non master-detail) lookup in a restored row that points at a row deleted since stays as the snapshot holds it; ADR-0148 names no refusal for it. - Not in this PR: the permission reader (PR 4), the REST doors (PR 5), the Console (#30239), the skills text (#30847). `@objectstack/client`'s `subscribeToRecords` still lists created/updated/deleted only (an explicit list, not an exhaustive switch); whether it also subscribes to `restored` by default is PR 5's / #30239's choice. `service-knowledge` does not re-index a restored record; it listens to created/updated only. - Dispatch-order conflict, resolved by the rules file: the order asked for `@objectstack/spec` `minor`, but the claim declares `Metadata-protocol: none`, and AGENTS.md step 3 / `check-changeset-no-major` grade a `none` change `patch`. The changeset is `patch` for both packages. ## Tests All on head `82b042200` (the merge of `origin/main` 13c6d8f, 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_at` kept, 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, the `relinkedValue` units), 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. - Full `@objectstack/objectql` suite on `82b042200`: Test Files 566 passed (566), Tests 11458 passed (11458). - On `ff9ad72fb` (the review fixes touch only `restore.ts` and its test): full `@objectstack/plugin-security` Test 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/spec` Test 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 empty `git diff HEAD`). Clean run: Tests 31 passed (31). Each mutated run: | Mutation | Result | |---|---| | bin asked once per missing reference, not once for all | 1 failed, 30 passed: the bin-read count test (`expected 11 to be 3`) | | bin lookup by master only (no snapshot walk) | 2 failed, 29 passed: the grandchild and the read-count tests | | first reason wins (in-bin thrown at the reference, not deferred) | 1 failed, 30 passed: `a permanent refusal wins over RESTORE_PARENT_IN_RECYCLE_BIN` | | unregistered parent object check off | 1 failed, 30 passed: `RESTORE_PARENT_PURGED: a parent whose object is no longer registered` | | purged-before-in-bin check off | 1 failed, 30 passed: `RESTORE_PARENT_PURGED: a detail whose master was deleted for good` | | unique-value probe off | 1 failed, 30 passed: `RESTORE_UNIQUE_VALUE_TAKEN: a live row holds a unique value` | | id probe off | 2 failed, 29 passed: `a live row holds the id` and the permanent-first test | | master-detail parent check off | 5 failed, 26 passed: every parent test | | interim code stamped back (`RESTORE_PARENT_PURGED` for the in-bin case) | 3 failed, 28 passed: every `RESTORE_PARENT_IN_RECYCLE_BIN` test | | package check off | 1 failed, 30 passed: `RESTORE_PACKAGE_UNINSTALLED` | | raw create swapped for the engine's hooked `insert` (two files: `restore.ts` plus an `insert` arrow held on the engine host) | 4 failed, 27 passed, among them `runs no insert hook` (received `beforeInsert:account`, `afterInsert:account`, ...) | | `'restore'` removed from plugin-security `DESTRUCTIVE_OPERATIONS` (`vitest run src/security-plugin.test.ts -t 30237`, on `ff9ad72fb`) | 1 failed: `promise resolved ... instead of rejecting`; clean run 1 passed | ## Verification On head `82b042200`: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 119 commands, each run and recorded as `CMD :: 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 on `packages/objectql/src/recycle-bin/`: exit 0. - On `ff9ad72fb`: `pnpm lint` exit 0; `@objectstack/plugin-security` and `@objectstack/spec` typecheck exit 0; a whole-workspace `pnpm build` (119 tasks) ran before the gate rounds, and `@objectstack/objectql` was rebuilt for each later head. - CI check-runs are not cited: this thread does not wait on CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2fsWoxB3no3RuuUR8y <!-- signed: domain:server#2 · marchtian · session_017ihL2fsWoxB3no3RuuUR8y --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c0068ed commit 69c7cd6

26 files changed

Lines changed: 1427 additions & 60 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/objectql': patch
4+
---
5+
6+
feat(objectql): restore a recycle-bin entry under its original ids, and publish `data.record.restored` (ADR-0148 D2)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
`ObjectQL.restore(entryId, { context })` writes one recycle-bin entry back: every row of the entry, the master first and its master-detail details after it, under the ids they had, with `created_by` / `created_at` as they were. No insert hook runs, no default or autonumber is issued: a restore is not a create. The references the delete cleared (`set_null` lookups) are re-linked where nothing has changed them since: a single-valued field while it still holds no value, a multi-valued one while it no longer holds the member. Roll-ups recompute from the rows that are back, one `data.record.restored` event is published per row, and the entry is removed. It answers what came back (`RecycleBinRestoreResult`: the entry, the master's object and id, every row, how many references were re-linked), or `null` when no entry with that id is visible to the caller's organization or the composition registers no bin.
12+
13+
The entry is the unit: every row comes back or none does. A restore that would write what it should not is refused `409` before anything is written, and the entry stays:
14+
15+
- `RESTORE_PACKAGE_UNINSTALLED`: an object of the entry is no longer registered.
16+
- `RESTORE_PARENT_PURGED`: a row's master-detail parent is neither live, nor a row of the entry, nor the record of another recycle-bin entry.
17+
- `RESTORE_PARENT_IN_RECYCLE_BIN`: a row's master-detail parent is not live but is the record of another recycle-bin entry. The refusal names that entry (`parentEntryId`, beside `parentObject` / `parentId`) and says to restore it first; unlike the other three, it clears once that entry is restored.
18+
- `RESTORE_UNIQUE_VALUE_TAKEN`: a live row holds the id or a unique value of a snapshotted row, found by a probe before the write or by the store's own unique constraint at it.
19+
20+
The writes run in one transaction when every object involved is stored by the recycle bin's driver; otherwise each write already made is undone when a later one fails.
21+
22+
The restore is dispatched as the new `restore` operation on the business object, with no query AST and no id, like `purge(entryId)`. `beforeRestore` / `afterRestore` stay outside the engine's hook namespace, so registering them only warns, as before. `restore` advances the write epoch. Until `@objectstack/plugin-security` maps the verb to `allowRestoreOwnDeleted` / `manageRecycleBin`, its fail-closed backstop refuses every restore by a caller that is not the system.
23+
24+
`@objectstack/spec`: `DataEventType` and `RealtimeEventType` gain `data.record.restored` (`RECORD_RESTORED_EVENT`), now that the engine emits it. A pure widening: a consumer that matches the event names it knows keeps working, and one that wants restores subscribes to the new name. A consumer keyed on `data.record.created` does not see a restore.
25+
26+
`@objectstack/spec`: `RECYCLE_BIN_RESTORE_REFUSAL_CODES` gains `parentInRecycleBin` (`'RESTORE_PARENT_IN_RECYCLE_BIN'`), registered in the error-code ledger under `@objectstack/objectql`, 409 like the other three. Also a widening: a consumer that switches over the codes it knows keeps working and treats the new one as an unknown 409; one that wants to offer "restore the parent first" reads `parentEntryId`.

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ const result = AutomationApiErrorCode.parse(data);
417417

418418
| Property | Type | Required | Description |
419419
| :--- | :--- | :--- | :--- |
420-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +362 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
420+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +363 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
421421
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
422422

423423
### Allowed Values: `FlowInnerRefusal.code`
@@ -707,6 +707,7 @@ const result = AutomationApiErrorCode.parse(data);
707707
* `RESEED_NO_ROWS`
708708
* `RESEED_SKIPPED`
709709
* `RESTORE_PACKAGE_UNINSTALLED`
710+
* `RESTORE_PARENT_IN_RECYCLE_BIN`
710711
* `RESTORE_PARENT_PURGED`
711712
* `RESTORE_UNIQUE_VALUE_TAKEN`
712713
* `RESUME_CHILD_PENDING`

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +362 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +363 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -325,6 +325,7 @@ const result = ApiErrorSchema.parse(data);
325325
* `RESEED_NO_ROWS`
326326
* `RESEED_SKIPPED`
327327
* `RESTORE_PACKAGE_UNINSTALLED`
328+
* `RESTORE_PARENT_IN_RECYCLE_BIN`
328329
* `RESTORE_PARENT_PURGED`
329330
* `RESTORE_UNIQUE_VALUE_TAKEN`
330331
* `RESUME_CHILD_PENDING`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ const result = ErrorCode.parse(data);
492492
* `RESEED_NO_ROWS`
493493
* `RESEED_SKIPPED`
494494
* `RESTORE_PACKAGE_UNINSTALLED`
495+
* `RESTORE_PARENT_IN_RECYCLE_BIN`
495496
* `RESTORE_PARENT_PURGED`
496497
* `RESTORE_UNIQUE_VALUE_TAKEN`
497498
* `RESUME_CHILD_PENDING`

‎content/docs/references/api/events.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const result = BulkDataEventSchema.parse(data);
5656
| Property | Type | Required | Description |
5757
| :--- | :--- | :--- | :--- |
5858
| **id** | `string` | ✅ | Unique event identifier |
59-
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted'>` | ✅ | Event type |
59+
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| 'data.record.restored'>` | ✅ | Event type |
6060
| **object** | `string` | ✅ | Object name |
6161
| **recordId** | `string` | ✅ | Record ID |
6262
| **organizationId** | `string` | optional | Organization the record belongs to (its organization_id), so a tenant-scoped consumer can discriminate the event's tenant without reading the record body. Absent when the record belongs to no organization: a row of an object outside the wall (environment-wide, or no organization column), and every row of a composition that registers no organization object. A single-posture deployment stamps every tenant row with its Default Organization, so its events carry it — read absence as "not behind any organization wall", never as "unknown". Present = exactly that organization; never fabricated, and the empty string is refused. |
@@ -76,6 +76,7 @@ const result = BulkDataEventSchema.parse(data);
7676
* `data.record.created`
7777
* `data.record.updated`
7878
* `data.record.deleted`
79+
* `data.record.restored`
7980

8081

8182
---

‎content/docs/references/api/realtime.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Realtime event type: a record-change event name the runtime emits
7171
* `data.record.created`
7272
* `data.record.updated`
7373
* `data.record.deleted`
74+
* `data.record.restored`
7475
* `data.records.updated`
7576
* `data.records.deleted`
7677

@@ -119,7 +120,7 @@ Realtime event type: a record-change event name the runtime emits
119120

120121
| Property | Type | Required | Description |
121122
| :--- | :--- | :--- | :--- |
122-
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| 'data.records.updated' \| 'data.records.deleted'>` | ✅ | Type of event to subscribe to |
123+
| **type** | `Enum<'data.record.created' \| 'data.record.updated' \| 'data.record.deleted' \| 'data.record.restored' \| 'data.records.updated' \| 'data.records.deleted'>` | ✅ | Type of event to subscribe to |
123124
| **object** | `string` | optional | Object name to subscribe to |
124125
| **filters** | `any` | optional | Filter conditions |
125126

‎docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,10 @@ its master comes back with its master.
187187

188188
- a **unique value has since been taken** by a live row;
189189
- the **parent was purged** — the row's master-detail parent is no longer in the bin and no longer live;
190-
- the **object's package was uninstalled** — the object no longer exists in the registry.
190+
- the **object's package was uninstalled** — the object no longer exists in the registry;
191+
- the **parent is in the bin** — the row's master-detail parent is not live but is the record of another entry; the
192+
refusal names that entry, and restoring it first lets this one through (added 2026-11-07 by the maintainer's ruling
193+
on #30237, record [`6710220841`](https://github.com/objectstack-ai/objectstack/issues/30237#issuecomment-6710220841)).
191194

192195
Each refusal is a named code in the ADR-0112 envelope (#30235 names them; #30237 answers them and pins one test per
193196
refusal), so the Console and an AI caller read the reason, not a generic failure.
@@ -392,7 +395,8 @@ of days and a default; the plan that maps to those days lives in the host that c
392395
write per operation; bulk delete is a later item with its own cap.
393396
- **Bin storage** is real storage until purge, counted and shown separately.
394397
- **Restore is best-effort by design**: the three refusals are permanent for that entry (a taken unique value, a
395-
purged parent, an uninstalled package) and the person is told why rather than given a forced write.
398+
purged parent, an uninstalled package) and the person is told why rather than given a forced write. The fourth, a
399+
parent that is in the bin, is not: it names the parent's entry, and restoring that entry first clears it.
396400
- **Forward-only**: nothing deleted before the engine slice lands is in the bin.
397401
- A restore brings a record back without its secret-channel values until the later item holds them.
398402

‎packages/objectql/src/engine-middleware-operation-vocabulary.test.ts‎

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
//
33
// [#7809] The engine's MIDDLEWARE DISPATCH VOCABULARY is fixed. Of the
44
// destructive lifecycle verbs (`purge` / `transfer` / `restore`) it contains
5-
// `purge` alone (#30237, the recycle bin), and `purge` never carries an AST.
5+
// `purge` and `restore` (#30237, the recycle bin), and neither ever carries an
6+
// AST.
67
//
78
// ── Why a pin here, in objectql, for a finding filed against plugin-security ──
89
//
@@ -19,14 +20,15 @@
1920
// the same select-only object is scoped (#7665).
2021
//
2122
// The finding's own first question was whether those verbs can reach that path
22-
// at all. They cannot reach the AST path. `transfer` and `restore` reach no
23-
// path; `purge` reaches the by-id and object-level ones only:
23+
// at all. They cannot reach the AST path. `transfer` reaches no path; `purge`
24+
// reaches the by-id and object-level ones only, and `restore` the object-level
25+
// one only:
2426
//
25-
// 1. `OperationContext['operation']` is the 8-member union pinned below.
26-
// Of the destructive lifecycle verbs, `purge` alone is a member (#30237),
27-
// and every dispatch of it is built without an `ast`: the permanent by-id
28-
// delete (`permanent: true`, refused on any delete that is not by id),
29-
// `purge(entryId)` and `empty(object)`.
27+
// 1. `OperationContext['operation']` is the 9-member union pinned below.
28+
// Of the destructive lifecycle verbs, `purge` and `restore` are members
29+
// (#30237), and every dispatch of either is built without an `ast`: the
30+
// permanent by-id delete (`permanent: true`, refused on any delete that is
31+
// not by id), `purge(entryId)`, `empty(object)` and `restore(entryId)`.
3032
// 2. Middleware is invoked from exactly one place — the PRIVATE
3133
// `executeWithMiddleware(ctx: OperationContext, …)`. There is no other
3234
// caller, and no public seam that hands a middleware a context the engine
@@ -37,8 +39,8 @@
3739
// So step 3 passing `opCtx.operation` raw is SAFE TODAY — not by accident, but
3840
// because the vocabulary cannot deliver a verb it fails to handle ON THAT PATH.
3941
// This file exists so that stays true by assertion rather than by luck. The
40-
// recycle bin (ADR-0148, #30237) made `purge` dispatchable, and kept it off the
41-
// AST path, which the AST-set lock below proves; the by-id permanent delete is
42+
// recycle bin (ADR-0148, #30237) made `purge` and `restore` dispatchable, and
43+
// kept both off the AST path, which the AST-set lock below proves; the by-id permanent delete is
4244
// judged by step 2.7, which normalises `purge -> delete`. The day a destructive
4345
// verb reaches the AST path, this pin goes red and names the two sites that
4446
// must normalise first.
@@ -127,6 +129,8 @@ const DISPATCHED_OPERATIONS = [
127129
'aggregate',
128130
// [#30237] The recycle bin's removal verb: the permanent delete, purge, empty.
129131
'purge',
132+
// [#30237] The recycle bin's restore: one entry written back, object-level.
133+
'restore',
130134
] as const;
131135

132136
type Declared = (typeof DISPATCHED_OPERATIONS)[number];
@@ -193,8 +197,8 @@ function readDispatchedUnionFromEngineSource(): string[] {
193197
*/
194198
const DESTRUCTIVE_LIFECYCLE_VERBS = ['purge', 'transfer', 'restore'] as const;
195199

196-
/** [#30237] The one destructive lifecycle verb the engine dispatches, never with an AST. */
197-
const DISPATCHED_DESTRUCTIVE_VERBS = ['purge'] as const;
200+
/** [#30237] The destructive lifecycle verbs the engine dispatches, never with an AST. */
201+
const DISPATCHED_DESTRUCTIVE_VERBS = ['purge', 'restore'] as const;
198202

199203
const NOTE_SCHEMA = {
200204
name: 'note',
@@ -215,7 +219,8 @@ const ROW = { id: 'n1', title: 'x', owner: 'me' };
215219

216220
/**
217221
* [#30237] The recycle bin, registered so the probe reaches its three `purge`
218-
* dispatches: an entry the driver always has, of a `note`.
222+
* dispatches and its `restore`: an entry the driver always has, of a `note`
223+
* whose row is not live ({@link RESTORED_ID}), so the restore writes it back.
219224
*/
220225
const BIN_SCHEMA = {
221226
name: 'sys_recycle_bin',
@@ -225,7 +230,13 @@ const BIN_SCHEMA = {
225230
record_id: { type: 'text' },
226231
},
227232
};
228-
const ENTRY = { id: 'e1', object_name: 'note', record_id: 'n1' };
233+
const RESTORED_ID = 'n_binned';
234+
const ENTRY = {
235+
id: 'e1',
236+
object_name: 'note',
237+
record_id: RESTORED_ID,
238+
snapshot: { rows: [{ object: 'note', id: RESTORED_ID, values: { title: 'x', owner: 'me' } }], clearedReferences: [] },
239+
};
229240

230241
// `any` on the double itself, matching the sibling read-filter test: this is a
231242
// deliberately PARTIAL driver (the engine only reaches the members listed), and
@@ -239,7 +250,8 @@ function makeDriver(): any {
239250
supports: {},
240251
connect: vi.fn().mockResolvedValue(undefined),
241252
disconnect: vi.fn().mockResolvedValue(undefined),
242-
find: vi.fn(async (object: string) => [object === 'sys_recycle_bin' ? { ...ENTRY } : { ...ROW }]),
253+
find: vi.fn(async (object: string, ast?: any) =>
254+
object === 'sys_recycle_bin' ? [{ ...ENTRY }] : ast?.where?.id === RESTORED_ID ? [] : [{ ...ROW }]),
243255
findOne: vi.fn(async () => ({ ...ROW })),
244256
count: vi.fn(async () => 1),
245257
aggregate: vi.fn(async () => []),
@@ -317,6 +329,8 @@ async function driveEveryPublicMethod(ql: ObjectQL): Promise<void> {
317329
await ql.delete('note', { where: { id: 'n1' }, permanent: true, context: ctx });
318330
await ql.purge('e1', { context: ctx });
319331
await ql.empty('note', { context: ctx });
332+
// [#30237] The recycle bin's `restore` dispatch.
333+
await ql.restore('e1', { context: ctx });
320334
}
321335

322336
describe('[#7809] engine middleware dispatch vocabulary', () => {
@@ -329,7 +343,7 @@ describe('[#7809] engine middleware dispatch vocabulary', () => {
329343
expect(fromSource.slice().sort()).toEqual([...DISPATCHED_OPERATIONS].sort());
330344
});
331345

332-
it('the engine union declares `purge` and no other destructive lifecycle verb', () => {
346+
it('the engine union declares `purge` and `restore` and no other destructive lifecycle verb', () => {
333347
const fromSource = readDispatchedUnionFromEngineSource();
334348
const destructive = fromSource.filter((op) =>
335349
(DESTRUCTIVE_LIFECYCLE_VERBS as readonly string[]).includes(op),
@@ -340,29 +354,31 @@ describe('[#7809] engine middleware dispatch vocabulary', () => {
340354
expect(destructive).toEqual([...DISPATCHED_DESTRUCTIVE_VERBS]);
341355
});
342356

343-
it('a real engine driven through every public method dispatches exactly the 8', async () => {
357+
it('a real engine driven through every public method dispatches exactly the 9', async () => {
344358
const { ql, seen } = await makeEngine();
345359
await driveEveryPublicMethod(ql);
346360

347361
const observed = [...new Set(seen.map((s) => s.operation))].sort();
348362
expect(observed).toEqual([...DISPATCHED_OPERATIONS].sort());
349363
});
350364

351-
it('of the destructive lifecycle verbs, only `purge` reaches a middleware, and never with an AST', async () => {
365+
it('of the destructive lifecycle verbs, only `purge` and `restore` reach a middleware, and never with an AST', async () => {
352366
const { ql, seen } = await makeEngine();
353367
await driveEveryPublicMethod(ql);
354368

355369
expect(seen.length).toBeGreaterThan(0);
356370
const destructive = seen.filter((s) =>
357371
(DESTRUCTIVE_LIFECYCLE_VERBS as readonly string[]).includes(s.operation),
358372
);
359-
// [#30237] The permanent delete, `purge` and `empty`, in the order driven.
360-
// Only the permanent delete names a live row: step 2.7 judges that row
361-
// as a delete (`purge -> delete`); `purge` and `empty` are object-level.
373+
// [#30237] The permanent delete, `purge`, `empty` and `restore`, in the
374+
// order driven. Only the permanent delete names a live row: step 2.7
375+
// judges that row as a delete (`purge -> delete`); `purge`, `empty` and
376+
// `restore` are object-level.
362377
expect(destructive).toEqual([
363378
{ operation: 'purge', hasAst: false, hasId: true },
364379
{ operation: 'purge', hasAst: false, hasId: false },
365380
{ operation: 'purge', hasAst: false, hasId: false },
381+
{ operation: 'restore', hasAst: false, hasId: false },
366382
]);
367383
});
368384

@@ -410,13 +426,16 @@ describe('[#7809] engine middleware dispatch vocabulary', () => {
410426
expect(astOperations).toEqual(['aggregate', 'count', 'delete', 'find', 'findOne', 'update']);
411427

412428
// The by-id write branches, conversely, must NOT carry an ast; nor does
413-
// any `purge` (#30237: the permanent delete, `purge`, `empty`).
429+
// any `purge` (#30237: the permanent delete, `purge`, `empty`), nor the
430+
// `restore` (#30237). The restore's re-links and its rows' writes are raw
431+
// or by-id, so the restore adds no AST-carrying dispatch either.
414432
expect(seen.filter((s) => !s.hasAst).map((s) => s.operation).sort()).toEqual([
415433
'delete',
416434
'insert',
417435
'purge',
418436
'purge',
419437
'purge',
438+
'restore',
420439
'update',
421440
]);
422441
});

‎packages/objectql/src/engine-realtime-publish.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ function eventMatchedCount(value: unknown): number | undefined {
410410
*/
411411
export async function publishDataEvent(
412412
this: Pick<RealtimePublishHost, 'realtimeService' | 'logger' | '_registry'>,
413-
action: 'created' | 'updated' | 'deleted',
413+
action: 'created' | 'updated' | 'deleted' | 'restored',
414414
object: string,
415415
input: {
416416
recordId: unknown;

0 commit comments

Comments
 (0)