Repository navigation
Commit 25e1c1a
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
File tree
- .changeset
- packages
- cli/src/utils
- objectql/src
- recycle-bin
- platform-objects/src
- system
- plugins/plugin-audit/src
- spec/src
- data
- system/constants
- scripts
- adr-anchors
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
813 | 813 | | |
814 | 814 | | |
815 | 815 | | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
816 | 823 | | |
817 | 824 | | |
818 | 825 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
511 | 511 | | |
512 | 512 | | |
513 | 513 | | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
514 | 520 | | |
515 | | - | |
| 521 | + | |
516 | 522 | | |
517 | 523 | | |
518 | 524 | | |
| |||
555 | 561 | | |
556 | 562 | | |
557 | 563 | | |
| 564 | + | |
558 | 565 | | |
559 | 566 | | |
560 | 567 | | |
| |||
1163 | 1170 | | |
1164 | 1171 | | |
1165 | 1172 | | |
| 1173 | + | |
| 1174 | + | |
| 1175 | + | |
| 1176 | + | |
| 1177 | + | |
| 1178 | + | |
| 1179 | + | |
1166 | 1180 | | |
1167 | 1181 | | |
1168 | 1182 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
15 | | - | |
| 15 | + | |
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| |||
4372 | 4372 | | |
4373 | 4373 | | |
4374 | 4374 | | |
| 4375 | + | |
| 4376 | + | |
| 4377 | + | |
| 4378 | + | |
| 4379 | + | |
| 4380 | + | |
| 4381 | + | |
| 4382 | + | |
| 4383 | + | |
| 4384 | + | |
4375 | 4385 | | |
4376 | 4386 | | |
4377 | 4387 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
81 | 81 | | |
82 | 82 | | |
83 | 83 | | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
84 | 88 | | |
85 | 89 | | |
86 | 90 | | |
| |||
8790 | 8794 | | |
8791 | 8795 | | |
8792 | 8796 | | |
| 8797 | + | |
8793 | 8798 | | |
8794 | 8799 | | |
8795 | | - | |
| 8800 | + | |
| 8801 | + | |
| 8802 | + | |
| 8803 | + | |
| 8804 | + | |
| 8805 | + | |
| 8806 | + | |
| 8807 | + | |
| 8808 | + | |
| 8809 | + | |
| 8810 | + | |
| 8811 | + | |
| 8812 | + | |
| 8813 | + | |
| 8814 | + | |
| 8815 | + | |
| 8816 | + | |
| 8817 | + | |
| 8818 | + | |
| 8819 | + | |
| 8820 | + | |
| 8821 | + | |
| 8822 | + | |
| 8823 | + | |
| 8824 | + | |
| 8825 | + | |
| 8826 | + | |
| 8827 | + | |
| 8828 | + | |
| 8829 | + | |
| 8830 | + | |
| 8831 | + | |
| 8832 | + | |
| 8833 | + | |
| 8834 | + | |
| 8835 | + | |
| 8836 | + | |
| 8837 | + | |
| 8838 | + | |
| 8839 | + | |
| 8840 | + | |
| 8841 | + | |
| 8842 | + | |
| 8843 | + | |
| 8844 | + | |
| 8845 | + | |
8796 | 8846 | | |
8797 | 8847 | | |
8798 | 8848 | | |
| |||
9460 | 9510 | | |
9461 | 9511 | | |
9462 | 9512 | | |
| 9513 | + | |
| 9514 | + | |
| 9515 | + | |
| 9516 | + | |
| 9517 | + | |
| 9518 | + | |
| 9519 | + | |
| 9520 | + | |
| 9521 | + | |
9463 | 9522 | | |
9464 | | - | |
| 9523 | + | |
| 9524 | + | |
9465 | 9525 | | |
9466 | | - | |
9467 | | - | |
9468 | | - | |
9469 | | - | |
9470 | | - | |
| 9526 | + | |
| 9527 | + | |
| 9528 | + | |
| 9529 | + | |
| 9530 | + | |
| 9531 | + | |
| 9532 | + | |
| 9533 | + | |
| 9534 | + | |
| 9535 | + | |
| 9536 | + | |
9471 | 9537 | | |
9472 | 9538 | | |
9473 | 9539 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
309 | 309 | | |
310 | 310 | | |
311 | 311 | | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
312 | 317 | | |
313 | 318 | | |
314 | 319 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
630 | 630 | | |
631 | 631 | | |
632 | 632 | | |
| 633 | + | |
| 634 | + | |
633 | 635 | | |
634 | 636 | | |
635 | 637 | | |
| |||
0 commit comments