Skip to content

Commit 25e1c1a

Browse files
feat(objectql,platform-objects): keep a by-id delete's rows in the recycle bin (ADR-0148) (#30780)
Part of #30237: the first of five PRs in the seat's split (claim amendment 6673429400). This one adds the object, the snapshot on delete, the retention stamp and the TTL reap. Still on the card: purge, empty and permanent delete (PR 2), 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 owed — skills/objectstack-api/SKILL.md :268, :417 (they teach that there is no recycle bin) ## 维护者速读 - 改了什么:按 id 删除一条业务记录时,引擎先把这条记录和它的主从级联子记录(主记录在前)连同被置空的引用快照进新表 `sys_recycle_bin`,一次删除一条,然后照旧物理删除;生命周期清理器在每条的 `purge_after` 到期后删掉它。 - 为什么改:ADR-0148 回收站的引擎前半段:没有快照就谈不上恢复。恢复、清除、权限和 REST 接口在后面四个 PR。 - 风险与回滚:每次按 id 删除业务记录都会多读一遍级联行、多写一行;回收站表写失败时整个删除会被拒(不丢快照)。保留期配置错了也会拒删,环境变量配错时启动就会失败。按谓词删除(含生命周期清理)、级联子记录、平台对象(`sys_*`、`cloud_*`、`ai_*`)不进回收站;回收站条目自身的清理、置空和随组织删除都不写审计账本。回滚就是 revert 本 PR,已写入的条目只是一张没人读的表。 - 先看:`packages/objectql/src/recycle-bin/capture.ts`(快照与写入、补偿)、`packages/objectql/src/engine.ts` 的 `runByIdDelete`(接入点)、`packages/objectql/src/engine-cascade-planning.ts`(`onCollected` 与置空引用记录)、`packages/plugins/plugin-audit/src/audit-writers.ts` 的 `SKIP_OBJECTS`。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run --project local src/recycle-bin/`,预期 2 个文件、47 个测试全过。 - 需要你做的:无,席位落地。 ## What changes **`@objectstack/platform-objects`**: `sys_recycle_bin` (`system/sys-recycle-bin.object.ts`), registered by `PlatformObjectsPlugin` beside `sys_secret`. - Its fields are the spec's `RECYCLE_BIN_ENTRY_FIELDS`, key for key, plus the tenant column. Each entry is spelled through its builder (`Field.text({ ...RECYCLE_BIN_ENTRY_FIELDS.object_name, maxLength: 255 })`) rather than spread in, because `check:keyed-text-bounds` refuses a spread and reads a keyed text column's bound only from a literal. The two keyed text bounds are restated, and `plugin.test.ts` pins every entry equal to the spec's, so the table and `RecycleBinEntrySchema` cannot drift. - It is `managedBy: 'engine-owned'`, has `access: { default: 'private' }`, and serves no generic data-API method (`apiEnabled: false`, `apiMethods: []`): the snapshot holds every field of the deleted rows, so it is read only through the doors of PR 4 and PR 5, under the live object's FLS. - Its tenant column `organization_id` declares `deleteBehavior: 'cascade'` (seat ruling Q4): an organization's delete takes its entries with it, and the entries are never counted against that delete. - `lifecycle: { class: 'transient', ttl: { field: 'purge_after', expireAfter: '0h' } }`: the sweep reaps an entry once its stamped deadline passes, and asks no resolver. **`@objectstack/objectql`**: the write side, in `recycle-bin/capture.ts` (the engine member is a delegation, per the facade gate). - `delete()`'s by-id branch, inside `runByIdDelete`, so it shares the #7413 unit of work: 1. `openRecycleBinCapture` runs before the cascade, and opens nothing when the caller asked for `multi: true`. A retention defect refuses the delete here, before any write. 2. `cascadeDeleteRelations` takes a new `onCollected` callback. It runs once with the freshly collected set, after the #26718 restricts are judged and before the walk writes. The capture reads each member's row raw, inside the unit, in BFS order from the root along `cascadeEdges` (master first). It keeps declared columns only, leaves out `secret` columns (D4) and skips `sys_*` members. 3. The walk's `set_null` branch appends `{ object, id, field, value }` to the new optional `CascadeDeleteSet.clearedReferences` for both arities (D2's re-link input). 4. After the walk, and before `driver.delete(root)`, ONE entry is written raw through the bin's driver with this unit's driver options. If removing the root row throws, the entry goes with it: the transaction's rollback when it covered the bin, else the capture's compensation (logged at `error` if that fails too). - `deleted_by`: the authorized subject, else `attributedUserId` (the audit ledger's precedence). `organization_id`: the root row's tenant column, else the context's. - Retention (D3, seat ruling Q2): `ObjectQLPlugin({ recycleBinRetention: { resolve, token } })` → `engine.setRecycleBinRetention`. The resolver is asked once per delete with the host's token. When it answers `undefined`: `OS_RECYCLE_BIN_RETENTION_DAYS`, else 30. A bad answer is refused with the spec's `recycleBinRetentionRefusal` text. The variable is also read once in `init()`, so a bad value stops the boot. - What does NOT enter the bin (seat ruling Q6, and the seat's ruling 6675653517 on the review's question 7): - predicate deletes (`multi: true`), lifecycle reaps included. The engine routes a delete by its id, so the sweep's per-row `where: { id }, multi: true` reap reaches the by-id branch; the capture is told apart by the caller's own flag; - cascade children, which are rows of the root's entry; - platform objects, meaning names in the `sys_`, `cloud_` or `ai_` namespace (`hasPlatformObjectPrefix`, the tenancy census's population); - a composition without the bin, which logs one `info` line per engine. That is reduced functionality, not a lost write. **`@objectstack/spec`** (both hunks consented by domain:spec#3 in 6673450505 on mailbox PR #30658): - `sys_recycle_bin` joins `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']`, in the same commit (`d374cbe5`) as the object file, its registration and the ownership-inventory row. The tenancy census row followed in `819b02f7`, after its gate reported the drift. - The `//` comment above `RECYCLE_BIN_OBJECT` in `recycle-bin.zod.ts` now says in the present tense where the object file lives. No `.describe()`, TSDoc, shape or export changed, and the module header is untouched. **`@objectstack/plugin-audit`**: `sys_recycle_bin` joins `SKIP_OBJECTS` (group 2, beside `sys_job_queue` / `sys_upload_session`, with its reason; amendment 6675653517). The generic writer would otherwise copy the whole entry, `snapshot` and its `internal: true` columns included, into `sys_audit_log` and `sys_activity` on the sweep's reap, on the `set_null` of `deleted_by` and on an organization delete's cascade (ADR-0057 §3.3, ADR-0148 D4, the #30751 precedent). The business delete an entry was taken for is audited as before. **`@objectstack/cli`**: the organization-ownership inventory row for `sys_recycle_bin` (fate `attribution`, like `sys_exchange_rate`). Census rows: - `federated-injected-column-readers.test.ts` gains a READERS row for the new `resolveTenantFieldName()` use (`row-value`, the realtime publisher's disposition). - `scripts/platform-object-tenancy-census.json` is regenerated with `--write`: `sys_recycle_bin` joins the population, in reach by `organization_id` (registered 93 → 94, in reach 47 → 48). - One new ADR anchor: `scripts/adr-anchors/packages__objectql__src__recycle-bin__capture.ts.json`. ## Tests - `packages/objectql/src/recycle-bin/retention.test.ts` (26 cases): - resolver → variable → 30; - the token is passed through; - every defect from either source is refused with the spec sentence, never with the default; - the boot read; - the `purge_after` arithmetic. - `packages/objectql/src/recycle-bin/capture.test.ts` (21 cases) runs a real `ObjectQL` on a driver with real rollback: - one entry per by-id delete: rows master first, both `set_null` arities in `clearedReferences`, `deleted_by`, `organization_id`, the schema-valid entry; - `purge_after` = `deleted_at` + retention; - attribution of a system-authorized delete; - the secret column left out, and undeclared columns left out; - the entry carries the same transaction handle as the root delete; - no entry for a cascade child, a predicate delete, a by-id delete asked with `multi: true`, a `LifecycleService.sweep()` reap of a business object that declares a lifecycle, a `sys_` / `cloud_` / `ai_` object, or a composition with no bin (one notice); - an organization's delete removes its entries, is not refused by them, and leaves another organization's entries (ruling Q4); - the resolver's days and token are stamped, and a defect refuses before any write; - a failed root removal: the entry is rolled back with the transaction, or compensated without one; - the TTL sweep reaps only due entries and asks no resolver; - the BFS order helper. - `packages/plugins/plugin-audit/src/audit-writers.test.ts` (3 cases): the reap of an entry and the update that clears its `deleted_by` write no ledger or activity row, and the business delete still writes both. - `packages/platform-objects/src/plugin.test.ts`: the registration list includes `SysRecycleBin`, and a new case pins its fields equal to the spec's `RECYCLE_BIN_ENTRY_FIELDS` (both sides through `ObjectSchema.create`) plus the cascading tenant lookup. ## Acceptance notes - The card's Wanted items in this slice: snapshot before the physical delete (business objects only), TTL reap on the LifecycleService, every read path untouched. - Restore, purge, the grants and the doors are the later PRs. - Forward-only (D4): deletes before this release have no entry. The changeset says so, and says which deletes skip the bin. ## Verification Head `6a07be61`. The contract review of `819b02f7` (6675639559) failed, and this head fixes both must-fixes, the seat's ruling on question 7 and should-fixes (a) to (d). - **Gates on `6a07be61`:** `node scripts/pm/dispatch-gates.mjs --ran` reports 99 derived, 99 run, 0 NOT-MEASURED, 0 UNRUN. Every family exited 0, including the two `check-tenant-audit-census` families that plugin-audit adds, and `pnpm lint` exited 0. The earlier rounds, on `136ad85d` and `819b02f7`, found three reds, fixed in `819b02f7`: the declaration shape for `check:keyed-text-bounds`, the test double's bound, and the tenancy census. - **Typecheck:** `pnpm --filter PKG typecheck` exits 0 for `@objectstack/objectql`, `@objectstack/plugin-audit` and `@objectstack/platform-objects`. - **Tests:** - the objectql suite: Test Files 559 passed (559), Tests 11132 passed (11132); - the plugin-audit suite: Test Files 75 passed (75), Tests 1164 passed (1164); - `src/recycle-bin/`: 47/47; - `platform-objects` `plugin.test.ts`: 23/23. - **Reverse verification**, with `scripts/ablation-replace.mjs`. Every suite imports `src` relatively, and every restore printed `blob == HEAD` and an empty `git diff HEAD`. - `openRecycleBinCapture(...)` replaced by `undefined`: `capture.test.ts` fails 11 of 16 (at `819b02f7`). - `object_name`'s bound restated as 254: the field pin fails. - `'sys_recycle_bin'` removed from `SKIP_OBJECTS`: 2 of the 3 new plugin-audit cases fail. The business control stays green. - The capture's `multi` flag forced to `false`: the `multi: true` by-id case and the `LifecycleService.sweep()` case fail. - `recycleBinTakesObject` narrowed back to `sys_` only: the `cloud_thing` and `ai_thing` cases fail. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr)_ <!-- signed: domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b618224 commit 25e1c1a

