From d374cbe520d505e90b255b50e2b2d7205e97fb89 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 01:17:14 +0000 Subject: [PATCH 1/5] feat(objectql,platform-objects): keep a by-id delete's rows in the recycle bin ADR-0148, first slice: the sys_recycle_bin object, the snapshot on delete, the retention stamp and the TTL reap. - platform-objects registers sys_recycle_bin, built from the spec's RECYCLE_BIN_ENTRY_FIELDS: engine-owned, no generic data-API method, its tenant column cascades, ttl on purge_after. - objectql: a by-id delete of a business record writes one entry in its own unit of work, before the root row goes: the cascade set read raw before the walk (master first, secret columns left out), the set_null references the walk cleared, deleted_by/deleted_at, retention_days and purge_after. A removal that fails takes the entry with it. - retention: ObjectQLPlugin's recycleBinRetention { resolve, token }, else OS_RECYCLE_BIN_RETENTION_DAYS (read at init), else 30 days; a value that is not a positive whole number is refused. - spec: sys_recycle_bin joins PLATFORM_OBJECTS_BY_PACKAGE; cli: its organization-ownership inventory row. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr --- .../30237-recycle-bin-snapshot-on-delete.md | 26 ++ .../utils/organization-ownership-inventory.ts | 5 + .../objectql/src/engine-cascade-planning.ts | 16 +- packages/objectql/src/engine-top-level.ts | 12 +- packages/objectql/src/engine.ts | 78 +++- .../federated-injected-column-readers.test.ts | 5 + packages/objectql/src/index.ts | 2 + packages/objectql/src/plugin.ts | 25 + .../objectql/src/recycle-bin/capture.test.ts | 432 ++++++++++++++++++ packages/objectql/src/recycle-bin/capture.ts | 323 +++++++++++++ .../src/recycle-bin/retention.test.ts | 100 ++++ .../objectql/src/recycle-bin/retention.ts | 98 ++++ packages/platform-objects/src/plugin.test.ts | 12 +- packages/platform-objects/src/plugin.ts | 9 +- packages/platform-objects/src/system/index.ts | 2 + .../src/system/sys-recycle-bin.object.ts | 107 +++++ .../system/constants/platform-object-names.ts | 1 + ...bjectql__src__recycle-bin__capture.ts.json | 5 + 18 files changed, 1244 insertions(+), 14 deletions(-) create mode 100644 .changeset/30237-recycle-bin-snapshot-on-delete.md create mode 100644 packages/objectql/src/recycle-bin/capture.test.ts create mode 100644 packages/objectql/src/recycle-bin/capture.ts create mode 100644 packages/objectql/src/recycle-bin/retention.test.ts create mode 100644 packages/objectql/src/recycle-bin/retention.ts create mode 100644 packages/platform-objects/src/system/sys-recycle-bin.object.ts create mode 100644 scripts/adr-anchors/packages__objectql__src__recycle-bin__capture.ts.json diff --git a/.changeset/30237-recycle-bin-snapshot-on-delete.md b/.changeset/30237-recycle-bin-snapshot-on-delete.md new file mode 100644 index 00000000000..31730cf9dd5 --- /dev/null +++ b/.changeset/30237-recycle-bin-snapshot-on-delete.md @@ -0,0 +1,26 @@ +--- +'@objectstack/objectql': minor +'@objectstack/platform-objects': minor +'@objectstack/spec': minor +'@objectstack/cli': minor +--- + +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 + +Clause-②: yes (widening) +Metadata-protocol: added + +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. + +- **`@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. +- **`@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. +- **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()`. +- **Which deletes do NOT enter the bin:** + - a delete by predicate (`multi: true`), which includes the lifecycle sweep; + - a child the cascade deletes, which is a row of its root's entry instead; + - any `sys_*` object, whose rows are also left out of a business record's snapshot; + - any delete on a composition that does not register `PlatformObjectsPlugin`, which logs one `info` line saying so. +- **Forward-only:** records deleted before this release have no entry. +- **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. +- **`@objectstack/spec`:** `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` lists `sys_recycle_bin`, so a reference to it lints as a real platform object. +- **`@objectstack/cli`:** the organization-ownership inventory has a row for `sys_recycle_bin` (fate `attribution`). diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 3549c8493c1..c6d7cbb733b 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -803,6 +803,11 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ // Exchange rates (`@objectstack/platform-objects`, #27185): an organization's // administrators author its own rate table; no parent row anchors a rate. { object: 'sys_exchange_rate', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, + // The recycle bin (`@objectstack/platform-objects`, ADR-0148, #30237): the + // engine writes each entry with the organization of the record it holds, + // else the deleting context's, so a NULL row has no producer; the record it + // names is gone, so no parent row anchors an entry. + { object: 'sys_recycle_bin', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, // A reader's own named list views (`@objectstack/platform-objects`, ruling // 6570568131 on #28971): user data on the preference template, each row in // its owner's organization; no parent row anchors a view. diff --git a/packages/objectql/src/engine-cascade-planning.ts b/packages/objectql/src/engine-cascade-planning.ts index 961fa139598..9c00176f6f6 100644 --- a/packages/objectql/src/engine-cascade-planning.ts +++ b/packages/objectql/src/engine-cascade-planning.ts @@ -511,8 +511,14 @@ export class CascadePlanningFacet { * A nested `delete()` the walk makes for a member joins the ambient set * ({@link cascadeDeleteSets}); any other delete reaching here — a hook's, * say — is the root of its own cascade, as before. + * + * [#30237, ADR-0148 D1] `onCollected` is the recycle bin's moment: called + * once with the freshly collected set, after the #26718 restricts are judged + * and before the walk deletes or clears anything, so the snapshot reads every + * row as it stood. Never called for a member that joins a cascade already in + * progress: such a row belongs to its root's entry. */ - static async cascadeDeleteRelations(this: Pick, object: string, ids: ReadonlyArray, context?: ExecutionContext, depth = 0): Promise { + static async cascadeDeleteRelations(this: Pick, object: string, ids: ReadonlyArray, context?: ExecutionContext, depth = 0, onCollected?: (set: CascadeDeleteSet) => Promise): Promise { const roots: Array = []; const seen = new Set(); for (const id of ids) { @@ -555,6 +561,7 @@ export class CascadePlanningFacet { // [#26718] Every restrict a member's own cascade would override is judged // here, before the first write. await this.refuseRestrictsInsideOwnCascade(cascadeSet, fresh.map((id) => ({ object, id })), context, objects); + if (onCollected) await onCollected(cascadeSet); await this.cascadeDeleteSets.run(cascadeSet, () => this.walkReferencingRelations(object, fresh, context, objects, cascadeSet), ); @@ -1163,6 +1170,13 @@ export class CascadePlanningFacet { } else { await this.update(childName, { id: depId, [fieldName]: null }, { context: referentialCtx } as any); } + // [#30237, ADR-0148 D2] The reference a restore re-links while + // the field still lacks it: the live row, the field, and the id + // this delete removed from it. Only a delete that enters the + // recycle bin carries the list. + cascadeSet.clearedReferences?.push({ + object: childName, id: String(depId), field: fieldName, value: String(id), + }); } } // [#27004] Every row outside the set has had `id` cleared from it. diff --git a/packages/objectql/src/engine-top-level.ts b/packages/objectql/src/engine-top-level.ts index c037fd73c7f..e86fd97bcf7 100644 --- a/packages/objectql/src/engine-top-level.ts +++ b/packages/objectql/src/engine-top-level.ts @@ -12,7 +12,7 @@ // importer of `./engine.js` reads what it read. 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'; -import type { DroppedFieldsEvent, FilterCondition, FilterLoweringOptions, HookContext, QueryAST, QueryAliasSlot, ServiceObject } from '@objectstack/spec/data'; +import type { DroppedFieldsEvent, FilterCondition, FilterLoweringOptions, HookContext, QueryAST, QueryAliasSlot, RecycleBinClearedReference, ServiceObject } from '@objectstack/spec/data'; import type { EngineFilterJudgement, ISecurityService } from '@objectstack/spec/contracts'; import { assertFilterIsMaterializable, assertListComparandShapes, collectComposableReadableIds, invalidFilterError } from './filter-comparand-shape.js'; import { assertResolvedTemporalTokensInRange, assertTemporalComparandsInterpretable } from './temporal-comparand-door.js'; @@ -4372,6 +4372,16 @@ export interface CascadeDeleteSet { * one has removed from it; the remainder written leaves those out too. */ readonly cleared: Map>; + /** + * [#30237, ADR-0148 D2] Every `set_null` reference the walk cleared, one per + * live row, field and removed id: the referencing row, the field, and the id + * the field held (a row this delete removes). Present only on the set of a + * delete that enters the recycle bin, which assigns it when the set is + * collected; the walk appends to it, nested deletes of the same cascade + * included, and the entry keeps it so a restore can re-link what is still + * empty. + */ + clearedReferences?: RecycleBinClearedReference[]; } /** [#26718] One member of a {@link CascadeDeleteSet}, as a single key. */ diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 83218827c0d..9ce18cdff09 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -81,6 +81,10 @@ import { type SecretHandleSlot, type StoredRowsReader, } from './engine-secret-internal-fields.js'; +// [#30237, ADR-0148] The recycle bin's write side: the capture body, and the +// retention seam the host hands over (`RecycleBinCaptureHost`). +import { openRecycleBinCapture, type RecycleBinCapture } from './recycle-bin/capture.js'; +import type { RecycleBinRetentionSettings } from './recycle-bin/retention.js'; // [#25522 cut 6] The predicate-write hook dispatch bodies; the methods of the // same names below hand each body the members it reads (`HookDispatchHost`). import { @@ -9019,9 +9023,54 @@ export class ObjectQL implements IObjectQLEngine { ids: ReadonlyArray, context?: ExecutionContext, depth = 0, + onCollected?: (set: CascadeDeleteSet) => Promise, ): Promise { // [#29986 codemod] The body moved to `engine-cascade-planning.ts`, its docblock with it (see `CascadePlanningHost`). - return CascadePlanningFacet.cascadeDeleteRelations.call(cascadePlanningHost.of(this), object, ids, context, depth); + return CascadePlanningFacet.cascadeDeleteRelations.call(cascadePlanningHost.of(this), object, ids, context, depth, onCollected); + } + + /** + * [#30237, ADR-0148 D3] What the host handed over for the recycle bin's + * retention: its resolver and the token to call it with. `undefined` until + * {@link setRecycleBinRetention}: `OS_RECYCLE_BIN_RETENTION_DAYS`, else 30. + */ + private recycleBinRetention: RecycleBinRetentionSettings | undefined; + + /** [#30237] The recycle bin's once-per-engine notices already given. */ + private readonly recycleBinNotices = new Set(); + + /** + * [#30237, ADR-0148 D3] Install the host's retention resolver and the opaque + * token it is called with (the one its `resolveFeatures` receives). The + * engine asks once per delete operation that enters the bin and stamps the + * answer on the entry; `ObjectQLPlugin` calls this from its + * `recycleBinRetention` option. + */ + setRecycleBinRetention(settings: RecycleBinRetentionSettings | undefined): void { + this.recycleBinRetention = settings; + } + + private openRecycleBinCapture( + object: string, + id: string | number, + context: ExecutionContext | undefined, + ): RecycleBinCapture | undefined { + // [#30237] The body lives in `recycle-bin/capture.ts`, its docblock with it (see `RecycleBinCaptureHost`). + const readRegistry = () => this._registry; + const readLogger = () => this.logger; + const readCascadeDeleteSets = () => this.cascadeDeleteSets; + const readRetention = () => this.recycleBinRetention; + const readNotices = () => this.recycleBinNotices; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + get logger() { return readLogger(); }, + get cascadeDeleteSets() { return readCascadeDeleteSets(); }, + get recycleBinRetention() { return readRetention(); }, + get recycleBinNotices() { return readNotices(); }, + getDriver: (...a) => this.getDriver(...a), + buildDriverOptions: (...a) => this.buildDriverOptions(...a), + }; + return openRecycleBinCapture.call(host, object, id, context); } /** @@ -9689,14 +9738,29 @@ export class ObjectQL implements IObjectQLEngine { // A dependent's stale-roll-up refusal stops the walk before this // row is removed, so it is not this delete's answer: // `cascadeStoppedRefusal` says what it is instead. + // + // [#30237, ADR-0148 D1] The recycle bin rides the same unit: the + // capture opens before the cascade (a retention defect refuses + // here, before any write), reads the collected set before the + // walk touches it, and writes the one entry after the walk and + // before this row goes. A removal that throws takes the entry + // with it: the transaction's rollback when one covers the bin, + // the capture's compensation otherwise. const runByIdDelete = async (writeContext?: ExecutionContext) => { - await this.cascadeDeleteRelations(object, [hookContext.input.id as string | number], writeContext) + const bin = this.openRecycleBinCapture(object, hookContext.input.id as string | number, writeContext); + await this.cascadeDeleteRelations(object, [hookContext.input.id as string | number], writeContext, 0, bin?.onCollected) .catch((e: unknown) => { throw cascadeStoppedRefusal(e, object); }); - return await driver.delete( - object, - hookContext.input.id as string, - this.buildDriverOptions(object, writeContext, hookContext.input.options as any), - ); + const entry = await bin?.write(); + try { + return await driver.delete( + object, + hookContext.input.id as string, + this.buildDriverOptions(object, writeContext, hookContext.input.options as any), + ); + } catch (e) { + await entry?.compensate(); + throw e; + } }; // WHEN to open one is `planCascadeAtomicity`'s call — see there. // `'none'` (nothing references this object) keeps the pre-#7413 diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index b3075db55a3..72bd7ab933a 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -309,6 +309,11 @@ const READERS: Record = { disposition: 'row-value', why: 'reads the tenant column off the written row; a federated row has none, so the event omits it', }, + // [#30237] A recycle-bin entry belongs to the organization of the record it holds. + 'recycle-bin/capture.ts#organizationOf :: resolveTenantFieldName()': { + disposition: 'row-value', + why: "reads the tenant column off the snapshotted root row; a federated row has none, so the entry takes the deleting context's organization", + }, 'relation-filter-lowering.ts#admitRelationCondition :: referenceTargetOf()': { disposition: 'caller-predicate', why: "lowers a relation condition the caller wrote; it names an injected column only when the caller did", diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 526c646892f..167547c124c 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -628,6 +628,8 @@ export type { LifecycleFloorViolation, } from './lifecycle/lifecycle-service.js'; export { parseLifecycleDuration } from './lifecycle/duration.js'; +// [#30237, ADR-0148 D3] What a host hands `ObjectQLPlugin`'s `recycleBinRetention`. +export type { RecycleBinRetentionSettings } from './recycle-bin/retention.js'; export { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js'; // [#4551] Read-only referential-integrity audit — the reporting half of the diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index dbd70a1f289..6c84b72b332 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -36,6 +36,7 @@ import { isFederatedObject } from './federated-object.js'; import { wrapDeclarativeHook } from './hook-wrappers.js'; import { announceStoredExpressionHoles } from './stored-expression-backfill.js'; import { LifecycleService } from './lifecycle/lifecycle-service.js'; +import { readRecycleBinRetentionEnv, type RecycleBinRetentionSettings } from './recycle-bin/retention.js'; import { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js'; import type { DanglingReferenceAuditOptions } from './integrity/dangling-reference-audit.js'; import { @@ -395,6 +396,19 @@ export interface ObjectQLPluginOptions { initialDelayMs?: number; referenceAudit?: Omit & { enabled?: boolean }; }; + /** + * [#30237, ADR-0148 D3] The recycle bin's retention seam: the host's + * retention resolver (`RecycleBinRetentionResolver`, `@objectstack/spec/data`) + * and the opaque token to call it with, the same one the host's + * `resolveFeatures` receives. One kernel serves one environment, so the + * token is fixed at boot; the engine never reads it. + * + * Absent, or a resolver answering `undefined`: `OS_RECYCLE_BIN_RETENTION_DAYS`, + * else 30 days. That variable is read once in `init()` as well, so a value + * that is not a positive whole number of days stops the boot with the + * refusal rather than the first delete. + */ + recycleBinRetention?: RecycleBinRetentionSettings; } /** @@ -537,6 +551,8 @@ export class ObjectQLPlugin implements Plugin { /** ADR-0057 lifecycle enforcement (Reaper/Rotator/Archiver). */ private lifecycleService: LifecycleService | undefined; private lifecycleOptions: ObjectQLPluginOptions['lifecycle']; + /** [#30237] The host's recycle-bin retention seam, handed to the engine in `init()`. */ + private recycleBinRetention: ObjectQLPluginOptions['recycleBinRetention']; constructor(qlOrOptions?: ObjectQL | ObjectQLPluginOptions, hostContext?: Record) { // Back-compat: legacy callers passed `(ObjectQL, hostContext)` positionally. @@ -564,6 +580,7 @@ export class ObjectQLPlugin implements Plugin { this.hydrateMetadataFromDb = opts.hydrateMetadataFromDb === true; this.registerProtocol = opts.registerProtocol !== false; this.lifecycleOptions = opts.lifecycle; + this.recycleBinRetention = opts.recycleBinRetention; } /** @@ -796,6 +813,14 @@ export class ObjectQLPlugin implements Plugin { // plugins read it — see SKIP_SCHEMA_SYNC_ENGINE_KEY. (this.ql as unknown as Record)[SKIP_SCHEMA_SYNC_ENGINE_KEY] = this.skipSchemaSync; + // [#30237, ADR-0148 D3] The recycle bin's retention: the variable is read + // now, so a defect in it stops the boot with the refusal instead of + // refusing the first delete, and the host's resolver is handed over. The + // resolver itself is asked per delete: its answer may change with the + // host's plan, and each entry keeps the one it was given. + readRecycleBinRetentionEnv(); + if (this.recycleBinRetention) this.ql.setRecycleBinRetention(this.recycleBinRetention); + // [ADR-0139 D2] The engine composes a `ReadableIds` leaf from the // registered `security` service. Handed over as a SOURCE in the engine's // slot (`SECURITY_SERVICE_SOURCE_SLOT`, not public surface), read at use and diff --git a/packages/objectql/src/recycle-bin/capture.test.ts b/packages/objectql/src/recycle-bin/capture.test.ts new file mode 100644 index 00000000000..e4656488d1d --- /dev/null +++ b/packages/objectql/src/recycle-bin/capture.test.ts @@ -0,0 +1,432 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30237, ADR-0148 D1/D3/D4] The recycle bin's write side, through a real + * `ObjectQL`: a by-id delete of a business record writes ONE entry holding the + * record and its master-detail cascade (master first), the `set_null` + * references it cleared, and the purge deadline stamped from the retention — + * inside the delete's own unit of work. Predicate deletes, cascade children + * and `sys_*` objects write none, and the TTL sweep reaps an entry once its + * stamped deadline has passed. + * + * The driver keeps real rows and real rollback (a deep snapshot per + * transaction, the shape `driver-memory` uses), because "the entry rolls back + * with the delete" cannot be told apart from "it does not" by a stub. + */ + +import { describe, it, expect } from 'vitest'; +import { + RECYCLE_BIN_ENTRY_FIELDS, + RecycleBinEntrySchema, + recycleBinRetentionRefusal, +} from '@objectstack/spec/data'; +import { ObjectQL } from '../engine.js'; +import { LifecycleService } from '../lifecycle/lifecycle-service.js'; +import { cascadeMembersInOrder } from './capture.js'; +import { cascadeMemberKey, type CascadeDeleteSet } from '../engine-top-level.js'; + +type Row = Record; + +function recordingLogger() { + const records: Array<{ level: string; message: string }> = []; + const push = (level: string) => (message: unknown) => void records.push({ level, message: String(message) }); + return { + records, + logger: { debug: push('debug'), info: push('info'), warn: push('warn'), error: push('error') }, + at: (level: string) => records.filter((r) => r.level === level), + }; +} + +function makeDriver(opts: { transactional?: boolean; failDeleteOf?: string } = {}) { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const writes: Array<{ object: string; op: string; transaction: unknown }> = []; + const snapshots = new Map>>(); + let begun = 0; + let nextId = 0; + const matches = (row: Row, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { + if (!(v as any).$in.some((x: unknown) => String(x) === String(row[k]))) return false; + continue; + } + if (v && typeof v === 'object' && '$contains' in (v as any)) { + const cell = row[k]; + const needle = String((v as any).$contains); + if (!(Array.isArray(cell) ? cell.map(String).includes(needle) : String(cell ?? '').includes(needle))) return false; + continue; + } + if (v && typeof v === 'object' && '$lt' in (v as any)) { + if (!(row[k] != null && String(row[k]) < String((v as any).$lt))) return false; + continue; + } + const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (exp ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'primary', + version: '0.0.0', + supports: {}, + writes, + rowsOf: (o: string) => Array.from(storeFor(o).values()), + seed(o: string, row: Row) { storeFor(o).set(String(row.id), { ...row }); }, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async syncSchema() {}, + async find(o: string, ast: any) { + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); + }, + async findOne(o: string, ast: any) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(o: string, data: Row, options: any) { + writes.push({ object: o, op: 'create', transaction: options?.transaction }); + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = JSON.parse(JSON.stringify({ ...data, id })); + storeFor(o).set(id, row); + return { ...row }; + }, + async update(o: string, id: string, data: Row, options: any) { + writes.push({ object: o, op: 'update', transaction: options?.transaction }); + const s = storeFor(o); + const cur = s.get(String(id)); + if (!cur) throw new Error(`not found ${o}/${id}`); + const up = { ...cur, ...data, id }; + s.set(String(id), up); + return { ...up }; + }, + async upsert(o: string, data: Row) { + const id = data.id as string | undefined; + return id && storeFor(o).has(id) ? this.update(o, id, data, undefined) : this.create(o, data, undefined); + }, + async delete(o: string, id: string, options: any) { + if (opts.failDeleteOf === o) throw new Error(`driver refused the delete of ${o}/${id}`); + writes.push({ object: o, op: 'delete', transaction: options?.transaction }); + return storeFor(o).delete(String(id)); + }, + async deleteMany(o: string, ast: any, options: any) { + const doomed = await this.find(o, ast); + for (const r of doomed) { + writes.push({ object: o, op: 'delete', transaction: options?.transaction }); + storeFor(o).delete(String((r as any).id)); + } + return doomed.length; + }, + async count(o: string, ast: any) { return (await this.find(o, ast)).length; }, + async bulkCreate(o: string, rows: Row[]) { return Promise.all(rows.map((r) => this.create(o, r, undefined))); }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + }; + if (opts.transactional !== false) { + driver.beginTransaction = async () => { + begun += 1; + const handle = { __trx: begun }; + const snap = new Map>(); + for (const [o, s] of stores) snap.set(o, new Map(Array.from(s, ([k, v]) => [k, { ...v }]))); + snapshots.set(handle, snap); + return handle; + }; + driver.commit = async (handle: unknown) => { snapshots.delete(handle); }; + driver.rollback = async (handle: unknown) => { + const snap = snapshots.get(handle); + if (snap) { stores.clear(); for (const [o, s] of snap) stores.set(o, s); } + snapshots.delete(handle); + }; + } + return driver; +} + +const text = (name: string) => ({ name, type: 'text' as const }); + +/** The bin, declared as `@objectstack/platform-objects` declares it. */ +const bin = { + name: 'sys_recycle_bin', + label: 'Recycle Bin Entry', + managedBy: 'engine-owned', + fields: { + ...RECYCLE_BIN_ENTRY_FIELDS, + organization_id: { name: 'organization_id', type: 'lookup' as const, reference: 'sys_organization', deleteBehavior: 'cascade' }, + }, + lifecycle: { class: 'transient', ttl: { field: 'purge_after', expireAfter: '0h' } }, +}; +/** account ← contact (master_detail) ← note (master_detail); task → account (set_null). */ +const account = { + name: 'account', + label: 'Account', + fields: { + id: text('id'), + name: text('name'), + organization_id: text('organization_id'), + api_token: { name: 'api_token', type: 'secret' as const }, + }, +}; +const contact = { + name: 'contact', + label: 'Contact', + fields: { + id: text('id'), + name: text('name'), + account: { name: 'account', type: 'master_detail' as const, reference: 'account' }, + }, +}; +const note = { + name: 'note', + label: 'Note', + fields: { + id: text('id'), + body: text('body'), + contact: { name: 'contact', type: 'master_detail' as const, reference: 'contact' }, + }, +}; +const task = { + name: 'task', + label: 'Task', + fields: { + id: text('id'), + subject: text('subject'), + account: { name: 'account', type: 'lookup' as const, reference: 'account' }, + watchers: { name: 'watchers', type: 'lookup' as const, reference: 'account', multiple: true }, + }, +}; +/** A system object: never enters the bin. */ +const sysThing = { name: 'sys_thing', label: 'Thing', fields: { id: text('id'), name: text('name') } }; +/** Nothing references it: the plain, non-cascading delete. */ +const loner = { name: 'loner', label: 'Loner', fields: { id: text('id'), name: text('name') } }; + +async function makeEngine(options: { withBin?: boolean; transactional?: boolean; failDeleteOf?: string } = {}) { + const rec = recordingLogger(); + const engine = new ObjectQL({ logger: rec.logger } as any); + const driver = makeDriver(options); + engine.registerDriver(driver, true); + await engine.init(); + const objects: any[] = [account, contact, note, task, sysThing, loner]; + if (options.withBin !== false) objects.push(bin); + for (const o of objects) engine.registry.registerObject(o as any, '__test__'); + return { engine, driver, rec }; +} + +function seedAccountTree(driver: any) { + driver.seed('account', { id: 'acc1', name: 'Acme', organization_id: 'org_1', api_token: 'plain-not-a-ref' }); + driver.seed('contact', { id: 'c1', name: 'Ada', account: 'acc1' }); + driver.seed('contact', { id: 'c2', name: 'Bob', account: 'acc1' }); + driver.seed('note', { id: 'n1', body: 'first', contact: 'c1' }); + driver.seed('task', { id: 't1', subject: 'call', account: 'acc1', watchers: ['acc1', 'acc2'] }); +} + +const USER = { context: { userId: 'u_alice' } } as any; +const entriesOf = (driver: any) => driver.rowsOf('sys_recycle_bin') as Row[]; + +describe('a by-id delete writes ONE recycle-bin entry (ADR-0148 D1)', () => { + it('holds the record and its master-detail cascade, master first, and the cleared references', async () => { + const { engine, driver } = await makeEngine(); + seedAccountTree(driver); + + await engine.delete('account', { where: { id: 'acc1' }, ...USER } as any); + + // The delete is physical, exactly as before. + expect(driver.rowsOf('account')).toHaveLength(0); + expect(driver.rowsOf('contact')).toHaveLength(0); + expect(driver.rowsOf('note')).toHaveLength(0); + + const entries = entriesOf(driver); + expect(entries).toHaveLength(1); + const entry = entries[0]; + expect(RecycleBinEntrySchema.safeParse(entry).success).toBe(true); + expect(entry).toMatchObject({ + object_name: 'account', + record_id: 'acc1', + record_count: 4, + deleted_by: 'u_alice', + retention_days: 30, + organization_id: 'org_1', + }); + const snapshot = entry.snapshot as { rows: Array<{ object: string; id: string; values: Row }>; clearedReferences: Row[] }; + // Master first, then its details breadth-first: a restore writes them back in this order. + expect(snapshot.rows.map((r) => `${r.object}/${r.id}`)).toEqual([ + 'account/acc1', 'contact/c1', 'contact/c2', 'note/n1', + ]); + expect(snapshot.rows[1].values).toMatchObject({ id: 'c1', name: 'Ada', account: 'acc1' }); + // Both arities of `set_null`: the single lookup and one member of the set. + expect(snapshot.clearedReferences).toEqual(expect.arrayContaining([ + { object: 'task', id: 't1', field: 'account', value: 'acc1' }, + { object: 'task', id: 't1', field: 'watchers', value: 'acc1' }, + ])); + expect(snapshot.clearedReferences).toHaveLength(2); + expect(driver.rowsOf('task')[0]).toMatchObject({ account: null, watchers: ['acc2'] }); + }); + + it('stamps purge_after as deleted_at plus the retention', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('loner', { id: 'l1', name: 'x' }); + await engine.delete('loner', { where: { id: 'l1' } } as any); + const [entry] = entriesOf(driver); + const span = Date.parse(entry.purge_after as string) - Date.parse(entry.deleted_at as string); + expect(span).toBe(30 * 86_400_000); + // A delete with no user is the system's: the entry names no one. + expect(entry.deleted_by).toBeUndefined(); + }); + + it('leaves the secret column out of the snapshot (D4)', async () => { + const { engine, driver } = await makeEngine(); + seedAccountTree(driver); + await engine.delete('account', { where: { id: 'acc1' }, ...USER } as any); + const root = (entriesOf(driver)[0].snapshot as any).rows[0]; + expect(root.values).toMatchObject({ id: 'acc1', name: 'Acme', organization_id: 'org_1' }); + expect(root.values).not.toHaveProperty('api_token'); + }); + + it('keeps no column the object does not declare', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('loner', { id: 'l1', name: 'x', leftover_column: 'undeclared' }); + await engine.delete('loner', { where: { id: 'l1' } } as any); + const root = (entriesOf(driver)[0].snapshot as any).rows[0]; + expect(root.values).not.toHaveProperty('leftover_column'); + }); + + it('writes the entry inside the transaction that wraps the cascade', async () => { + const { engine, driver } = await makeEngine(); + seedAccountTree(driver); + await engine.delete('account', { where: { id: 'acc1' }, ...USER } as any); + const binWrite = driver.writes.find((w: any) => w.object === 'sys_recycle_bin'); + const rootDelete = driver.writes.find((w: any) => w.object === 'account' && w.op === 'delete'); + expect(binWrite?.transaction).toBeDefined(); + expect(binWrite?.transaction).toBe(rootDelete?.transaction); + }); +}); + +describe('deletes that do not enter the bin (v1 reach, seat ruling Q6)', () => { + it('a cascade child is a row of its root entry, never an entry of its own', async () => { + const { engine, driver } = await makeEngine(); + seedAccountTree(driver); + await engine.delete('account', { where: { id: 'acc1' } } as any); + expect(entriesOf(driver).map((e) => e.object_name)).toEqual(['account']); + }); + + it('a delete by predicate writes no entry', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('loner', { id: 'l1', name: 'x' }); + driver.seed('loner', { id: 'l2', name: 'x' }); + await engine.delete('loner', { where: { name: 'x' }, multi: true } as any); + expect(driver.rowsOf('loner')).toHaveLength(0); + expect(entriesOf(driver)).toHaveLength(0); + }); + + it('a sys_* object writes no entry', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('sys_thing', { id: 's1', name: 'x' }); + await engine.delete('sys_thing', { where: { id: 's1' } } as any); + expect(driver.rowsOf('sys_thing')).toHaveLength(0); + expect(entriesOf(driver)).toHaveLength(0); + }); + + it('a composition with no bin deletes as before and says so once per engine', async () => { + const { engine, driver, rec } = await makeEngine({ withBin: false }); + driver.seed('loner', { id: 'l1', name: 'x' }); + driver.seed('loner', { id: 'l2', name: 'y' }); + seedAccountTree(driver); + await engine.delete('loner', { where: { id: 'l1' } } as any); + await engine.delete('loner', { where: { id: 'l2' } } as any); + await engine.delete('account', { where: { id: 'acc1' } } as any); + expect(driver.rowsOf('loner')).toHaveLength(0); + expect(driver.rowsOf('contact')).toHaveLength(0); + const said = rec.at('info').filter((r) => r.message.includes('sys_recycle_bin is not registered')); + expect(said).toHaveLength(1); + }); +}); + +describe('the retention seam (ADR-0148 D3)', () => { + it("stamps the host resolver's answer, asked with the token the host handed over", async () => { + const { engine, driver } = await makeEngine(); + const seen: Array = []; + engine.setRecycleBinRetention({ resolve: (token) => { seen.push(token); return 7; }, token: 'tok' }); + driver.seed('loner', { id: 'l1', name: 'x' }); + await engine.delete('loner', { where: { id: 'l1' } } as any); + expect(entriesOf(driver)[0].retention_days).toBe(7); + expect(seen).toEqual(['tok']); + }); + + it('refuses the delete before any write when the resolver answers a defect', async () => { + const { engine, driver } = await makeEngine(); + engine.setRecycleBinRetention({ resolve: () => 0 }); + seedAccountTree(driver); + await expect(engine.delete('account', { where: { id: 'acc1' } } as any)) + .rejects.toThrow(recycleBinRetentionRefusal('resolver', 0)); + expect(driver.rowsOf('account')).toHaveLength(1); + expect(driver.rowsOf('contact')).toHaveLength(2); + expect(entriesOf(driver)).toHaveLength(0); + }); +}); + +describe('a failed delete leaves no entry', () => { + it('rolls the entry back with the transaction', async () => { + const { engine, driver } = await makeEngine({ failDeleteOf: 'account' }); + seedAccountTree(driver); + await expect(engine.delete('account', { where: { id: 'acc1' } } as any)).rejects.toThrow(/driver refused/); + expect(entriesOf(driver)).toHaveLength(0); + expect(driver.rowsOf('contact')).toHaveLength(2); + }); + + it('removes the entry itself when no transaction covered it', async () => { + const { engine, driver } = await makeEngine({ failDeleteOf: 'loner', transactional: false }); + driver.seed('loner', { id: 'l1', name: 'x' }); + await expect(engine.delete('loner', { where: { id: 'l1' } } as any)).rejects.toThrow(/driver refused/); + expect(driver.rowsOf('loner')).toHaveLength(1); + expect(entriesOf(driver)).toHaveLength(0); + // The entry was written, then compensated: not merely never written. + expect(driver.writes.filter((w: any) => w.object === 'sys_recycle_bin').map((w: any) => w.op)) + .toEqual(['create', 'delete']); + }); +}); + +describe('the TTL sweep reaps an entry once its stamped deadline has passed (D3)', () => { + it('reaps due entries and keeps the rest, asking no resolver', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('sys_recycle_bin', { id: 'due', purge_after: '2026-11-01T00:00:00.000Z' }); + driver.seed('sys_recycle_bin', { id: 'kept', purge_after: '2026-12-01T00:00:00.000Z' }); + let asked = 0; + engine.setRecycleBinRetention({ resolve: () => { asked += 1; return 1; } }); + const lifecycle = new LifecycleService({ + getEngine: () => engine as any, + logger: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} } as any, + now: () => Date.parse('2026-11-06T00:00:00.000Z'), + initialDelayMs: 1, + sweepIntervalMs: 10, + }); + const report = await lifecycle.sweep(); + expect(report.errors).toEqual([]); + expect(entriesOf(driver).map((e) => e.id)).toEqual(['kept']); + expect(asked).toBe(0); + }); +}); + +describe('cascadeMembersInOrder', () => { + it('lists the root first, then breadth-first along the cascading edges, then any member no edge reached', () => { + const set: CascadeDeleteSet = { + members: new Map([ + ['a', new Set(['1'])], + ['b', new Set(['2', '3'])], + ['c', new Set(['4'])], + ['d', new Set(['5'])], + ]), + entered: new Map(), + elevationsFiled: new Set(), + cascadeEdges: new Map([ + [cascadeMemberKey('a', '1'), [{ object: 'b', id: '2' }, { object: 'b', id: '3' }]], + [cascadeMemberKey('b', '2'), [{ object: 'c', id: '4' }]], + [cascadeMemberKey('b', '3'), [{ object: 'c', id: '4' }]], + ]), + cleared: new Map(), + }; + expect(cascadeMembersInOrder(set, { object: 'a', id: '1' }).map((m) => `${m.object}/${m.id}`)) + .toEqual(['a/1', 'b/2', 'b/3', 'c/4', 'd/5']); + }); +}); diff --git a/packages/objectql/src/recycle-bin/capture.ts b/packages/objectql/src/recycle-bin/capture.ts new file mode 100644 index 00000000000..5c99ef9fcd1 --- /dev/null +++ b/packages/objectql/src/recycle-bin/capture.ts @@ -0,0 +1,323 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30237, ADR-0148 D1] The recycle bin's write side: one entry per delete + * operation, snapshotted before the physical delete. + * + * ## Where it runs + * + * `ObjectQL.delete`'s by-id branch, inside `runByIdDelete`, so it shares that + * branch's unit of work: + * + * 1. {@link openRecycleBinCapture}, before the cascade: is this delete one + * the bin takes (see "Which deletes enter the bin"), is the bin there, + * and what is the retention. A retention defect refuses the delete here, + * before anything is written. + * 2. {@link RecycleBinCapture.onCollected}, handed to + * `cascadeDeleteRelations`: once the cascade set is collected and its + * restricts judged, and before the walk deletes or clears anything, the + * set's rows are read as they stand. The walk then records each `set_null` + * reference it clears on the set ({@link CascadeDeleteSet.clearedReferences}). + * 3. {@link RecycleBinCapture.write}, after the walk and before the root row + * is removed: the entry is written raw through the bin's driver, with the + * driver options of this unit of work, so a transaction that covers the + * bin's driver covers the entry too. + * 4. {@link RecycleBinWrittenEntry.compensate}, when the root row's removal + * throws and no transaction covered the entry: the entry is removed, so a + * failed delete leaves no entry for a row that is still live. + * + * ## Which deletes enter the bin (v1, seat ruling on #30237 Q6) + * + * A by-id delete of a business object (a name outside `sys_`) that is the + * ROOT of its cascade. Not: a delete by predicate (`multi: true`, a lifecycle + * reap), a child the cascade deletes (it is a row of its root's entry), and a + * `sys_*` object. A `sys_*` row the cascade reaches is left out of the + * snapshot too. + * + * ## Why raw, and not `engine.insert` + * + * The entry is engine-owned bookkeeping about a delete, not a record anyone + * created: `engine.insert` would run the bin through insert hooks, the audit + * and activity mirrors and the security middleware (which refuses a + * user-context write to an engine-owned object). `sys_secret` is written the + * same way, for the same reason. + */ + +import { + RECYCLE_BIN_OBJECT, + RecycleBinEntrySchema, + type RecycleBinClearedReference, + type RecycleBinEntry, + type RecycleBinSnapshotRow, +} from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { Logger } from '@objectstack/core'; +import { redactBoundStatement } from '@objectstack/types'; +import { cascadeMemberKey, cascadeSetHas, type CascadeDeleteSet } from '../engine-top-level.js'; +import { declaredColumnSet } from '../declared-read-columns.js'; +import { deletedSecretColumns } from '../engine-secret-internal-fields.js'; +import { resolveTenantFieldName } from '../tenancy/system-write-organization.js'; +import { recycleBinPurgeAfter, resolveRecycleBinRetentionDays, type RecycleBinRetentionSettings } from './retention.js'; + +/** + * Does a delete of `object` enter the bin at all? The v1 reach: every object + * outside the `sys_` namespace. The by-id and root-of-cascade conditions are + * the caller's ({@link openRecycleBinCapture}). + */ +export function recycleBinTakesObject(object: string): boolean { + return !object.startsWith('sys_'); +} + +/** The notice that the composition registers no bin, given once per engine. */ +const MISSING_BIN_NOTICE = 'missing-bin'; + +/** How many ids one snapshot read names: the cascade walk's own `$in` batch. */ +const SNAPSHOT_READ_BATCH = 500; + +/** The engine members the capture reads. */ +export interface RecycleBinCaptureHost { + readonly _registry: { getObject(name: string): unknown }; + readonly logger: Pick; + readonly cascadeDeleteSets: { getStore(): CascadeDeleteSet | undefined }; + readonly recycleBinRetention: RecycleBinRetentionSettings | undefined; + /** The once-per-engine notices already given ({@link MISSING_BIN_NOTICE}). */ + readonly recycleBinNotices: Set; + getDriver(object: string): any; + buildDriverOptions(object: string, execCtx?: ExecutionContext): any; +} + +/** One cascade member, in the order the snapshot lists it. */ +interface Member { + readonly object: string; + readonly id: string; +} + +/** + * The set's members, the root first and then breadth-first along the + * cascading edges the collection walk recorded: a master always before its + * details, which is the order a restore writes them back in. A member no edge + * reaches (none should be) follows, so nothing collected is dropped. + */ +export function cascadeMembersInOrder(set: CascadeDeleteSet, root: Member): Member[] { + const out: Member[] = []; + const seen = new Set(); + const queue: Member[] = [root]; + while (queue.length > 0) { + const next = queue.shift()!; + const key = cascadeMemberKey(next.object, next.id); + if (seen.has(key)) continue; + seen.add(key); + out.push(next); + for (const edge of set.cascadeEdges.get(key) ?? []) { + queue.push({ object: edge.object, id: String(edge.id) }); + } + } + for (const [object, ids] of set.members) { + for (const id of ids) { + const key = cascadeMemberKey(object, id); + if (seen.has(key)) continue; + seen.add(key); + out.push({ object, id }); + } + } + return out; +} + +/** + * A row's values as the snapshot keeps them: the declared columns, the + * `secret` columns left out (ADR-0148 D4: the delete releases their handles, + * so a restored record's secret fields come back empty). + */ +function snapshotValues(schema: unknown, row: Record): Record { + const declared = declaredColumnSet(schema as { fields?: unknown }); + const secrets = new Set(deletedSecretColumns(schema as Parameters[0])); + const values: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (declared && !declared.has(key)) continue; + if (secrets.has(key)) continue; + values[key] = value; + } + return values; +} + +/** The entry {@link RecycleBinCapture.write} wrote, for the caller to undo. */ +export interface RecycleBinWrittenEntry { + /** The entry's id as the driver answered it; `undefined` when it answered none. */ + readonly id: string | undefined; + /** + * Remove the entry when the root row's removal failed. A no-op when a + * transaction covered the entry, since its rollback removes the entry with + * everything else. A failure to remove it is logged at `error` and never + * replaces the delete's own error. + */ + compensate(): Promise; +} + +/** + * One delete operation's capture: opened before the cascade, filled when the + * cascade set is collected, written after the walk. + */ +export class RecycleBinCapture { + private rows: RecycleBinSnapshotRow[] | undefined; + private set: CascadeDeleteSet | undefined; + + constructor( + private readonly host: RecycleBinCaptureHost, + readonly object: string, + readonly id: string, + private readonly context: ExecutionContext | undefined, + readonly retentionDays: number, + ) {} + + /** + * Handed to `cascadeDeleteRelations`: called once, with the delete's own + * freshly collected set, after its restricts are judged and before the walk + * writes. Reads every member's row raw, inside this unit of work. + */ + readonly onCollected = async (set: CascadeDeleteSet): Promise => { + this.set = set; + set.clearedReferences = []; + const ordered = cascadeMembersInOrder(set, { object: this.object, id: this.id }) + .filter((m) => recycleBinTakesObject(m.object)); + const byObject = new Map(); + for (const m of ordered) { + const ids = byObject.get(m.object); + if (ids) ids.push(m.id); else byObject.set(m.object, [m.id]); + } + const read = new Map>(); + for (const [object, ids] of byObject) { + const schema = this.host._registry.getObject(object); + const driver = this.host.getDriver(object); + const options = this.host.buildDriverOptions(object, this.context); + for (let i = 0; i < ids.length; i += SNAPSHOT_READ_BATCH) { + const batch = ids.slice(i, i + SNAPSHOT_READ_BATCH); + const where = batch.length === 1 ? { id: batch[0] } : { id: { $in: batch } }; + const rows = ((await driver.find(object, { object, where }, options)) ?? []) as Record[]; + for (const row of rows) { + if (row?.id == null) continue; + read.set(cascadeMemberKey(object, row.id), snapshotValues(schema, row)); + } + } + } + this.rows = []; + for (const m of ordered) { + const values = read.get(cascadeMemberKey(m.object, m.id)); + if (values) this.rows.push({ object: m.object, id: m.id, values }); + } + }; + + /** + * Write the entry, after the walk and before the root row is removed. + * `undefined` when there is nothing to keep: the root row was not there to + * read (the delete removes nothing), or the set was never handed over. + */ + async write(): Promise { + const rows = this.rows; + if (!rows || rows.length === 0 || rows[0].object !== this.object || rows[0].id !== this.id) { + if (!this.set) { + // The cascade never handed its set over: an engine defect, not a + // configuration. The delete still runs; the lost snapshot is loud. + this.host.logger.error('[recycle-bin] the cascade set was never collected; this delete has no recycle-bin entry', undefined, { + object: this.object, id: this.id, + }); + } + return undefined; + } + const deletedAt = new Date(); + const deletedBy = this.context?.userId; + const organizationId = this.organizationOf(rows[0].values); + const clearedReferences: RecycleBinClearedReference[] = [...(this.set?.clearedReferences ?? [])]; + const entry: RecycleBinEntry = RecycleBinEntrySchema.parse({ + object_name: this.object, + record_id: this.id, + record_count: rows.length, + snapshot: { rows, clearedReferences }, + ...(typeof deletedBy === 'string' && deletedBy !== '' ? { deleted_by: deletedBy } : {}), + deleted_at: deletedAt.toISOString(), + retention_days: this.retentionDays, + purge_after: recycleBinPurgeAfter(deletedAt, this.retentionDays), + }); + const row: Record = { ...entry }; + if (organizationId !== undefined) row.organization_id = organizationId; + const driver = this.host.getDriver(RECYCLE_BIN_OBJECT); + const options = this.host.buildDriverOptions(RECYCLE_BIN_OBJECT, this.context); + const written = (await driver.create(RECYCLE_BIN_OBJECT, row, options)) as { id?: unknown } | null; + const id = written?.id == null ? undefined : String(written.id); + const covered = options?.transaction != null; + const host = this.host; + const object = this.object; + const recordId = this.id; + return { + id, + async compensate(): Promise { + if (covered) return; + if (id === undefined) { + host.logger.error('[recycle-bin] the delete failed and its entry cannot be removed: the driver returned no entry id', undefined, { + object, id: recordId, + }); + return; + } + try { + await driver.delete(RECYCLE_BIN_OBJECT, id, options); + } catch (error) { + // The driver's fault, bound values stripped: it may quote the row. + const redacted = redactBoundStatement(error); + host.logger.error( + '[recycle-bin] the delete failed and removing its entry failed too; the entry names a record that is still live', + redacted instanceof Error ? redacted : undefined, + { object, id: recordId, entry: id }, + ); + } + }, + }; + } + + /** + * The organization that owns the entry: the root row's own, read off its + * tenant column, else the deleting context's. An entry belongs to the + * organization whose record it holds, so the organization's delete removes + * it (the bin's tenant column cascades) and its listing is that + * organization's. + */ + private organizationOf(values: Record): string | undefined { + const tenantField = resolveTenantFieldName(this.host._registry.getObject(this.object)); + const own = tenantField ? values[tenantField] : undefined; + if (typeof own === 'string' && own !== '') return own; + const fromContext = (this.context as { tenantId?: unknown } | undefined)?.tenantId; + return typeof fromContext === 'string' && fromContext !== '' ? fromContext : undefined; + } +} + +/** + * Open the capture for one by-id delete, before its cascade: `undefined` when + * the delete does not enter the bin. Throws the retention refusal when the + * host's resolver or `OS_RECYCLE_BIN_RETENTION_DAYS` holds a defect, before + * the delete has written anything. + */ +export function openRecycleBinCapture( + this: RecycleBinCaptureHost, + object: string, + id: string | number, + context: ExecutionContext | undefined, +): RecycleBinCapture | undefined { + if (!recycleBinTakesObject(object)) return undefined; + // A child the cascade deletes is a row of its root's entry, never an entry. + const inherited = this.cascadeDeleteSets.getStore(); + if (inherited && cascadeSetHas(inherited.members, object, id)) return undefined; + if (!this._registry.getObject(RECYCLE_BIN_OBJECT)) { + // A composition without `PlatformObjectsPlugin` has no bin, and claims + // none: no restore door reads one. That is reduced functionality, not a + // write that failed to land, so `info` (AGENTS.md, "Degradation log + // levels"). Said once per engine, on its first delete that would have + // entered the bin. + if (!this.recycleBinNotices.has(MISSING_BIN_NOTICE)) { + this.recycleBinNotices.add(MISSING_BIN_NOTICE); + this.logger.info(`[recycle-bin] ${RECYCLE_BIN_OBJECT} is not registered, so deletes are not kept in a recycle bin`, { + object, + }); + } + return undefined; + } + const days = resolveRecycleBinRetentionDays(this.recycleBinRetention); + return new RecycleBinCapture(this, object, String(id), context, days); +} diff --git a/packages/objectql/src/recycle-bin/retention.test.ts b/packages/objectql/src/recycle-bin/retention.test.ts new file mode 100644 index 00000000000..590fd150ae6 --- /dev/null +++ b/packages/objectql/src/recycle-bin/retention.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30237, ADR-0148 D3] The recycle bin's retention: the host resolver, then + * `OS_RECYCLE_BIN_RETENTION_DAYS`, then 30 — and a defect in either source + * refused with the spec's sentence, never read as "purge now" or "keep + * forever", never replaced by the default. + */ + +import { describe, it, expect } from 'vitest'; +import { RECYCLE_BIN_DEFAULT_RETENTION_DAYS, recycleBinRetentionRefusal } from '@objectstack/spec/data'; +import { + readRecycleBinRetentionEnv, + recycleBinPurgeAfter, + resolveRecycleBinRetentionDays, +} from './retention.js'; + +const ENV = 'OS_RECYCLE_BIN_RETENTION_DAYS'; + +describe('resolveRecycleBinRetentionDays: resolver, then the variable, then 30', () => { + it('answers 30 with no resolver and no variable', () => { + expect(resolveRecycleBinRetentionDays(undefined, {})).toBe(RECYCLE_BIN_DEFAULT_RETENTION_DAYS); + expect(RECYCLE_BIN_DEFAULT_RETENTION_DAYS).toBe(30); + }); + + it('answers the variable when the resolver is absent', () => { + expect(resolveRecycleBinRetentionDays(undefined, { [ENV]: '7' })).toBe(7); + }); + + it('keeps the default for an empty variable', () => { + expect(resolveRecycleBinRetentionDays(undefined, { [ENV]: '' })).toBe(30); + }); + + it("answers the resolver's days, called with the host's token", () => { + const seen: Array = []; + const days = resolveRecycleBinRetentionDays( + { resolve: (token) => { seen.push(token); return 90; }, token: 'plan-token' }, + { [ENV]: '7' }, + ); + expect(days).toBe(90); + expect(seen).toEqual(['plan-token']); + }); + + it('falls through to the variable, then the default, when the resolver answers undefined', () => { + const settings = { resolve: () => undefined }; + expect(resolveRecycleBinRetentionDays(settings, { [ENV]: '14' })).toBe(14); + expect(resolveRecycleBinRetentionDays(settings, {})).toBe(30); + }); + + it('calls the resolver with undefined when the host handed no token', () => { + const seen: unknown[] = []; + resolveRecycleBinRetentionDays({ resolve: (token) => { seen.push(token); return 1; } }, {}); + expect(seen).toEqual([undefined]); + }); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, null, '7', {}])( + 'refuses a resolver answer of %p with the spec sentence, never the default', + (answer) => { + const settings = { resolve: () => answer as unknown as number }; + expect(() => resolveRecycleBinRetentionDays(settings, { [ENV]: '7' })) + .toThrow(recycleBinRetentionRefusal('resolver', answer)); + }, + ); + + it.each(['0', '-3', '1.5', '07', ' 7', 'seven', '7d'])( + 'refuses a variable of %p with the spec sentence, never the default', + (raw) => { + expect(() => resolveRecycleBinRetentionDays(undefined, { [ENV]: raw })) + .toThrow(recycleBinRetentionRefusal('env', raw)); + }, + ); + + it('does not read a broken variable when the resolver answers', () => { + expect(resolveRecycleBinRetentionDays({ resolve: () => 5 }, { [ENV]: '0' })).toBe(5); + }); +}); + +describe('readRecycleBinRetentionEnv: the boot-time read', () => { + it('is undefined when unset or empty', () => { + expect(readRecycleBinRetentionEnv({})).toBeUndefined(); + expect(readRecycleBinRetentionEnv({ [ENV]: '' })).toBeUndefined(); + }); + + it('answers the days it holds', () => { + expect(readRecycleBinRetentionEnv({ [ENV]: '365' })).toBe(365); + }); + + it('refuses zero by name, as a configuration defect', () => { + expect(() => readRecycleBinRetentionEnv({ [ENV]: '0' })) + .toThrow(/^OS_RECYCLE_BIN_RETENTION_DAYS is set to "0", which is not a recycle-bin retention/); + }); +}); + +describe('recycleBinPurgeAfter: the deadline stamped at delete time', () => { + it('is deleted_at plus whole days, in ISO 8601', () => { + const deletedAt = new Date('2026-11-06T01:02:03.004Z'); + expect(recycleBinPurgeAfter(deletedAt, 30)).toBe('2026-12-06T01:02:03.004Z'); + expect(recycleBinPurgeAfter(deletedAt, 1)).toBe('2026-11-07T01:02:03.004Z'); + }); +}); diff --git a/packages/objectql/src/recycle-bin/retention.ts b/packages/objectql/src/recycle-bin/retention.ts new file mode 100644 index 00000000000..faea2b4c0f2 --- /dev/null +++ b/packages/objectql/src/recycle-bin/retention.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30237, ADR-0148 D3] The recycle bin's retention, in days. + * + * Three sources, read in order, the first that answers wins: + * + * 1. the host's retention resolver ({@link RecycleBinRetentionSettings}), + * called with the token the host handed over at boot; + * 2. `OS_RECYCLE_BIN_RETENTION_DAYS`; + * 3. `RECYCLE_BIN_DEFAULT_RETENTION_DAYS` (30). + * + * The engine asks once per delete operation and stamps the answer on the entry + * as its purge deadline, so a later change of the answer never shortens a + * window a person was already shown. The sweep reads only the stamped deadline + * and asks no resolver. + * + * A value that is not a positive whole number of days is a configuration + * defect, refused with the spec's own sentence (`recycleBinRetentionRefusal`): + * never read as "purge now" or "keep forever", and never replaced by the + * default. The refusal reaches the caller of the delete, before the delete + * writes anything, so a misconfigured host loses no snapshot quietly. + */ + +import { + RECYCLE_BIN_DEFAULT_RETENTION_DAYS, + RECYCLE_BIN_RETENTION_DAYS_ENV, + RecycleBinRetentionDaysEnvSchema, + RecycleBinRetentionDaysSchema, + recycleBinRetentionRefusal, + type RecycleBinRetentionResolver, +} from '@objectstack/spec/data'; + +/** + * What the host hands the engine at boot (`ObjectQLPluginOptions.recycleBinRetention`). + * + * One kernel serves one environment, so the token is fixed for the engine's + * life: it is the same opaque token the host's `resolveFeatures` receives, and + * the engine never interprets it. No token is `undefined`, which ADR-0148 D3 + * allows. + */ +export interface RecycleBinRetentionSettings { + /** The host retention resolver (ADR-0148 D3). Absent: the variable, else the default. */ + readonly resolve?: RecycleBinRetentionResolver; + /** The opaque token the resolver is called with. */ + readonly token?: string; +} + +/** The environment the variable is read from; `process.env` unless a test hands its own. */ +export type RecycleBinRetentionEnv = Readonly>; + +function processEnv(): RecycleBinRetentionEnv { + return typeof process !== 'undefined' && process.env ? process.env : {}; +} + +/** + * `OS_RECYCLE_BIN_RETENTION_DAYS`, read: `undefined` when it is unset or empty + * (the default holds), the number of days when it holds a positive whole + * number, and a thrown `Error` carrying the refusal text otherwise. + * + * The plugin calls this once at boot, so an operator who set the variable + * wrong learns it when the kernel starts rather than on the first delete. + */ +export function readRecycleBinRetentionEnv(env: RecycleBinRetentionEnv = processEnv()): number | undefined { + const raw = env[RECYCLE_BIN_RETENTION_DAYS_ENV]; + if (raw === undefined || raw === '') return undefined; + const parsed = RecycleBinRetentionDaysEnvSchema.safeParse(raw); + if (!parsed.success) throw new Error(recycleBinRetentionRefusal('env', raw)); + return Number(parsed.data); +} + +/** + * The retention in days for one delete operation: the resolver's answer, else + * the variable, else the default. Throws on a defect in either source. + */ +export function resolveRecycleBinRetentionDays( + settings: RecycleBinRetentionSettings | undefined, + env: RecycleBinRetentionEnv = processEnv(), +): number { + const resolve = settings?.resolve; + if (typeof resolve === 'function') { + const answer: unknown = resolve(settings?.token); + if (answer !== undefined) { + const parsed = RecycleBinRetentionDaysSchema.safeParse(answer); + if (!parsed.success) throw new Error(recycleBinRetentionRefusal('resolver', answer)); + return parsed.data; + } + } + return readRecycleBinRetentionEnv(env) ?? RECYCLE_BIN_DEFAULT_RETENTION_DAYS; +} + +/** One day, in milliseconds: retention is whole days, the deadline is exact. */ +const DAY_MS = 86_400_000; + +/** `deleted_at` plus `days`, as the ISO 8601 deadline the entry stores in `purge_after`. */ +export function recycleBinPurgeAfter(deletedAt: Date, days: number): string { + return new Date(deletedAt.getTime() + days * DAY_MS).toISOString(); +} diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index efb84ef80b0..504e23ec6dd 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest'; // the predicate into a package that depends on neither side. import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { PlatformObjectsPlugin } from './plugin.js'; -import { SysExchangeRate, SysMetadataActivation, SysMigration, SysMigrationJournal, SysSecret, SysTrustedKey } from './system/index.js'; +import { SysExchangeRate, SysMetadataActivation, SysMigration, SysMigrationJournal, SysRecycleBin, SysSecret, SysTrustedKey } from './system/index.js'; /** * Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this @@ -46,7 +46,7 @@ function makeCtx() { } describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4243, #4270)', () => { - it('registers SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate and SysTrustedKey through the manifest service', async () => { + it('registers SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey and SysRecycleBin through the manifest service', async () => { const ctx = makeCtx(); const manifests: any[] = []; ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); @@ -56,7 +56,7 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 const infra = manifests.find((m) => m.id === 'com.objectstack.platform-objects'); expect(infra).toBeDefined(); expect(infra.scope).toBe('system'); - expect(infra.objects).toEqual([SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey]); + expect(infra.objects).toEqual([SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey, SysRecycleBin]); expect(infra.objects.map((o: any) => o.name)).toEqual([ 'sys_migration', // ADR-0119 D2 (#4617). Registered UNCONDITIONALLY, alongside the flag @@ -73,8 +73,12 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 // keys, read by `os plugin install` from a one-shot boot that composes // this plugin and nothing that would register it otherwise. 'sys_trusted_key', + // [ADR-0148, #30237] The recycle bin. Written by the engine's by-id + // delete for any business object, so it registers with the engine's + // infrastructure rather than with any one capability plugin. + 'sys_recycle_bin', ]); - // ⛔ The five above must NOT acquire the ledger's routing triple. They + // ⛔ The six above must NOT acquire the ledger's routing triple. They // ride the project database today; moving them is a different question. expect(infra.defaultDatasource).toBeUndefined(); }); diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index 83152351592..f7e3f68eb41 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -8,6 +8,7 @@ import { SysMigrationJournal } from './system/sys-migration-journal.object.js'; import { SysSecret } from './system/sys-secret.object.js'; import { SysExchangeRate } from './system/sys-exchange-rate.object.js'; import { SysTrustedKey } from './system/sys-trusted-key.object.js'; +import { SysRecycleBin } from './system/sys-recycle-bin.object.js'; import { attestFreshDatastore } from './system/migration-flag.js'; import type { II18nService, @@ -56,6 +57,12 @@ import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; * and ADR-0025 M1's boot-time plugin loader in the runtime is the second * reader (not built yet). No capability plugin owns plugin distribution, * so it rides with the other platform infrastructure (ADR-0029 D1/D2). + * - **`sys_recycle_bin`** — the recycle bin (ADR-0148, #30237): one entry + * per delete operation, written by the ObjectQL engine's by-id delete for + * every business object, whichever package declared it. Its writer is the + * engine, present in every composition, so it registers with the engine- + * facing infrastructure, on the project database. A composition without it + * keeps no snapshot, and the engine says so once per object. * - **`sys_metadata_activation`** — the packaged-metadata activation * ledger (ADR-0126 §4). Same story a third time, and the ruling that * moved it here is recorded verbatim on #12359 (maintainer, 2026-08-26, @@ -202,7 +209,7 @@ export class PlatformObjectsPlugin { version: this.version, type: 'plugin', scope: 'system', - objects: [SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey], + objects: [SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey, SysRecycleBin], }); ctx?.getService?.('manifest')?.register?.({ ...ACTIVATION_LEDGER_MANIFEST, diff --git a/packages/platform-objects/src/system/index.ts b/packages/platform-objects/src/system/index.ts index 9849501f4ca..c5aa4e9bbdb 100644 --- a/packages/platform-objects/src/system/index.ts +++ b/packages/platform-objects/src/system/index.ts @@ -23,6 +23,8 @@ export { SysSettingAudit } from './sys-setting-audit.object.js'; export { SysPlatformSettingAudit } from './sys-platform-setting-audit.object.js'; export { SysMigration } from './sys-migration.object.js'; export { SysMigrationJournal } from './sys-migration-journal.object.js'; +// [ADR-0148, #30237] The recycle bin: one entry per delete operation. +export { SysRecycleBin } from './sys-recycle-bin.object.js'; // Multi-currency M1-1 (epic #8345): the dated rate table the write-time // home-currency projection reads. export { diff --git a/packages/platform-objects/src/system/sys-recycle-bin.object.ts b/packages/platform-objects/src/system/sys-recycle-bin.object.ts new file mode 100644 index 00000000000..d3b3b6f6cd4 --- /dev/null +++ b/packages/platform-objects/src/system/sys-recycle-bin.object.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field, RECYCLE_BIN_ENTRY_FIELDS, RECYCLE_BIN_OBJECT } from '@objectstack/spec/data'; + +/** + * sys_recycle_bin — the recycle bin: one entry per delete operation + * (ADR-0148 D1, card #30237). + * + * When a by-id delete removes a business record, the engine reads the rows + * the delete removes (the record and its master-detail cascade, in cascade + * order) into ONE entry here, records the `set_null` references the delete + * clears, and then deletes physically as before. No read path filters on this + * table; a deleted record is gone from every query the moment its delete + * commits. + * + * ## Fields + * + * Built from `RECYCLE_BIN_ENTRY_FIELDS` (`@objectstack/spec/data`), the one + * field list the spec's `RecycleBinEntrySchema` shares, so the table and the + * contract cannot drift. Every field is read-only. + * + * ## Writers and readers + * + * `managedBy: 'engine-owned'`: the engine's recycle-bin paths are the only + * writers. The entry is written raw through this object's driver, inside the + * delete's own unit of work (`@objectstack/objectql`, `recycle-bin/capture.ts`), + * never through `engine.insert`, so no insert hook, audit mirror or activity + * row fires for the bookkeeping of a delete the audit already records. + * + * ⛔ No generic data-API method (`apiEnabled: false`, `apiMethods: []`): the + * snapshot holds every field of the deleted rows, so a listing must apply the + * field-level security of the live object it came from (ADR-0148 D4). The + * engine's restore, purge and empty paths and their REST doors are that + * reader, under `allowRestoreOwnDeleted` / `manageRecycleBin`. + * + * ## Retention + * + * Each entry carries its own deadline, `purge_after`, stamped at delete time + * from the retention in days the host resolver, `OS_RECYCLE_BIN_RETENTION_DAYS` + * or the default of 30 answered (ADR-0148 D3). The lifecycle sweep reaps an + * entry once that deadline has passed (`ttl` on `purge_after`, expiring the + * moment the stamped deadline does), and asks no resolver: a later change of + * the retention never shortens a window a person was already shown. A + * settings override can only lengthen it. + * + * ## Organization delete + * + * The entry belongs to the organization whose record it holds. Its tenant + * column cascades (seat ruling on #30237 Q4): deleting the organization + * deletes its entries, rather than keeping a snapshot of rows the + * organization's delete has removed (ADR-0148 D4). An engine-owned object is + * otherwise never counted against an organization delete. + * + * Registered by `PlatformObjectsPlugin` (`../plugin.ts`). + * + * @namespace sys + */ +export const SysRecycleBin = ObjectSchema.create({ + name: RECYCLE_BIN_OBJECT, + label: 'Recycle Bin Entry', + pluralLabel: 'Recycle Bin', + icon: 'trash-2', + isSystem: true, + managedBy: 'engine-owned', + access: { default: 'private' }, + description: + 'One entry per delete operation: the rows a delete removed, kept until their purge deadline so they can be restored under their original ids.', + nameField: 'record_id', // [ADR-0079] canonical primary-title pointer + highlightFields: ['object_name', 'record_id', 'record_count', 'deleted_by', 'deleted_at', 'purge_after'], + + fields: { + ...RECYCLE_BIN_ENTRY_FIELDS, + + organization_id: Field.lookup('sys_organization', { + label: 'Organization', + required: false, + readonly: true, + // [#30237 Q4] The organization's delete takes its entries with it. + deleteBehavior: 'cascade', + description: 'The organization whose record the entry holds. Deleting the organization deletes its entries.', + }), + }, + + indexes: [ + // The entries of one record, newest first: the Console's undo and the + // per-object listing read by these two. + { fields: ['object_name', 'record_id'], unique: false }, + // The lifecycle sweep's access path. + { fields: ['purge_after'], unique: false }, + // A person's own entries (`allowRestoreOwnDeleted`). + { fields: ['deleted_by'], unique: false }, + ], + + lifecycle: { + class: 'transient', + // The deadline is the stamped column itself, so the window is zero: an + // entry is due the moment `purge_after` passes, and never before. + ttl: { field: 'purge_after', expireAfter: '0h' }, + }, + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: false, + apiMethods: [], + }, +}); diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index f6a95cac013..e9ba30d6eec 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -76,6 +76,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly Date: Fri, 6 Nov 2026 01:19:06 +0000 Subject: [PATCH 2/5] fix(objectql): credit a recycle-bin entry to the human a system delete was made for deleted_by reads the authorized subject first, then the attributed user of a system-authorized write, the precedence the audit ledger uses. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr --- .../objectql/src/recycle-bin/capture.test.ts | 7 +++++++ packages/objectql/src/recycle-bin/capture.ts | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/recycle-bin/capture.test.ts b/packages/objectql/src/recycle-bin/capture.test.ts index e4656488d1d..78c03e85617 100644 --- a/packages/objectql/src/recycle-bin/capture.test.ts +++ b/packages/objectql/src/recycle-bin/capture.test.ts @@ -275,6 +275,13 @@ describe('a by-id delete writes ONE recycle-bin entry (ADR-0148 D1)', () => { expect(entry.deleted_by).toBeUndefined(); }); + it('credits the human a system-authorized delete was made for, as the audit ledger does', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('loner', { id: 'l1', name: 'x' }); + await engine.delete('loner', { where: { id: 'l1' }, context: { isSystem: true, attributedUserId: 'u_bob' } } as any); + expect(entriesOf(driver)[0].deleted_by).toBe('u_bob'); + }); + it('leaves the secret column out of the snapshot (D4)', async () => { const { engine, driver } = await makeEngine(); seedAccountTree(driver); diff --git a/packages/objectql/src/recycle-bin/capture.ts b/packages/objectql/src/recycle-bin/capture.ts index 5c99ef9fcd1..8bb1f360d45 100644 --- a/packages/objectql/src/recycle-bin/capture.ts +++ b/packages/objectql/src/recycle-bin/capture.ts @@ -224,7 +224,7 @@ export class RecycleBinCapture { return undefined; } const deletedAt = new Date(); - const deletedBy = this.context?.userId; + const deletedBy = this.deletedBy(); const organizationId = this.organizationOf(rows[0].values); const clearedReferences: RecycleBinClearedReference[] = [...(this.set?.clearedReferences ?? [])]; const entry: RecycleBinEntry = RecycleBinEntrySchema.parse({ @@ -232,7 +232,7 @@ export class RecycleBinCapture { record_id: this.id, record_count: rows.length, snapshot: { rows, clearedReferences }, - ...(typeof deletedBy === 'string' && deletedBy !== '' ? { deleted_by: deletedBy } : {}), + ...(deletedBy !== undefined ? { deleted_by: deletedBy } : {}), deleted_at: deletedAt.toISOString(), retention_days: this.retentionDays, purge_after: recycleBinPurgeAfter(deletedAt, this.retentionDays), @@ -272,6 +272,21 @@ export class RecycleBinCapture { }; } + /** + * Who deleted the rows, as the audit ledger credits a write: the subject the + * delete was authorized as, else the human credited for a delete the system + * authorized on their behalf (`attributedUserId`, a better-auth-originated + * write). `undefined` for the system's own deletes. It is the person whose + * entries `allowRestoreOwnDeleted` lets restore. + */ + private deletedBy(): string | undefined { + const context = this.context as { userId?: unknown; attributedUserId?: unknown } | undefined; + for (const candidate of [context?.userId, context?.attributedUserId]) { + if (typeof candidate === 'string' && candidate !== '') return candidate; + } + return undefined; + } + /** * The organization that owns the entry: the root row's own, read off its * tenant column, else the deleting context's. An entry belongs to the From 136ad85db6392643120f450f4dcdcb8b73b5d477 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 01:31:25 +0000 Subject: [PATCH 3/5] docs(spec): say in the present tense where the recycle bin's object file lives The comment above RECYCLE_BIN_OBJECT described the object file as landing later; it lands with this change, so the comment now names it. The changeset records the skills follow-up that is owed, not moved. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr --- .changeset/30237-recycle-bin-snapshot-on-delete.md | 3 ++- packages/spec/src/data/recycle-bin.zod.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/30237-recycle-bin-snapshot-on-delete.md b/.changeset/30237-recycle-bin-snapshot-on-delete.md index 31730cf9dd5..934cfa320c6 100644 --- a/.changeset/30237-recycle-bin-snapshot-on-delete.md +++ b/.changeset/30237-recycle-bin-snapshot-on-delete.md @@ -9,6 +9,7 @@ feat(objectql,platform-objects): a by-id delete keeps its rows in the recycle bi Clause-②: yes (widening) Metadata-protocol: added +Skills-docs: follow-up owed — skills/objectstack-api/SKILL.md :268, :417 (they still teach that there is no recycle bin; not moved in this release) 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. @@ -22,5 +23,5 @@ The recycle bin's write side. A delete is still a physical delete, and no read p - any delete on a composition that does not register `PlatformObjectsPlugin`, which logs one `info` line saying so. - **Forward-only:** records deleted before this release have no entry. - **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. -- **`@objectstack/spec`:** `PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` lists `sys_recycle_bin`, so a reference to it lints as a real platform object. +- **`@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. - **`@objectstack/cli`:** the organization-ownership inventory has a row for `sys_recycle_bin` (fate `attribution`). diff --git a/packages/spec/src/data/recycle-bin.zod.ts b/packages/spec/src/data/recycle-bin.zod.ts index fdf313bd3b5..7b52fd88780 100644 --- a/packages/spec/src/data/recycle-bin.zod.ts +++ b/packages/spec/src/data/recycle-bin.zod.ts @@ -11,10 +11,10 @@ import { z } from 'zod'; import { Field } from './field.zod'; -// The object FILE, its registration in `PlatformObjectsPlugin` and its row in -// `PLATFORM_OBJECTS_BY_PACKAGE` land together with the engine slice (#30237): -// that registry means "a table exists", so the name joins it only with the -// table. The object file builds `fields` from RECYCLE_BIN_ENTRY_FIELDS below. +// The object FILE is `sys-recycle-bin.object.ts` in @objectstack/platform-objects, +// which builds `fields` from RECYCLE_BIN_ENTRY_FIELDS below. `PlatformObjectsPlugin` +// registers it and `PLATFORM_OBJECTS_BY_PACKAGE` lists its name: that registry +// means "a table exists", and the name sits there because the table does. /** * The recycle bin: one system object, one entry per delete operation * (ADR-0148 D1). From 819b02f707dd76ef25254db10092706dd7b8f772 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 02:07:10 +0000 Subject: [PATCH 4/5] fix(platform-objects,objectql): write the recycle bin's declaration in shapes the source scans read check:keyed-text-bounds refuses a spread inside `fields` and a `name` that is not a string literal, so sys_recycle_bin now spells each spec entry through its builder (restating the two keyed text bounds) and names itself literally. The plugin test pins every entry equal to the spec's, so the restatement cannot drift. The tenancy census is regenerated for the new in-reach object, and the capture test's driver double applies a caller's bound after its filter and before it copies rows. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr --- .../objectql/src/recycle-bin/capture.test.ts | 4 ++- packages/platform-objects/src/plugin.test.ts | 13 ++++++++++ .../src/system/sys-recycle-bin.object.ts | 25 +++++++++++++++---- scripts/platform-object-tenancy-census.json | 11 ++++++-- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/objectql/src/recycle-bin/capture.test.ts b/packages/objectql/src/recycle-bin/capture.test.ts index 78c03e85617..b6bb94848d5 100644 --- a/packages/objectql/src/recycle-bin/capture.test.ts +++ b/packages/objectql/src/recycle-bin/capture.test.ts @@ -81,7 +81,9 @@ function makeDriver(opts: { transactional?: boolean; failDeleteOf?: string } = { async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async syncSchema() {}, async find(o: string, ast: any) { - return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + return page.map((r) => ({ ...r })); }, async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return { ...r }; diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index 504e23ec6dd..f1e1e09db8c 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect } from 'vitest'; // why this file's `update` entry sat in the gate's DEBT ledger until #5619 sank // the predicate into a package that depends on neither side. import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectSchema, RECYCLE_BIN_ENTRY_FIELDS, RECYCLE_BIN_OBJECT } from '@objectstack/spec/data'; import { PlatformObjectsPlugin } from './plugin.js'; import { SysExchangeRate, SysMetadataActivation, SysMigration, SysMigrationJournal, SysRecycleBin, SysSecret, SysTrustedKey } from './system/index.js'; @@ -83,6 +84,18 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 expect(infra.defaultDatasource).toBeUndefined(); }); + // [ADR-0148, #30237] The table and `RecycleBinEntrySchema` share one field + // list. The object file spells each entry through its builder (a spread is + // unreadable to the source scans), so this is what keeps it the spec's. + it("sys_recycle_bin declares exactly the spec's entry fields, plus its tenant column", () => { + const { organization_id, ...entryFields } = SysRecycleBin.fields as Record; + // Through the same factory, so both sides carry the parse's defaults. + const spec = ObjectSchema.create({ name: RECYCLE_BIN_OBJECT, label: 'Recycle Bin Entry', fields: { ...RECYCLE_BIN_ENTRY_FIELDS } }); + expect(entryFields).toEqual(spec.fields); + expect(organization_id).toMatchObject({ type: 'lookup', reference: 'sys_organization', deleteBehavior: 'cascade' }); + expect(SysRecycleBin.name).toBe(RECYCLE_BIN_OBJECT); + }); + /** * [#12359 ruling, 2026-08-26 — 「同意」] ADR-0126 §4's activation ledger. * Registration follows the DECLARATION: the object is declared in this diff --git a/packages/platform-objects/src/system/sys-recycle-bin.object.ts b/packages/platform-objects/src/system/sys-recycle-bin.object.ts index d3b3b6f6cd4..877a819d721 100644 --- a/packages/platform-objects/src/system/sys-recycle-bin.object.ts +++ b/packages/platform-objects/src/system/sys-recycle-bin.object.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { ObjectSchema, Field, RECYCLE_BIN_ENTRY_FIELDS, RECYCLE_BIN_OBJECT } from '@objectstack/spec/data'; +import { ObjectSchema, Field, RECYCLE_BIN_ENTRY_FIELDS } from '@objectstack/spec/data'; /** * sys_recycle_bin — the recycle bin: one entry per delete operation @@ -16,8 +16,9 @@ import { ObjectSchema, Field, RECYCLE_BIN_ENTRY_FIELDS, RECYCLE_BIN_OBJECT } fro * ## Fields * * Built from `RECYCLE_BIN_ENTRY_FIELDS` (`@objectstack/spec/data`), the one - * field list the spec's `RecycleBinEntrySchema` shares, so the table and the - * contract cannot drift. Every field is read-only. + * field list the spec's `RecycleBinEntrySchema` shares, key for key, plus the + * tenant column below; `plugin.test.ts` pins each entry equal to the spec's, + * so the table and the contract cannot drift. Every field is read-only. * * ## Writers and readers * @@ -56,7 +57,7 @@ import { ObjectSchema, Field, RECYCLE_BIN_ENTRY_FIELDS, RECYCLE_BIN_OBJECT } fro * @namespace sys */ export const SysRecycleBin = ObjectSchema.create({ - name: RECYCLE_BIN_OBJECT, + name: 'sys_recycle_bin', // RECYCLE_BIN_OBJECT; a literal, so the source scans can attribute this declaration label: 'Recycle Bin Entry', pluralLabel: 'Recycle Bin', icon: 'trash-2', @@ -69,7 +70,21 @@ export const SysRecycleBin = ObjectSchema.create({ highlightFields: ['object_name', 'record_id', 'record_count', 'deleted_by', 'deleted_at', 'purge_after'], fields: { - ...RECYCLE_BIN_ENTRY_FIELDS, + // One entry per key of RECYCLE_BIN_ENTRY_FIELDS, each spelled through its + // builder rather than spread in: `check:keyed-text-bounds` reads `fields` + // from source and refuses an entry it cannot attribute to a column, and it + // reads a keyed text column's bound only from a literal `maxLength`, so the + // two keyed text columns restate theirs. `plugin.test.ts` pins every entry + // equal to the spec's, so the restatement cannot drift. + id: Field.text({ ...RECYCLE_BIN_ENTRY_FIELDS.id }), + object_name: Field.text({ ...RECYCLE_BIN_ENTRY_FIELDS.object_name, maxLength: 255 }), + record_id: Field.text({ ...RECYCLE_BIN_ENTRY_FIELDS.record_id, maxLength: 255 }), + record_count: Field.number({ ...RECYCLE_BIN_ENTRY_FIELDS.record_count }), + snapshot: Field.json({ ...RECYCLE_BIN_ENTRY_FIELDS.snapshot }), + deleted_by: Field.lookup('sys_user', { ...RECYCLE_BIN_ENTRY_FIELDS.deleted_by }), + deleted_at: Field.datetime({ ...RECYCLE_BIN_ENTRY_FIELDS.deleted_at }), + retention_days: Field.number({ ...RECYCLE_BIN_ENTRY_FIELDS.retention_days }), + purge_after: Field.datetime({ ...RECYCLE_BIN_ENTRY_FIELDS.purge_after }), organization_id: Field.lookup('sys_organization', { label: 'Organization', diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index a775e6db527..388609b8007 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -26,8 +26,8 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 93, - "inReach": 47, + "registered": 94, + "inReach": 48, "outOfReach": 46 }, "reasonTotals": { @@ -564,6 +564,13 @@ "tenantField": "organization_id", "reasons": [] }, + { + "name": "sys_recycle_bin", + "file": "packages/platform-objects/src/system/sys-recycle-bin.object.ts", + "reach": "in", + "tenantField": "organization_id", + "reasons": [] + }, { "name": "sys_scim_connection_binding", "file": "packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts", From 6a07be615a15c79843c7ec65e7e0592cb0503d1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 03:04:01 +0000 Subject: [PATCH 5/5] fix(objectql,plugin-audit): keep predicate deletes, platform objects and the bin's own bookkeeping out of the recycle bin and the ledger The lifecycle sweep reaps each row with `where: { id }, multi: true`, which the engine routes as a by-id delete, so a business object that declares a lifecycle would have entered the bin on every reap. The capture now opens only when the caller did not ask for `multi: true`. The bin also leaves out every platform namespace (sys_, cloud_, ai_), not only sys_. plugin-audit's generic writer would have copied each entry, snapshot and internal columns included, into sys_audit_log and sys_activity on its reap, on the set_null of its deleted_by and on an organization delete's cascade. sys_recycle_bin joins SKIP_OBJECTS beside the other engine-owned plumbing. Pins: a predicate-shaped by-id delete and a lifecycle sweep of a business object write no entry; cloud_ and ai_ objects write none; an organization delete removes its entries without being refused by them; a reap and an update of an entry write no ledger row while a business delete still does. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr --- .../30237-recycle-bin-snapshot-on-delete.md | 8 ++- .../utils/organization-ownership-inventory.ts | 6 +- packages/objectql/src/engine.ts | 8 ++- .../objectql/src/recycle-bin/capture.test.ts | 67 +++++++++++++++++-- packages/objectql/src/recycle-bin/capture.ts | 29 +++++--- .../src/system/sys-recycle-bin.object.ts | 5 +- .../plugin-audit/src/audit-writers.test.ts | 63 +++++++++++++++++ .../plugins/plugin-audit/src/audit-writers.ts | 12 ++++ 8 files changed, 174 insertions(+), 24 deletions(-) diff --git a/.changeset/30237-recycle-bin-snapshot-on-delete.md b/.changeset/30237-recycle-bin-snapshot-on-delete.md index 934cfa320c6..3936f00f61e 100644 --- a/.changeset/30237-recycle-bin-snapshot-on-delete.md +++ b/.changeset/30237-recycle-bin-snapshot-on-delete.md @@ -3,13 +3,14 @@ '@objectstack/platform-objects': minor '@objectstack/spec': minor '@objectstack/cli': minor +'@objectstack/plugin-audit': minor --- 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 Clause-②: yes (widening) Metadata-protocol: added -Skills-docs: follow-up owed — skills/objectstack-api/SKILL.md :268, :417 (they still teach that there is no recycle bin; not moved in this release) +Skills-docs: follow-up owed — skills/objectstack-api/SKILL.md :268, :417 (they teach that there is no recycle bin) 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. @@ -17,11 +18,12 @@ The recycle bin's write side. A delete is still a physical delete, and no read p - **`@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. - **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()`. - **Which deletes do NOT enter the bin:** - - a delete by predicate (`multi: true`), which includes the lifecycle sweep; + - a delete by predicate (`multi: true`), which includes every lifecycle sweep reap, even though the sweep names each reaped row by its id; - a child the cascade deletes, which is a row of its root's entry instead; - - any `sys_*` object, whose rows are also left out of a business record's snapshot; + - any platform object, meaning a `sys_*`, `cloud_*` or `ai_*` object, whose rows are also left out of a business record's snapshot; - any delete on a composition that does not register `PlatformObjectsPlugin`, which logs one `info` line saying so. - **Forward-only:** records deleted before this release have no entry. - **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. - **`@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. - **`@objectstack/cli`:** the organization-ownership inventory has a row for `sys_recycle_bin` (fate `attribution`). +- **`@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. diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index c6d7cbb733b..e258fbdb55b 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -805,8 +805,10 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ { object: 'sys_exchange_rate', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, // The recycle bin (`@objectstack/platform-objects`, ADR-0148, #30237): the // engine writes each entry with the organization of the record it holds, - // else the deleting context's, so a NULL row has no producer; the record it - // names is gone, so no parent row anchors an entry. + // else the deleting context's. A NULL row comes only from a delete with + // neither (a tenancy-disabled object deleted by a context with no active + // organization). The record an entry names is gone, so no parent row + // anchors an entry. { object: 'sys_recycle_bin', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, // A reader's own named list views (`@objectstack/platform-objects`, ruling // 6570568131 on #28971): user data on the preference template, each row in diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9ce18cdff09..c0f11adc141 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -9054,6 +9054,7 @@ export class ObjectQL implements IObjectQLEngine { object: string, id: string | number, context: ExecutionContext | undefined, + predicate: boolean, ): RecycleBinCapture | undefined { // [#30237] The body lives in `recycle-bin/capture.ts`, its docblock with it (see `RecycleBinCaptureHost`). const readRegistry = () => this._registry; @@ -9070,7 +9071,7 @@ export class ObjectQL implements IObjectQLEngine { getDriver: (...a) => this.getDriver(...a), buildDriverOptions: (...a) => this.buildDriverOptions(...a), }; - return openRecycleBinCapture.call(host, object, id, context); + return openRecycleBinCapture.call(host, object, id, context, predicate); } /** @@ -9745,9 +9746,10 @@ export class ObjectQL implements IObjectQLEngine { // walk touches it, and writes the one entry after the walk and // before this row goes. A removal that throws takes the entry // with it: the transaction's rollback when one covers the bin, - // the capture's compensation otherwise. + // the capture's compensation otherwise. A caller's `multi: true` + // opens none, even here: the lifecycle sweep reaps by id with it. const runByIdDelete = async (writeContext?: ExecutionContext) => { - const bin = this.openRecycleBinCapture(object, hookContext.input.id as string | number, writeContext); + const bin = this.openRecycleBinCapture(object, hookContext.input.id as string | number, writeContext, options?.multi === true); await this.cascadeDeleteRelations(object, [hookContext.input.id as string | number], writeContext, 0, bin?.onCollected) .catch((e: unknown) => { throw cascadeStoppedRefusal(e, object); }); const entry = await bin?.write(); diff --git a/packages/objectql/src/recycle-bin/capture.test.ts b/packages/objectql/src/recycle-bin/capture.test.ts index b6bb94848d5..0c7bd06abb4 100644 --- a/packages/objectql/src/recycle-bin/capture.test.ts +++ b/packages/objectql/src/recycle-bin/capture.test.ts @@ -199,8 +199,19 @@ const task = { watchers: { name: 'watchers', type: 'lookup' as const, reference: 'account', multiple: true }, }, }; -/** A system object: never enters the bin. */ +/** Platform objects, one per namespace: never enter the bin. */ const sysThing = { name: 'sys_thing', label: 'Thing', fields: { id: text('id'), name: text('name') } }; +const cloudThing = { name: 'cloud_thing', label: 'Cloud Thing', fields: { id: text('id'), name: text('name') } }; +const aiThing = { name: 'ai_thing', label: 'AI Thing', fields: { id: text('id'), name: text('name') } }; +/** The organization the bin's tenant column cascades from (ruling Q4). */ +const organization = { name: 'sys_organization', label: 'Organization', fields: { id: text('id'), name: text('name') } }; +/** A business object that declares its own retention (ADR-0057's authoring surface). */ +const ephemeral = { + name: 'ephemeral', + label: 'Ephemeral', + fields: { id: text('id'), expires_at: { name: 'expires_at', type: 'datetime' as const } }, + lifecycle: { class: 'transient', ttl: { field: 'expires_at', expireAfter: '0h' } }, +}; /** Nothing references it: the plain, non-cascading delete. */ const loner = { name: 'loner', label: 'Loner', fields: { id: text('id'), name: text('name') } }; @@ -210,7 +221,7 @@ async function makeEngine(options: { withBin?: boolean; transactional?: boolean; const driver = makeDriver(options); engine.registerDriver(driver, true); await engine.init(); - const objects: any[] = [account, contact, note, task, sysThing, loner]; + const objects: any[] = [account, contact, note, task, sysThing, cloudThing, aiThing, organization, ephemeral, loner]; if (options.withBin !== false) objects.push(bin); for (const o of objects) engine.registry.registerObject(o as any, '__test__'); return { engine, driver, rec }; @@ -329,11 +340,37 @@ describe('deletes that do not enter the bin (v1 reach, seat ruling Q6)', () => { expect(entriesOf(driver)).toHaveLength(0); }); - it('a sys_* object writes no entry', async () => { + it('a by-id delete that its caller asked as a predicate (`multi: true`) writes no entry', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('loner', { id: 'l1', name: 'x' }); + await engine.delete('loner', { where: { id: 'l1' }, multi: true, ...USER } as any); + expect(driver.rowsOf('loner')).toHaveLength(0); + expect(entriesOf(driver)).toHaveLength(0); + }); + + it("the lifecycle sweep's reap of a business object writes no entry", async () => { + const { engine, driver } = await makeEngine(); + driver.seed('ephemeral', { id: 'e_due', expires_at: '2026-11-01T00:00:00.000Z' }); + driver.seed('ephemeral', { id: 'e_kept', expires_at: '2026-12-01T00:00:00.000Z' }); + const lifecycle = new LifecycleService({ + getEngine: () => engine as any, + logger: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} } as any, + now: () => Date.parse('2026-11-06T00:00:00.000Z'), + initialDelayMs: 1, + sweepIntervalMs: 10, + }); + const report = await lifecycle.sweep(); + expect(report.errors).toEqual([]); + // The reap ran: the due row is gone, by id, through the engine. + expect(driver.rowsOf('ephemeral').map((r: Row) => r.id)).toEqual(['e_kept']); + expect(entriesOf(driver)).toHaveLength(0); + }); + + it.each(['sys_thing', 'cloud_thing', 'ai_thing'])('a platform object (%s) writes no entry', async (object) => { const { engine, driver } = await makeEngine(); - driver.seed('sys_thing', { id: 's1', name: 'x' }); - await engine.delete('sys_thing', { where: { id: 's1' } } as any); - expect(driver.rowsOf('sys_thing')).toHaveLength(0); + driver.seed(object, { id: 's1', name: 'x' }); + await engine.delete(object, { where: { id: 's1' }, ...USER } as any); + expect(driver.rowsOf(object)).toHaveLength(0); expect(entriesOf(driver)).toHaveLength(0); }); @@ -417,6 +454,24 @@ describe('the TTL sweep reaps an entry once its stamped deadline has passed (D3) }); }); +describe("an organization's delete takes its entries with it (ruling Q4)", () => { + it('removes the entries of that organization, is not refused by them, and leaves other organizations\' entries', async () => { + const { engine, driver } = await makeEngine(); + driver.seed('sys_organization', { id: 'org_1', name: 'One' }); + driver.seed('sys_organization', { id: 'org_2', name: 'Two' }); + driver.seed('loner', { id: 'l1', name: 'one' }); + driver.seed('loner', { id: 'l2', name: 'two' }); + await engine.delete('loner', { where: { id: 'l1' }, context: { userId: 'u_alice', tenantId: 'org_1' } } as any); + await engine.delete('loner', { where: { id: 'l2' }, context: { userId: 'u_bob', tenantId: 'org_2' } } as any); + expect(entriesOf(driver).map((e) => e.organization_id).sort()).toEqual(['org_1', 'org_2']); + + await expect(engine.delete('sys_organization', { where: { id: 'org_1' }, ...USER } as any)).resolves.toBeTruthy(); + + expect(driver.rowsOf('sys_organization').map((r: Row) => r.id)).toEqual(['org_2']); + expect(entriesOf(driver).map((e) => e.organization_id)).toEqual(['org_2']); + }); +}); + describe('cascadeMembersInOrder', () => { it('lists the root first, then breadth-first along the cascading edges, then any member no edge reached', () => { const set: CascadeDeleteSet = { diff --git a/packages/objectql/src/recycle-bin/capture.ts b/packages/objectql/src/recycle-bin/capture.ts index 8bb1f360d45..f2055c52db3 100644 --- a/packages/objectql/src/recycle-bin/capture.ts +++ b/packages/objectql/src/recycle-bin/capture.ts @@ -28,11 +28,14 @@ * * ## Which deletes enter the bin (v1, seat ruling on #30237 Q6) * - * A by-id delete of a business object (a name outside `sys_`) that is the - * ROOT of its cascade. Not: a delete by predicate (`multi: true`, a lifecycle - * reap), a child the cascade deletes (it is a row of its root's entry), and a - * `sys_*` object. A `sys_*` row the cascade reaches is left out of the - * snapshot too. + * A by-id delete of a business object that is the ROOT of its cascade. A + * business object is a name outside the platform namespaces `sys_`, `cloud_` + * and `ai_` (`hasPlatformObjectPrefix`, the tenancy census's population; seat + * ruling on the contract review's question 7). Not: a delete by predicate + * (`multi: true`, which every lifecycle reap is, even though it addresses each + * reaped row by id), a child the cascade deletes (it is a row of its root's + * entry), and a platform object. A platform row the cascade reaches is left + * out of the snapshot too. * * ## Why raw, and not `engine.insert` * @@ -51,6 +54,7 @@ import { type RecycleBinSnapshotRow, } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { hasPlatformObjectPrefix } from '@objectstack/spec/system'; import type { Logger } from '@objectstack/core'; import { redactBoundStatement } from '@objectstack/types'; import { cascadeMemberKey, cascadeSetHas, type CascadeDeleteSet } from '../engine-top-level.js'; @@ -61,11 +65,12 @@ import { recycleBinPurgeAfter, resolveRecycleBinRetentionDays, type RecycleBinRe /** * Does a delete of `object` enter the bin at all? The v1 reach: every object - * outside the `sys_` namespace. The by-id and root-of-cascade conditions are - * the caller's ({@link openRecycleBinCapture}). + * outside the platform namespaces (`sys_`, `cloud_`, `ai_`). The by-id, + * not-a-predicate and root-of-cascade conditions are the caller's + * ({@link openRecycleBinCapture}). */ export function recycleBinTakesObject(object: string): boolean { - return !object.startsWith('sys_'); + return !hasPlatformObjectPrefix(object); } /** The notice that the composition registers no bin, given once per engine. */ @@ -308,14 +313,20 @@ export class RecycleBinCapture { * the delete does not enter the bin. Throws the retention refusal when the * host's resolver or `OS_RECYCLE_BIN_RETENTION_DAYS` holds a defect, before * the delete has written anything. + * + * `predicate` is the caller's own `multi: true`. The engine routes a delete by + * its id, not by that flag (#5574, #11009), so a predicate delete that names + * one row by id, as the lifecycle sweep's per-row reap does, arrives here on + * the by-id path and is told apart only by the flag it was asked with. */ export function openRecycleBinCapture( this: RecycleBinCaptureHost, object: string, id: string | number, context: ExecutionContext | undefined, + predicate: boolean, ): RecycleBinCapture | undefined { - if (!recycleBinTakesObject(object)) return undefined; + if (predicate || !recycleBinTakesObject(object)) return undefined; // A child the cascade deletes is a row of its root's entry, never an entry. const inherited = this.cascadeDeleteSets.getStore(); if (inherited && cascadeSetHas(inherited.members, object, id)) return undefined; diff --git a/packages/platform-objects/src/system/sys-recycle-bin.object.ts b/packages/platform-objects/src/system/sys-recycle-bin.object.ts index 877a819d721..f19a654efb3 100644 --- a/packages/platform-objects/src/system/sys-recycle-bin.object.ts +++ b/packages/platform-objects/src/system/sys-recycle-bin.object.ts @@ -26,7 +26,10 @@ import { ObjectSchema, Field, RECYCLE_BIN_ENTRY_FIELDS } from '@objectstack/spec * writers. The entry is written raw through this object's driver, inside the * delete's own unit of work (`@objectstack/objectql`, `recycle-bin/capture.ts`), * never through `engine.insert`, so no insert hook, audit mirror or activity - * row fires for the bookkeeping of a delete the audit already records. + * row fires for the bookkeeping of a delete the audit already records. The + * later writes the engine makes through its own paths (the sweep's reap, the + * `set_null` of `deleted_by`, an organization delete's cascade) are kept out + * of the ledger by `@objectstack/plugin-audit`'s `SKIP_OBJECTS`. * * ⛔ No generic data-API method (`apiEnabled: false`, `apiMethods: []`): the * snapshot holds every field of the deleted rows, so a listing must apply the diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index d9a462fbd00..37ecd638aca 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -525,6 +525,69 @@ describe('audit writers — chunked upload sessions are excluded (#5202, ADR-005 }); }); +describe('audit writers — recycle-bin entries are excluded (ADR-0148 D4, ADR-0057 §3.3)', () => { + // An entry as the engine writes it: `snapshot` holds every declared column of + // the deleted rows, `internal: true` ones included. Nothing here may reach + // the ledger through the generic writer. + const SCHEMA = { + sys_audit_log: SINGLE_TENANT.sys_audit_log, + sys_activity: SINGLE_TENANT.sys_activity, + sys_recycle_bin: ['id', 'object_name', 'record_id', 'record_count', 'snapshot', 'deleted_by', 'deleted_at', 'retention_days', 'purge_after', 'organization_id'], + crm_lead: ['id', 'name'], + }; + const entry = (deletedBy: string | null) => ({ + id: 'rb-1', + object_name: 'crm_lead', + record_id: 'lead-1', + record_count: 1, + snapshot: { rows: [{ object: 'crm_lead', id: 'lead-1', values: { id: 'lead-1', name: 'Acme', scoring_notes: 'internal only' } }], clearedReferences: [] }, + deleted_by: deletedBy, + deleted_at: '2026-11-06T00:00:00.000Z', + retention_days: 30, + purge_after: '2026-12-06T00:00:00.000Z', + organization_id: 'org-1', + }); + + it('writes NO audit/activity row for the lifecycle reap of an entry', async () => { + const { engine, fire, created } = makeEngine(SCHEMA); + installAuditWriters(engine as any, 'test.audit'); + await fire('afterDelete', { + object: 'sys_recycle_bin', + input: { id: 'rb-1' }, + previous: entry('user-1'), + result: { id: 'rb-1' }, + session: { isSystem: true }, + }); + expect(created).toEqual([]); + }); + + it("writes NO audit/activity row when a user's delete clears an entry's deleted_by", async () => { + const { engine, fire, created } = makeEngine(SCHEMA); + installAuditWriters(engine as any, 'test.audit'); + await fire('afterUpdate', { + object: 'sys_recycle_bin', + input: { id: 'rb-1', deleted_by: null }, + previous: entry('user-1'), + result: entry(null), + session: { userId: 'user-admin' }, + }); + expect(created).toEqual([]); + }); + + it('still audits the business delete the entry was taken for (the skip stays narrow)', async () => { + const { engine, fire, created } = makeEngine(SCHEMA); + installAuditWriters(engine as any, 'test.audit'); + await fire('afterDelete', { + object: 'crm_lead', + input: { id: 'lead-1' }, + previous: { id: 'lead-1', name: 'Acme' }, + result: { id: 'lead-1' }, + session: { userId: 'user-1' }, + }); + expect(created.map((c) => c.object)).toEqual(['sys_audit_log', 'sys_activity']); + }); +}); + describe('audit writers — declarative trackHistory activity (ADR-0052 §5b)', () => { // crm_opportunity with a tracked select field (Stage) carrying option labels. const SCHEMA = { diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 1ac08c8b78d..7fc29b36186 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -297,6 +297,18 @@ const SKIP_OBJECTS = new Set([ // Deliberately NOT `sys_file`: those rows are mostly permanent business truth // and keep their compliance value, so they stay audited (see #5202). 'sys_upload_session', // chunked-upload progress (1 write per chunk) + // [ADR-0148 D4, ADR-0057 §3.3] The recycle bin, `lifecycle.class: + // 'transient'` and engine-owned. Its entry is written raw by the delete that + // the ledger already records, so the generic writer only ever sees the + // bookkeeping: the lifecycle sweep's reap of an entry, the `set_null` of its + // `deleted_by` when the deleting user goes, and an organization delete's + // cascade into its entries. Each of those would copy the WHOLE entry into + // `sys_audit_log` and `sys_activity`, and the entry's `snapshot` holds every + // declared column of the deleted rows, `internal: true` ones included, which + // the ledger leaves out of the business row's own delete. The snapshot is + // read only through the bin's own doors, under the live object's field-level + // security; a purge by a person is an event-specific writer's to record. + 'sys_recycle_bin', // deleted-record snapshots (reaped by TTL) 'ai_traces', // LLM trace telemetry ]);