Skip to content

Commit 9d939c5

Browse files
feat(objectql,spec): purge and empty the recycle bin, and delete a record for good (ADR-0148) (#30990)
Part of #30237: the second of five PRs in the seat's split (claim amendment 6673429400; PR 1 #30780 merged as 25e1c1a). This one adds purge, empty and the permanent delete (Q3 ruled A, 6676408264). Still on the card: restore (PR 3), the permission reader for `allowRestoreOwnDeleted` / `manageRecycleBin` (PR 4) and the REST doors (PR 5). Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up #30847 — skills/objectstack-api/SKILL.md :268, :417 (they teach that there is no recycle bin) ## 维护者速读 - 改了什么:引擎新增 `purge(entryId)`(删掉一条回收站条目)和 `empty(object)`(清空某个对象在本组织的全部条目),以及 `delete(object, { where: { id }, permanent: true })`(彻底删除,不进回收站)。三者都以新的 `purge` 操作经过中间件链,作用在业务对象上,且都不带 AST。另按 Tesla 裁决 A(#30237 评论 6691358077):应用编写的代码(hook、action、job 的脚本体)不能彻底删除,`HookDeleteOptions` 去掉了 `permanent`,沙箱对带 `permanent` 的删除直接拒绝(400 VALIDATION_ERROR),删除一律进回收站。 - 为什么改:ADR-0148 回收站的移除侧。Q3 裁定为 A:彻底删除只有回收站管理员能做,所以它和清除、清空共用 `purge` 这个动词。 - 风险与回滚:`plugin-security` 还没把 `purge` 映射到 `manageRecycleBin`(PR 4 做),所以在它映射之前,非系统调用者的这三种操作一律被拒(fail-closed),这是预期行为。`OperationContext.operation` 多了 `'purge'`:按 `'delete'` 处理写操作的中间件看到的彻底删除是 `'purge'`(已列出 PR 4 要处理的 plugin-security 位置)。回滚就是 revert 本 PR。 - 先看:`packages/objectql/src/recycle-bin/purge.ts`(三种操作和拒绝规则)、`packages/objectql/src/engine.ts` 的 `delete()`(`readPermanentDelete` 接入点和 `runByIdDelete` 跳过快照)、`packages/objectql/src/engine-middleware-operation-vocabulary.test.ts`(#7809 词表 pin 的翻转)。 - 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run src/sandbox/body-runner.test.ts`,预期 42 个测试全过(其中 6 个是本次新增:job 脚本体传 `permanent: true` 被拒且什么都没删、`false`/不传照常进回收站、宿主代码的彻底删除不受影响)。 - 需要你做的:无。spec 改动已获 Elon 同意(#30237 评论 6688058790),契约复核和落地由席位负责。 ## What changes **`@objectstack/spec`** (its own commit, 2302a5f; the maintainer's spec consent is recorded on #30237 in 6688058790): - `EngineDeleteOptionsSchema` gains `permanent: z.boolean().optional()`: delete for good, so the rows skip the recycle bin and cannot be restored. It is authorized as the `purge` operation, not `delete`, and is honoured on a by-id delete only. - Regenerated: `packages/spec/authorable-surface/data.json` (one row) and `content/docs/references/data/data-engine.mdx` (one row). **Hooks and app-authored bodies cannot pass `permanent`** (maintainer ruling A, recorded on #30237 in 6691358077; the hook face narrows from what this PR first offered, which never shipped): - `@objectstack/spec`: `HookDeleteOptions` is now `Omit<EngineDeleteOptions, 'context' | 'permanent'>`, and the drift pin in `hook-api.test.ts` lists `permanent` under OMITTED. `hook.zod.ts` needs no mirror: its `api` is typed `IScopedContext`, and its `EngineDeleteOptions` mention is the firing delete's own options, which a `beforeDelete` hook still reads `permanent` off. - `@objectstack/runtime`: `buildSandboxApi` (`sandbox/body-runner.ts`), the one place every hook, action and job body gets its `ctx.api`, gains an outer layer, `refuseBodyPermanentDeletes`. A repository's `delete(options)` whose `options.permanent` is anything but `false` / absent is refused before the verb runs: `400 VALIDATION_ERROR`, `param: 'permanent'`, message "A job body cannot delete a record permanently: app-authored code cannot purge; its deletes enter the recycle bin. Remove 'permanent' from the delete of 'OBJECT'." (the face label varies: hook body / action body / job body). It walks every derived context (`object`, `sudo`, `withRunAs`, transactions) through the shared `deriveThroughSeam`. It holds under a job's `{ isSystem: true, tenantId }` envelope, which is the point: the system is never refused `purge`. The engine's own `delete(…, { permanent: true })` (Q3 = A) is unchanged for every other caller. - Changeset: `'@objectstack/runtime': minor` added, and one bullet saying hooks and app bodies cannot pass `permanent`. **`@objectstack/objectql`**: the removal side, in the new `recycle-bin/purge.ts`. The two new engine members are host-literal delegations, per the facade gate. - **One verb.** `OperationContext.operation` gains `'purge'`. All three acts are dispatched through the middleware chain as `purge`, on the business object the rows belong to, so one grant (`manageRecycleBin`, ADR-0148 D5) will govern all three once PR 4 maps it. Until then, `plugin-security` refuses the unmapped destructive verb for every caller that is not the system (`DESTRUCTIVE_OPERATIONS`), which is the Q3 = A posture. - **`purge` never carries an AST** (#7809). The security plugin's step 3 hands `opCtx.operation` to the RLS compiler raw, and the compiler knows `select` / `insert` / `update` / `delete` only. So: - `purge` and `empty` dispatch with no `ast` and no id. The security plugin's 2.7 pre-image check needs a live-row id, so it skips them; they are judged at the object level only. - The permanent delete is a by-id delete. Its `where.id` is the live row's, so step 2.7 judges that row as it judges a delete (`rlsOperationForVerb('purge')` is `delete`). - **The permanent delete.** In `delete()`, `readPermanentDelete` runs right after the dispatch is resolved, before the operation context is built. It sets the verb to `purge`, and `runByIdDelete` opens no capture, so no entry is written. Everything else runs as for a by-id delete: the hooks (which read `permanent` off `input.options`), the cascade, the events and the secret release. **Accepted deviation** (seat, 11-06 07:31Z): the plan did not name these refusals. They are refused `400 VALIDATION_ERROR` (`param: 'permanent'`) before any middleware or hook runs: - `permanent: true` on a delete that is not by id, or that asks `multi: true`. Only a by-id delete enters the bin, so a predicate delete has nothing to skip, and its `where` rides an AST; - `permanent: true` on a platform object (`sys_`, `cloud_`, `ai_`), whose delete never enters the bin and is already permanent. This also keeps `purge` off every object the grants cache watches (`GRANTS_CACHE_WATCHED_OBJECTS` in `@objectstack/core`, all `sys_*`), whose watched-write set is `insert` / `update` / `delete`; - a `permanent` that is not a boolean. - **`purge(entryId, { context })`** answers `true` when the entry was removed. It answers `false` when no entry with that id is visible to the caller's organization, or when the composition registers no bin. The entry is read raw first, with the caller's driver options (so the caller's organization scopes it), for its `object_name`. The removal is then dispatched as `purge` on that object, and the bin row is removed raw inside the executor. Nothing is removed if the chain refuses. - **`empty(object, { context })`** removes every entry of `object` that the caller's organization holds, and answers how many. It is one `purge` dispatch on `object`, so one authorization covers the act. - It uses `deleteMany` on the bin with `{ object_name }`. On a driver without `deleteMany`, it removes the entries by id, one bounded page (500) at a time, and stops on a page that removes nothing. - An object that is no longer registered can still be emptied. - A platform object, or an empty name, is refused `400` (`param: 'object'`). It answers `0` when there is no bin. - **Accepted deviation from the plan's `empty(object?)`** (seat, 11-06 07:31Z): the object is required. Emptying every object at once would be one `purge` authorization per object, and a caller with the grant on some objects only would get a partial empty. PR 5's Console door can loop over the objects the admin page lists. - **Write epoch.** `purge` joins `WRITE_EPOCH_OPERATIONS`, so the permanent delete, a purge and an empty each advance the engine's write epoch, as `delete` does. - **Hook namespace unchanged.** `ENGINE_OPERATION_VERBS` became `Record<Verb, boolean>`, where the value says whether the verb names a `before` / `after` hook pair. `purge` is `false`, so `beforePurge` / `afterPurge` stay outside the engine's lifecycle namespace and keep warning at registration, as before. Inside the namespace they would be refused, which is a narrowing of `registerHook` this PR does not make. The `#17713` dispatch-gap pins are unchanged. - `openRecycleBinCapture`'s last parameter is renamed `predicate` → `skip`, and `runByIdDelete` passes `multi === true || permanent`. - `RecycleBinRemovalOptions` is exported from the package barrel, beside `RecycleBinRetentionSettings`. **For PR 4** (sites in `@objectstack/plugin-security` that key on `'delete'` and skip a `purge` dispatch; out of this PR's surface, not changed here): - `security-plugin-authz-middleware.ts:399`: the self-owned epoch bump. It is inert while the engine owns the seam, which it does. - `security-plugin-authz-middleware.ts:2302`: the bulk-write guard target. It is AST-only, so `purge` never reaches it. - `security-plugin-authz-middleware.ts:2314`: the step-3 comment says the vocabulary has seven members and none is destructive. It should say that `purge` is a member and never carries an AST. - `security-plugin-addressed-writes.ts:110` / `:129`, `security-plugin-authored-row-write.ts:108`, `security-plugin-cbp-write-gate.ts:364`, `security-plugin-rls-filter.ts:172` / `:371`, `platform-ownership-policies.ts:358`, `rls-compiler.ts:1349`: the permanent delete reaches these as `purge`, so each should read `rlsOperationForVerb` or list `purge` beside `delete` once the verb is mapped. - `packages/verify/src/handle.ts:598` (`observeWrites`) records `insert` / `update` / `delete` only, so a permanent delete passes it unrecorded. ## Tests - `packages/objectql/src/recycle-bin/purge.test.ts` (25 cases). It runs a real `ObjectQL` on a driver that keeps rows and honours `options.tenantId` the way the SQL driver does, under the `isolated` posture. - The permanent delete: - removes the record and its master-detail child; - writes no entry; - is one `purge` dispatch with the row's id and no AST; - fires `beforeDelete` / `afterDelete`, which see `permanent: true`. - `permanent: false` writes an entry, as the ordinary delete does. - The permanent delete is refused and changes nothing under a middleware that refuses non-system `purge` (plugin-security's current posture). The ordinary delete still passes. - `400` refusals, each with no dispatch and no write: - a delete by filter; - a by-id delete with `multi: true`; - a `sys_` object; - a non-boolean `permanent`. - `purge`: - removes the one entry, as one dispatch on its object with no AST and no id; - answers `false` for another organization's entry, an unknown id, and a composition with no bin; - is refused, removing nothing, for a non-system caller under that middleware, and the system purges; - refuses a bad id with `400`. - `empty`: - removes the caller's organization's entries of one object only, in one dispatch, and counts them; - removes every organization's entries for the system; - on a driver without `deleteMany`, reads 1,100 entries in three bounded pages and stops on a full page it cannot remove; - empties an object that is no longer registered; - is refused, removing nothing, under that middleware; - answers `0` with no bin; - refuses `''`, a `sys_` object and a `cloud_` object with `400`. - `packages/objectql/src/engine-middleware-operation-vocabulary.test.ts`: the #7809 pin flips for `purge`. - The weld and the probe now hold 8 verbs, and the probe also drives a permanent delete, `purge` and `empty`. - The union declares `purge` and no other destructive verb. - The `purge` dispatches are exactly the three driven, none with an AST, and only the permanent delete with an id. - The AST-carrying set is unchanged. The no-AST list gains the three `purge`. - `permanent: true` off the by-id path is refused before any middleware runs. - `packages/objectql/src/write-epoch.test.ts`: the counted operations are `delete`, `insert`, `purge` and `update`. - `packages/objectql/src/engine-unknown-option.test.ts` (unchanged): its drift pin derives the delete key set from `EngineDeleteOptionsSchema`, and it is green only because `permanent` joined `ENGINE_DELETE_OPTION_KEYS`. - Ablations, each run against the tests above and restored: - capture not skipped for `permanent` → 1 red (the permanent delete writes an entry); - the verb always `delete` → 4 red (purge suite + #7809 pin); - `permanent` refused only on the `reject` dispatch → 3 red; - the platform-object refusal dropped → 1 red; - `purge`'s entry read without the caller's driver options → 1 red (another organization's entry is purged); - `empty` run outside the middleware chain → 2 red; - the fallback stops after one page → 1 red (1,100 entries); - the stuck-page guard dropped → the suite hangs on that case (no result in 120 s). - `packages/runtime/src/sandbox/body-runner.test.ts`, new top-level describe (6 cases, real `ObjectQL` over a row-keeping driver, one entry already in the bin): - a job body passing `permanent: true` is refused `VALIDATION_ERROR` / `400` with the rule in the message; no delete is dispatched, the record stays, the bin still holds its one entry; - uncaught, the same refusal fails the job run with that envelope; - a hook body (the engine's api) and an action body (the executionContext fallback) are refused the same way; - `permanent: false` and an absent key are the ordinary delete: one `delete` dispatch, the record enters the bin; - host code's `engine.delete(…, { permanent: true })` still purges (one `purge` dispatch, no entry). - Ablation (via `scripts/ablation-replace.mjs`, anchor x1 → x0, blob 0a33b23 → d1c7f8ab): the refusal's condition replaced by `if (false)` → `Tests 3 failed | 39 passed (42)` (the three refusal cases); restored, blob == HEAD, `git diff HEAD` empty. The test imports `./body-runner.js` relatively, so no dist leg. ## Acceptance notes - The card's Wanted items in this slice: purge, empty, and the permanent delete as the manager's act (Q3 = A). - No non-system caller can use any of the three until PR 4 maps `purge` to `manageRecycleBin`. That is the fail-closed posture ADR-0148 D2 keeps. - A purge or an empty writes no audit row: plugin-audit skips the bin, and the verb fires no hook. The permanent delete is audited as a delete, through its `afterDelete`. If a manager's purge must be ledgered, that needs an event-specific writer, which is a later item. ## Verification Ruling-A fix (head `9f765466`: merge of `main` `bcf8a37e`, fix `d8e95344`, pins + changeset `9f765466`): the gate readings are in the os-dev report 6692900377 on #30237 (109/109 derived families exit 0; runtime 551 files / 8,832 tests; ablation 3 red). The seat's delta review is 6693177811 on this PR. The readings below are for `16a1d37e`. Head `16a1d37e`: commits `2302a5f0` (spec) and `aa8b43fa` (engine), then a merge of `main` at `1aeeafd9`. The merge regenerated `packages/spec/authorable-surface/data.json` with `pnpm --filter @objectstack/spec gen:schema`, as the generated-file merge driver asks. - **Gates on the merged head.** First the build: `pnpm --filter @objectstack/spec --filter @objectstack/objectql --filter @objectstack/metadata-protocol --filter @objectstack/client run build`, exit 0. Then every family from `node scripts/pm/dispatch-gates.mjs --commands`: all 109 exited 0. `--ran` reports "109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN". `pnpm lint` exited 0. - **Gates on the pre-merge head `aa8b43fa`.** The same 109 families. Two failed first on stale builds of packages this PR does not touch, and passed after rebuilding those packages: - `check:skill-examples` exited 3 (`@objectstack/client`); - `check:dts-closure` exited 1 (`@object-ui/i18n`). - **Typecheck.** `pnpm --filter <pkg> run typecheck` exited 0 for `@objectstack/objectql` (on both heads), and on the pre-merge head for `core`, `plugin-audit`, `plugin-security`, `plugin-sharing`, `service-storage` and `verify`: every package that reads `OperationContext`. - **Tests.** - On the merged head, `src/recycle-bin/`, the #7809 vocabulary pin, `write-epoch`, `engine-unknown-option` and `engine-hook-event-dispatch-gap`: 7 files, 134/134 pass. Full `@objectstack/objectql` suite: 561 files, 11,231/11,231 pass. - On the pre-merge head, the full suite passed 11,187 of 11,190. The 3 failures were `protocol-destructive-currency-mode.test.ts` reading a stale `@objectstack/metadata-protocol` dist, and the file passes 7/7 after that package is rebuilt. - `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`: exit 0. <!-- signed: domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4d9c09 commit 9d939c5

17 files changed

Lines changed: 1130 additions & 58 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/spec': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
feat(objectql,spec,runtime): purge and empty the recycle bin, and delete a record for good (ADR-0148): `ObjectQL.purge(entryId)`, `ObjectQL.empty(object)` and `delete(object, { where: { id }, permanent: true })`, all authorized as the `purge` operation; hooks and app-authored bodies cannot pass `permanent`
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: added
11+
Skills-docs: follow-up #30847 — `skills/objectstack-api/SKILL.md` :268, :417 teach that there is no recycle bin; the skills lane owns that file.
12+
13+
The recycle bin's removal side. All three acts are dispatched through the middleware chain as one new operation, `purge`, on the business object the rows belong to, so one grant will govern all three: `manageRecycleBin`, the recycle-bin manager's (ADR-0148 D5). **Until `@objectstack/plugin-security` maps `purge` to that grant, it refuses `purge` for every caller that is not the system**, the fail-closed rule it already applies to the destructive verbs. So in this release only the system, or a composition without the security plugin, can purge, empty or delete for good. Restoring, the two grants and the REST doors are not in this release.
14+
15+
- **`@objectstack/spec`:** `EngineDeleteOptions` gains `permanent?: boolean`: delete for good, so the rows skip the recycle bin and cannot be restored. It is authorized as the `purge` operation, not `delete`, and is honoured on a by-id delete only.
16+
- **Hooks and app bodies cannot pass `permanent`:** app-authored code cannot erase a record past the recycle bin; its deletes always enter the bin, and the permanent delete stays with a principal holding `manageRecycleBin`. `HookDeleteOptions` (the bag `ctx.api.object(n).delete()` takes) omits `permanent`, and `@objectstack/runtime`'s sandbox refuses a hook, action or job body's `delete` carrying `permanent` (any value but `false`) with `400 VALIDATION_ERROR` (`param: 'permanent'`) before anything is removed, whatever envelope the body runs under, a job's system envelope included. `permanent: false` and an absent key are the ordinary delete.
17+
- **`@objectstack/objectql`, the permanent delete:** `delete(object, { where: { id }, permanent: true })` runs as the by-id delete does (its `beforeDelete` / `afterDelete` hooks, which read `permanent` off `input.options`, its cascade, its events), writes no recycle-bin entry, and is dispatched as `purge` with the row's id. These are refused `400 VALIDATION_ERROR` (`param: 'permanent'`) before any middleware or hook runs:
18+
- `permanent: true` on a delete that is not by id, or that asks `multi: true`;
19+
- `permanent: true` on a platform object (`sys_*`, `cloud_*`, `ai_*`), whose delete never enters the bin and is already permanent;
20+
- a `permanent` that is not a boolean.
21+
`permanent: false` is the ordinary delete.
22+
- **`@objectstack/objectql`, `purge(entryId, { context })`:** removes one recycle-bin entry and answers `true`. It answers `false` when no entry with that id is visible to the caller's organization, or when the composition registers no bin. The entry is read first, scoped to the caller's organization, for the object its rows belong to; the removal is then dispatched as `purge` on that object, with no id.
23+
- **`@objectstack/objectql`, `empty(object, { context })`:** removes every entry of one object that the caller's organization holds, and answers how many. It is one `purge` dispatch on `object`, so one authorization covers it. The object need not be registered any more, so an uninstalled package's entries can still be emptied. A platform object, or an empty name, is refused `400 VALIDATION_ERROR` (`param: 'object'`). It answers `0` when the composition registers no bin.
24+
- **Middleware authors:** `OperationContext.operation` gains `'purge'`, which never carries an `ast`. A middleware that switches on the operation exhaustively, or keys write handling on `'delete'`, sees the permanent delete as `'purge'`. `purge` advances the engine's write epoch, as `insert`, `update` and `delete` do (`WRITE_EPOCH_OPERATIONS`). `beforePurge` / `afterPurge` are not engine hook events: registering one warns that it never fires, as before.

‎content/docs/references/data/data-engine.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ QueryAST-aligned options for DataEngine.delete operations
656656
| **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | |
657657
| **where** | `Record<string, any> \| any` | optional | |
658658
| **multi** | `boolean` | optional (default: `false`) | |
659+
| **permanent** | `boolean` | optional | Delete for good: the rows skip the recycle bin and cannot be restored. Authorized as the `purge` operation, not `delete`. By-id deletes only. |
659660

660661
### Nested Shape: `EngineDeleteOptions.context`
661662

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

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
3-
// [#7809] The engine's MIDDLEWARE DISPATCH VOCABULARY is fixed, and it contains
4-
// no destructive lifecycle verb (`purge` / `transfer` / `restore`).
3+
// [#7809] The engine's MIDDLEWARE DISPATCH VOCABULARY is fixed. Of the
4+
// destructive lifecycle verbs (`purge` / `transfer` / `restore`) it contains
5+
// `purge` alone (#30237, the recycle bin), and `purge` never carries an AST.
56
//
67
// ── Why a pin here, in objectql, for a finding filed against plugin-security ──
78
//
@@ -18,10 +19,14 @@
1819
// the same select-only object is scoped (#7665).
1920
//
2021
// The finding's own first question was whether those verbs can reach that path
21-
// at all. They cannot — not on the AST path, and not on ANY 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:
2224
//
23-
// 1. `OperationContext['operation']` is the 7-member union pinned below.
24-
// No destructive lifecycle verb is a member.
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)`.
2530
// 2. Middleware is invoked from exactly one place — the PRIVATE
2631
// `executeWithMiddleware(ctx: OperationContext, …)`. There is no other
2732
// caller, and no public seam that hands a middleware a context the engine
@@ -30,12 +35,13 @@
3035
// `operation` is a member of that union by construction.
3136
//
3237
// So step 3 passing `opCtx.operation` raw is SAFE TODAY — not by accident, but
33-
// because the vocabulary cannot deliver a verb it fails to handle. This file
34-
// exists so that stays true by assertion rather than by luck: the day a recycle
35-
// bin (#3146, parked — `enable.trash` was retired in #2377/ADR-0049, which is
36-
// why `API_METHOD_DERIVATION` gives `restore`/`purge` a permanent
37-
// `flag: () => false`) makes one of these verbs dispatchable, this pin goes red
38-
// and names the two sites that must normalise first.
38+
// because the vocabulary cannot deliver a verb it fails to handle ON THAT PATH.
39+
// 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+
// judged by step 2.7, which normalises `purge -> delete`. The day a destructive
43+
// verb reaches the AST path, this pin goes red and names the two sites that
44+
// must normalise first.
3945
//
4046
// ⚠️ Note the vocabularies are deliberately DIFFERENT and must not be conflated:
4147
// `ExplainOperationSchema` (spec/security/explain.zod.ts) is a 7-verb
@@ -119,6 +125,8 @@ const DISPATCHED_OPERATIONS = [
119125
'delete',
120126
'count',
121127
'aggregate',
128+
// [#30237] The recycle bin's removal verb: the permanent delete, purge, empty.
129+
'purge',
122130
] as const;
123131

124132
type Declared = (typeof DISPATCHED_OPERATIONS)[number];
@@ -180,11 +188,14 @@ function readDispatchedUnionFromEngineSource(): string[] {
180188
/**
181189
* The verbs #7809 is about. `plugin-security`'s step-2.7 gate normalises these
182190
* onto `update`/`delete` before deriving row scope; its step-3 AST path and the
183-
* ADR-0090 D10 delegator half do NOT. Keeping them out of the dispatch
184-
* vocabulary is what makes that difference unobservable.
191+
* ADR-0090 D10 delegator half do NOT. Keeping them off the AST path is what
192+
* makes that difference unobservable.
185193
*/
186194
const DESTRUCTIVE_LIFECYCLE_VERBS = ['purge', 'transfer', 'restore'] as const;
187195

196+
/** [#30237] The one destructive lifecycle verb the engine dispatches, never with an AST. */
197+
const DISPATCHED_DESTRUCTIVE_VERBS = ['purge'] as const;
198+
188199
const NOTE_SCHEMA = {
189200
name: 'note',
190201
fields: {
@@ -202,6 +213,20 @@ const NOTE_SCHEMA = {
202213
*/
203214
const ROW = { id: 'n1', title: 'x', owner: 'me' };
204215

216+
/**
217+
* [#30237] The recycle bin, registered so the probe reaches its three `purge`
218+
* dispatches: an entry the driver always has, of a `note`.
219+
*/
220+
const BIN_SCHEMA = {
221+
name: 'sys_recycle_bin',
222+
fields: {
223+
id: { type: 'text' },
224+
object_name: { type: 'text' },
225+
record_id: { type: 'text' },
226+
},
227+
};
228+
const ENTRY = { id: 'e1', object_name: 'note', record_id: 'n1' };
229+
205230
// `any` on the double itself, matching the sibling read-filter test: this is a
206231
// deliberately PARTIAL driver (the engine only reaches the members listed), and
207232
// annotating it `IDataDriver` would demand the whole surface for no assertion.
@@ -214,7 +239,7 @@ function makeDriver(): any {
214239
supports: {},
215240
connect: vi.fn().mockResolvedValue(undefined),
216241
disconnect: vi.fn().mockResolvedValue(undefined),
217-
find: vi.fn(async () => [{ ...ROW }]),
242+
find: vi.fn(async (object: string) => [object === 'sys_recycle_bin' ? { ...ENTRY } : { ...ROW }]),
218243
findOne: vi.fn(async () => ({ ...ROW })),
219244
count: vi.fn(async () => 1),
220245
aggregate: vi.fn(async () => []),
@@ -229,11 +254,13 @@ function makeDriver(): any {
229254
interface Seen {
230255
operation: string;
231256
hasAst: boolean;
257+
/** A scalar `options.where.id`: the live row a by-id write names. */
258+
hasId: boolean;
232259
}
233260

234261
async function makeEngine() {
235262
vi.mocked((SchemaRegistry as any).getObject).mockImplementation((name: string) =>
236-
name === 'note' ? NOTE_SCHEMA : undefined,
263+
name === 'note' ? NOTE_SCHEMA : name === 'sys_recycle_bin' ? BIN_SCHEMA : undefined,
237264
);
238265
const ql = new ObjectQL();
239266
ql.registerDriver(makeDriver(), true);
@@ -244,7 +271,8 @@ async function makeEngine() {
244271
// precisely why that plugin can compare against verbs the union does not
245272
// contain without TypeScript ever objecting.
246273
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
247-
seen.push({ operation: opCtx.operation, hasAst: !!opCtx.ast });
274+
const id = opCtx.options?.where?.id;
275+
seen.push({ operation: opCtx.operation, hasAst: !!opCtx.ast, hasId: typeof id === 'string' || typeof id === 'number' });
248276
await next();
249277
});
250278
return { ql, seen };
@@ -285,6 +313,10 @@ async function driveEveryPublicMethod(ql: ObjectQL): Promise<void> {
285313
await ql.update('note', { title: 'bulk' }, { multi: true, where: { owner: 'me' }, context: ctx } as any);
286314
await ql.delete('note', { where: { id: 'n1' }, context: ctx } as any);
287315
await ql.delete('note', { multi: true, where: { owner: 'me' }, context: ctx } as any);
316+
// [#30237] The recycle bin's three `purge` dispatches, in this order.
317+
await ql.delete('note', { where: { id: 'n1' }, permanent: true, context: ctx });
318+
await ql.purge('e1', { context: ctx });
319+
await ql.empty('note', { context: ctx });
288320
}
289321

290322
describe('[#7809] engine middleware dispatch vocabulary', () => {
@@ -297,34 +329,56 @@ describe('[#7809] engine middleware dispatch vocabulary', () => {
297329
expect(fromSource.slice().sort()).toEqual([...DISPATCHED_OPERATIONS].sort());
298330
});
299331

300-
it('the engine union declares no destructive lifecycle verb', () => {
332+
it('the engine union declares `purge` and no other destructive lifecycle verb', () => {
301333
const fromSource = readDispatchedUnionFromEngineSource();
302334
const destructive = fromSource.filter((op) =>
303335
(DESTRUCTIVE_LIFECYCLE_VERBS as readonly string[]).includes(op),
304336
);
305337
// Asserted against the SOURCE list, not against `DISPATCHED_OPERATIONS` —
306338
// checking this file's own array against this file's own array would prove
307339
// nothing.
308-
expect(destructive).toEqual([]);
340+
expect(destructive).toEqual([...DISPATCHED_DESTRUCTIVE_VERBS]);
309341
});
310342

311-
it('a real engine driven through every public method dispatches exactly the 7', async () => {
343+
it('a real engine driven through every public method dispatches exactly the 8', async () => {
312344
const { ql, seen } = await makeEngine();
313345
await driveEveryPublicMethod(ql);
314346

315347
const observed = [...new Set(seen.map((s) => s.operation))].sort();
316348
expect(observed).toEqual([...DISPATCHED_OPERATIONS].sort());
317349
});
318350

319-
it('no destructive lifecycle verb reaches a middleware, on any path', async () => {
351+
it('of the destructive lifecycle verbs, only `purge` reaches a middleware, and never with an AST', async () => {
320352
const { ql, seen } = await makeEngine();
321353
await driveEveryPublicMethod(ql);
322354

323355
expect(seen.length).toBeGreaterThan(0);
324356
const destructive = seen.filter((s) =>
325357
(DESTRUCTIVE_LIFECYCLE_VERBS as readonly string[]).includes(s.operation),
326358
);
327-
expect(destructive).toEqual([]);
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.
362+
expect(destructive).toEqual([
363+
{ operation: 'purge', hasAst: false, hasId: true },
364+
{ operation: 'purge', hasAst: false, hasId: false },
365+
{ operation: 'purge', hasAst: false, hasId: false },
366+
]);
367+
});
368+
369+
it('[#30237] `permanent: true` off the by-id path is refused before any middleware runs', async () => {
370+
const { ql, seen } = await makeEngine();
371+
const ctx: ExecutionContext = { userId: 'u1', isSystem: true };
372+
373+
// A delete by filter: it would carry its `where` as an AST.
374+
await expect(
375+
ql.delete('note', { multi: true, where: { owner: 'me' }, permanent: true, context: ctx }),
376+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400 });
377+
// A by-id delete asked as a predicate one: the bin never takes it.
378+
await expect(
379+
ql.delete('note', { multi: true, where: { id: 'n1' }, permanent: true, context: ctx }),
380+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400 });
381+
expect(seen).toEqual([]);
328382
});
329383

330384
it('every AST-carrying dispatch — the #7809 path — carries a handled operation', async () => {
@@ -355,10 +409,14 @@ describe('[#7809] engine middleware dispatch vocabulary', () => {
355409
const astOperations = [...new Set(astCarrying.map((s) => s.operation))].sort();
356410
expect(astOperations).toEqual(['aggregate', 'count', 'delete', 'find', 'findOne', 'update']);
357411

358-
// The by-id write branches, conversely, must NOT carry an ast.
412+
// The by-id write branches, conversely, must NOT carry an ast; nor does
413+
// any `purge` (#30237: the permanent delete, `purge`, `empty`).
359414
expect(seen.filter((s) => !s.hasAst).map((s) => s.operation).sort()).toEqual([
360415
'delete',
361416
'insert',
417+
'purge',
418+
'purge',
419+
'purge',
362420
'update',
363421
]);
364422
});

‎packages/objectql/src/engine-top-level.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,34 +137,42 @@ const DISPATCHABLE_HOOK_EVENTS: ReadonlySet<string> = new Set([
137137
* [#17713] Every verb the engine can put on an {@link OperationContext}, as
138138
* DATA rather than as a second hand-written list.
139139
*
140-
* The type annotation is the weld: `Record<Union, true>` is exhaustive in both
141-
* directions, so a new engine method that widens `OperationContext['operation']`
140+
* The type annotation is the weld: `Record<Union, boolean>` is exhaustive in
141+
* both directions, so a new engine method that widens `OperationContext['operation']`
142142
* fails to compile here until the verb is added, and a verb dropped from the
143-
* union makes its entry an excess property. `pnpm typecheck` compiles this file
143+
* union makes its entry an excess property. The value says whether the verb
144+
* names a `before`/`after` hook pair. `pnpm typecheck` compiles this file
144145
* (it is `src`, not the test layer), so the weld is enforced by the package's
145146
* own type check and not only by a test.
146147
*
147148
* It exists so {@link UNDISPATCHED_LIFECYCLE_HOOK_EVENTS} below is DERIVED. A
148149
* hand-written list of "the events we refuse" would be this card's own defect
149150
* one layer up: it would go stale the moment a verb is added, and silently.
150151
*/
151-
const ENGINE_OPERATION_VERBS: Record<OperationContext['operation'], true> = {
152+
const ENGINE_OPERATION_VERBS: Record<OperationContext['operation'], boolean> = {
152153
find: true,
153154
findOne: true,
154155
insert: true,
155156
update: true,
156157
delete: true,
157158
count: true,
158159
aggregate: true,
160+
// [#30237] The recycle bin's removal verb names no hook pair. `beforePurge` /
161+
// `afterPurge` stay outside the lifecycle namespace, where they were before
162+
// the verb existed: inside it they would be refused at registration, a
163+
// narrowing of `registerHook` this verb does not make. The permanent delete
164+
// still fires `beforeDelete` / `afterDelete`.
165+
purge: false,
159166
};
160167

161168
/**
162169
* [#17713] The engine's own lifecycle-event NAMESPACE — `before`/`after` × every
163-
* verb in {@link ENGINE_OPERATION_VERBS}. Fourteen names, of which
164-
* {@link DISPATCHABLE_HOOK_EVENTS} is the eight the engine actually dispatches.
170+
* verb in {@link ENGINE_OPERATION_VERBS} that names a hook pair. Fourteen names,
171+
* of which {@link DISPATCHABLE_HOOK_EVENTS} is the eight the engine actually
172+
* dispatches.
165173
*/
166174
const ENGINE_LIFECYCLE_HOOK_EVENTS: ReadonlySet<string> = new Set(
167-
Object.keys(ENGINE_OPERATION_VERBS).flatMap((verb) => {
175+
Object.keys(ENGINE_OPERATION_VERBS).filter((verb) => ENGINE_OPERATION_VERBS[verb as OperationContext['operation']]).flatMap((verb) => {
168176
const suffix = `${verb.charAt(0).toUpperCase()}${verb.slice(1)}`;
169177
return [`before${suffix}`, `after${suffix}`];
170178
}),
@@ -321,7 +329,7 @@ const ENGINE_UPDATE_OPTION_KEYS: ReadonlySet<string> = new Set([
321329
...ENGINE_DRIVER_PASSTHROUGH_KEYS,
322330
]);
323331
const ENGINE_DELETE_OPTION_KEYS: ReadonlySet<string> = new Set([
324-
'context', 'where', 'multi',
332+
'context', 'where', 'multi', 'permanent',
325333
...ENGINE_DRIVER_PASSTHROUGH_KEYS,
326334
]);
327335
// `search` / `searchFields` are READ by `count` and `aggregate` through the
@@ -3659,7 +3667,16 @@ export interface FunctionRegistrationOptions {
36593667
*/
36603668
export interface OperationContext {
36613669
object: string;
3662-
operation: 'find' | 'findOne' | 'insert' | 'update' | 'delete' | 'count' | 'aggregate';
3670+
/**
3671+
* The verb the middleware chain authorizes. `purge` [#30237, ADR-0148] is the
3672+
* recycle bin's removal verb, dispatched on the business object the rows
3673+
* belong to: a by-id `delete` with `permanent: true` (its `where.id` the live
3674+
* row's), `purge(entryId)` and `empty(object)` (no id). It never carries an
3675+
* `ast`, so `@objectstack/plugin-security`'s AST path (step 3), which hands the
3676+
* verb to the RLS compiler raw, never sees it (#7809,
3677+
* `engine-middleware-operation-vocabulary.test.ts`).
3678+
*/
3679+
operation: 'find' | 'findOne' | 'insert' | 'update' | 'delete' | 'count' | 'aggregate' | 'purge';
36633680
ast?: QueryAST;
36643681
data?: any;
36653682
options?: any;

0 commit comments

Comments
 (0)