22 files changed

Lines changed: 1460 additions & 20 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/platform-objects': minor
4+
'@objectstack/spec': minor
5+
'@objectstack/cli': minor
6+
'@objectstack/plugin-audit': minor
7+
---
8+
9+
feat(objectql,platform-objects): a by-id delete keeps its rows in the recycle bin (ADR-0148): the `sys_recycle_bin` object, the snapshot on delete, the retention stamp and the TTL reap
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: added
13+
Skills-docs: follow-up owed — skills/objectstack-api/SKILL.md :268, :417 (they teach that there is no recycle bin)
14+
15+
The recycle bin's write side. A delete is still a physical delete, and no read path changed: the rows are gone from every query the moment the delete commits. What is new is the copy kept beside them. Restoring, purging and emptying the bin, the two grants that govern them and their REST doors are not in this release.
16+
17+
- **`@objectstack/platform-objects`:** registers `sys_recycle_bin`, built from the spec's `RECYCLE_BIN_ENTRY_FIELDS`. One entry per delete operation. It is engine-owned, with no generic data-API method, because the snapshot holds every field of the deleted rows. Its tenant column cascades, so deleting an organization deletes its entries. The lifecycle sweep reaps an entry once its stamped `purge_after` has passed (`ttl` on `purge_after`, window `0h`), and asks no resolver.
18+
- **`@objectstack/objectql`:** a by-id delete of a business record writes one entry in the delete's own unit of work, before the record's row is removed. The entry holds the record and its master-detail cascade, the master first. Each row keeps its declared columns, and `secret` columns are left out, so a restored record's secret fields come back empty. The entry also records the `set_null` references the delete cleared, and `deleted_by`, `deleted_at`, `retention_days` and `purge_after`. If removing the row fails, the entry goes too: the transaction rolls it back, or the engine removes it when no transaction covered it.
19+
- **Retention:** `ObjectQLPlugin`'s new `recycleBinRetention: { resolve, token }` option takes the host's retention resolver, called once per delete with the token the host handed over. When the option is absent or the resolver answers `undefined`, the retention is `OS_RECYCLE_BIN_RETENTION_DAYS`, else 30 days. A value that is not a positive whole number of days is refused: from the resolver, the delete is refused before it writes anything; in the variable, the boot is refused, because the plugin reads it in `init()`.
20+
- **Which deletes do NOT enter the bin:**
21+
- a delete by predicate (`multi: true`), which includes every lifecycle sweep reap, even though the sweep names each reaped row by its id;
22+
- a child the cascade deletes, which is a row of its root's entry instead;
23+
- any platform object, meaning a `sys_*`, `cloud_*` or `ai_*` object, whose rows are also left out of a business record's snapshot;
24+
- any delete on a composition that does not register `PlatformObjectsPlugin`, which logs one `info` line saying so.
25+
- **Forward-only:** records deleted before this release have no entry.
26+
- **Storage:** every by-id delete of a business record now also writes one `sys_recycle_bin` row holding the deleted rows. The row is reaped once its retention has passed.
27+
- **`@objectstack/spec`:** `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` lists `sys_recycle_bin`, so a reference to it lints as a real platform object. The comment above `RECYCLE_BIN_OBJECT` now names the object file that builds the table; no exported name, shape or description changed.
28+
- **`@objectstack/cli`:** the organization-ownership inventory has a row for `sys_recycle_bin` (fate `attribution`).
29+
- **`@objectstack/plugin-audit`:** `sys_recycle_bin` is excluded from the generic audit and activity writer, like the other engine-owned plumbing, so reaping an entry, clearing its `deleted_by` or cascading an organization's delete into its entries never copies a snapshot into `sys_audit_log` or `sys_activity`. The business delete an entry was taken for is audited exactly as before.

‎packages/cli/src/utils/organization-ownership-inventory.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,13 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
813813
// Exchange rates (`@objectstack/platform-objects`, #27185): an organization's
814814
// administrators author its own rate table; no parent row anchors a rate.
815815
{ object: 'sys_exchange_rate', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] },
816+
// The recycle bin (`@objectstack/platform-objects`, ADR-0148, #30237): the
817+
// engine writes each entry with the organization of the record it holds,
818+
// else the deleting context's. A NULL row comes only from a delete with
819+
// neither (a tenancy-disabled object deleted by a context with no active
820+
// organization). The record an entry names is gone, so no parent row
821+
// anchors an entry.
822+
{ object: 'sys_recycle_bin', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] },
816823
// A reader's own named list views (`@objectstack/platform-objects`, ruling
817824
// 6570568131 on #28971): user data on the preference template, each row in
818825
// its owner's organization; no parent row anchors a view.

‎packages/objectql/src/engine-cascade-planning.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,8 +511,14 @@ export class CascadePlanningFacet {
511511
* A nested `delete()` the walk makes for a member joins the ambient set
512512
* ({@link cascadeDeleteSets}); any other delete reaching here — a hook's,
513513
* say — is the root of its own cascade, as before.
514+
*
515+
* [#30237, ADR-0148 D1] `onCollected` is the recycle bin's moment: called
516+
* once with the freshly collected set, after the #26718 restricts are judged
517+
* and before the walk deletes or clears anything, so the snapshot reads every
518+
* row as it stood. Never called for a member that joins a cascade already in
519+
* progress: such a row belongs to its root's entry.
514520
*/
515-
static async cascadeDeleteRelations(this: Pick<CascadePlanningHost, '_registry' | 'cascadeDeleteSets' | 'collectCascadeDeleteSet' | 'refuseRestrictsInsideOwnCascade' | 'walkReferencingRelations'>, object: string, ids: ReadonlyArray<string | number>, context?: ExecutionContext, depth = 0): Promise<void> {
521+
static async cascadeDeleteRelations(this: Pick<CascadePlanningHost, '_registry' | 'cascadeDeleteSets' | 'collectCascadeDeleteSet' | 'refuseRestrictsInsideOwnCascade' | 'walkReferencingRelations'>, object: string, ids: ReadonlyArray<string | number>, context?: ExecutionContext, depth = 0, onCollected?: (set: CascadeDeleteSet) => Promise<void>): Promise<void> {
516522
const roots: Array<string | number> = [];
517523
const seen = new Set<string>();
518524
for (const id of ids) {
@@ -555,6 +561,7 @@ export class CascadePlanningFacet {
555561
// [#26718] Every restrict a member's own cascade would override is judged
556562
// here, before the first write.
557563
await this.refuseRestrictsInsideOwnCascade(cascadeSet, fresh.map((id) => ({ object, id })), context, objects);
564+
if (onCollected) await onCollected(cascadeSet);
558565
await this.cascadeDeleteSets.run(cascadeSet, () =>
559566
this.walkReferencingRelations(object, fresh, context, objects, cascadeSet),
560567
);
@@ -1163,6 +1170,13 @@ export class CascadePlanningFacet {
11631170
} else {
11641171
await this.update(childName, { id: depId, [fieldName]: null }, { context: referentialCtx } as any);
11651172
}
1173+
// [#30237, ADR-0148 D2] The reference a restore re-links while
1174+
// the field still lacks it: the live row, the field, and the id
1175+
// this delete removed from it. Only a delete that enters the
1176+
// recycle bin carries the list.
1177+
cascadeSet.clearedReferences?.push({
1178+
object: childName, id: String(depId), field: fieldName, value: String(id),
1179+
});
11661180
}
11671181
}
11681182
// [#27004] Every row outside the set has had `id` cleared from it.

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// importer of `./engine.js` reads what it read.
1313

1414
import { AUDIT_PROVENANCE_FIELDS, ENGINE_UPDATE_UPSERT_REMOVED, FILE_REFERENCE_TYPES, FullTextSearchSchema, QUERY_CURSOR_REMOVED, QUERY_DISTINCT_REMOVED, REFERENCE_VALUE_TYPES, RPC_QUERY_ALIAS_SLOTS, VALID_AST_OPERATORS, foldQueryAliasSlots, isFilterAST, isMultiValueField, lowerFilterCondition, normalizeFilterComparandTypes, parseFilterAST, readAutonumberCounter, referenceCarrierOf, referenceTargetOf, resolveInjectedColumnProvenance } from '@objectstack/spec/data';
15-
import type { DroppedFieldsEvent, FilterCondition, FilterLoweringOptions, HookContext, QueryAST, QueryAliasSlot, ServiceObject } from '@objectstack/spec/data';
15+
import type { DroppedFieldsEvent, FilterCondition, FilterLoweringOptions, HookContext, QueryAST, QueryAliasSlot, RecycleBinClearedReference, ServiceObject } from '@objectstack/spec/data';
1616
import type { EngineFilterJudgement, ISecurityService } from '@objectstack/spec/contracts';
1717
import { assertFilterIsMaterializable, assertListComparandShapes, collectComposableReadableIds, invalidFilterError } from './filter-comparand-shape.js';
1818
import { assertResolvedTemporalTokensInRange, assertTemporalComparandsInterpretable } from './temporal-comparand-door.js';
@@ -4372,6 +4372,16 @@ export interface CascadeDeleteSet {
43724372
* one has removed from it; the remainder written leaves those out too.
43734373
*/
43744374
readonly cleared: Map<string, Set<string>>;
4375+
/**
4376+
* [#30237, ADR-0148 D2] Every `set_null` reference the walk cleared, one per
4377+
* live row, field and removed id: the referencing row, the field, and the id
4378+
* the field held (a row this delete removes). Present only on the set of a
4379+
* delete that enters the recycle bin, which assigns it when the set is
4380+
* collected; the walk appends to it, nested deletes of the same cascade
4381+
* included, and the entry keeps it so a restore can re-link what is still
4382+
* empty.
4383+
*/
4384+
clearedReferences?: RecycleBinClearedReference[];
43754385
}
43764386

43774387
/** [#26718] One member of a {@link CascadeDeleteSet}, as a single key. */

‎packages/objectql/src/engine.ts‎

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ import {
8181
type SecretHandleSlot,
8282
type StoredRowsReader,
8383
} from './engine-secret-internal-fields.js';
84+
// [#30237, ADR-0148] The recycle bin's write side: the capture body, and the
85+
// retention seam the host hands over (`RecycleBinCaptureHost`).
86+
import { openRecycleBinCapture, type RecycleBinCapture } from './recycle-bin/capture.js';
87+
import type { RecycleBinRetentionSettings } from './recycle-bin/retention.js';
8488
// [#25522 cut 6] The predicate-write hook dispatch bodies; the methods of the
8589
// same names below hand each body the members it reads (`HookDispatchHost`).
8690
import {
@@ -8790,9 +8794,55 @@ export class ObjectQL implements IObjectQLEngine {
87908794
ids: ReadonlyArray<string | number>,
87918795
context?: ExecutionContext,
87928796
depth = 0,
8797+
onCollected?: (set: CascadeDeleteSet) => Promise<void>,
87938798
): Promise<void> {
87948799
// [#29986 codemod] The body moved to `engine-cascade-planning.ts`, its docblock with it (see `CascadePlanningHost`).
8795-
return CascadePlanningFacet.cascadeDeleteRelations.call(cascadePlanningHost.of(this), object, ids, context, depth);
8800+
return CascadePlanningFacet.cascadeDeleteRelations.call(cascadePlanningHost.of(this), object, ids, context, depth, onCollected);
8801+
}
8802+
8803+
/**
8804+
* [#30237, ADR-0148 D3] What the host handed over for the recycle bin's
8805+
* retention: its resolver and the token to call it with. `undefined` until
8806+
* {@link setRecycleBinRetention}: `OS_RECYCLE_BIN_RETENTION_DAYS`, else 30.
8807+
*/
8808+
private recycleBinRetention: RecycleBinRetentionSettings | undefined;
8809+
8810+
/** [#30237] The recycle bin's once-per-engine notices already given. */
8811+
private readonly recycleBinNotices = new Set<string>();
8812+
8813+
/**
8814+
* [#30237, ADR-0148 D3] Install the host's retention resolver and the opaque
8815+
* token it is called with (the one its `resolveFeatures` receives). The
8816+
* engine asks once per delete operation that enters the bin and stamps the
8817+
* answer on the entry; `ObjectQLPlugin` calls this from its
8818+
* `recycleBinRetention` option.
8819+
*/
8820+
setRecycleBinRetention(settings: RecycleBinRetentionSettings | undefined): void {
8821+
this.recycleBinRetention = settings;
8822+
}
8823+
8824+
private openRecycleBinCapture(
8825+
object: string,
8826+
id: string | number,
8827+
context: ExecutionContext | undefined,
8828+
predicate: boolean,
8829+
): RecycleBinCapture | undefined {
8830+
// [#30237] The body lives in `recycle-bin/capture.ts`, its docblock with it (see `RecycleBinCaptureHost`).
8831+
const readRegistry = () => this._registry;
8832+
const readLogger = () => this.logger;
8833+
const readCascadeDeleteSets = () => this.cascadeDeleteSets;
8834+
const readRetention = () => this.recycleBinRetention;
8835+
const readNotices = () => this.recycleBinNotices;
8836+
const host: ThisParameterType<typeof openRecycleBinCapture> = {
8837+
get _registry() { return readRegistry(); },
8838+
get logger() { return readLogger(); },
8839+
get cascadeDeleteSets() { return readCascadeDeleteSets(); },
8840+
get recycleBinRetention() { return readRetention(); },
8841+
get recycleBinNotices() { return readNotices(); },
8842+
getDriver: (...a) => this.getDriver(...a),
8843+
buildDriverOptions: (...a) => this.buildDriverOptions(...a),
8844+
};
8845+
return openRecycleBinCapture.call(host, object, id, context, predicate);
87968846
}
87978847

87988848
/**
@@ -9460,14 +9510,30 @@ export class ObjectQL implements IObjectQLEngine {
94609510
// A dependent's stale-roll-up refusal stops the walk before this
94619511
// row is removed, so it is not this delete's answer:
94629512
// `cascadeStoppedRefusal` says what it is instead.
9513+
//
9514+
// [#30237, ADR-0148 D1] The recycle bin rides the same unit: the
9515+
// capture opens before the cascade (a retention defect refuses
9516+
// here, before any write), reads the collected set before the
9517+
// walk touches it, and writes the one entry after the walk and
9518+
// before this row goes. A removal that throws takes the entry
9519+
// with it: the transaction's rollback when one covers the bin,
9520+
// the capture's compensation otherwise. A caller's `multi: true`
9521+
// opens none, even here: the lifecycle sweep reaps by id with it.
94639522
const runByIdDelete = async (writeContext?: ExecutionContext) => {
9464-
await this.cascadeDeleteRelations(object, [hookContext.input.id as string | number], writeContext)
9523+
const bin = this.openRecycleBinCapture(object, hookContext.input.id as string | number, writeContext, options?.multi === true);
9524+
await this.cascadeDeleteRelations(object, [hookContext.input.id as string | number], writeContext, 0, bin?.onCollected)
94659525
.catch((e: unknown) => { throw cascadeStoppedRefusal(e, object); });
9466-
return await driver.delete(
9467-
object,
9468-
hookContext.input.id as string,
9469-
this.buildDriverOptions(object, writeContext, hookContext.input.options as any),
9470-
);
9526+
const entry = await bin?.write();
9527+
try {
9528+
return await driver.delete(
9529+
object,
9530+
hookContext.input.id as string,
9531+
this.buildDriverOptions(object, writeContext, hookContext.input.options as any),
9532+
);
9533+
} catch (e) {
9534+
await entry?.compensate();
9535+
throw e;
9536+
}
94719537
};
94729538
// WHEN to open one is `planCascadeAtomicity`'s call — see there.
94739539
// `'none'` (nothing references this object) keeps the pre-#7413

‎packages/objectql/src/federated-injected-column-readers.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,11 @@ const READERS: Record<string, Row> = {
309309
disposition: 'row-value',
310310
why: 'reads the tenant column off the written row; a federated row has none, so the event omits it',
311311
},
312+
// [#30237] A recycle-bin entry belongs to the organization of the record it holds.
313+
'recycle-bin/capture.ts#organizationOf :: resolveTenantFieldName()': {
314+
disposition: 'row-value',
315+
why: "reads the tenant column off the snapshotted root row; a federated row has none, so the entry takes the deleting context's organization",
316+
},
312317
'relation-filter-lowering.ts#admitRelationCondition :: referenceTargetOf()': {
313318
disposition: 'caller-predicate',
314319
why: "lowers a relation condition the caller wrote; it names an injected column only when the caller did",

‎packages/objectql/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,8 @@ export type {
630630
LifecycleFloorViolation,
631631
} from './lifecycle/lifecycle-service.js';
632632
export { parseLifecycleDuration } from './lifecycle/duration.js';
633+
// [#30237, ADR-0148 D3] What a host hands `ObjectQLPlugin`'s `recycleBinRetention`.
634+
export type { RecycleBinRetentionSettings } from './recycle-bin/retention.js';
633635
export { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js';
634636

635637
// [#4551] Read-only referential-integrity audit — the reporting half of the

0 commit comments

Comments
 (0)