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..3936f00f61e --- /dev/null +++ b/.changeset/30237-recycle-bin-snapshot-on-delete.md @@ -0,0 +1,29 @@ +--- +'@objectstack/objectql': minor +'@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 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. + +- **`@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 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 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 0b514226a3e..3edd2b303c5 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -813,6 +813,13 @@ 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. 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 // 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 2f00997f3ec..d71e7690816 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 { @@ -9020,9 +9024,55 @@ 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, + predicate: boolean, + ): 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, predicate); } /** @@ -9690,14 +9740,30 @@ 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. A caller's `multi: true` + // opens none, even here: the lifecycle sweep reaps by id with it. 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, options?.multi === true); + 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 4d9eea01cff..15d6b53ac8a 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 22309114581..eb3ae1979f5 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..0c7bd06abb4 --- /dev/null +++ b/packages/objectql/src/recycle-bin/capture.test.ts @@ -0,0 +1,496 @@ +// 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) { + 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 }; + 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 }, + }, +}; +/** 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') } }; + +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, 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 }; +} + +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('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); + 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 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(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); + }); + + 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("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 = { + 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..f2055c52db3 --- /dev/null +++ b/packages/objectql/src/recycle-bin/capture.ts @@ -0,0 +1,349 @@ +// 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 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` + * + * 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 { 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'; +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 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 !hasPlatformObjectPrefix(object); +} + +/** 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.deletedBy(); + 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 }, + ...(deletedBy !== undefined ? { 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 }, + ); + } + }, + }; + } + + /** + * 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 + * 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. + * + * `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 (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; + 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 5385eea2abf..285c15e30ff 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -9,8 +9,9 @@ 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, SysLifecycleState, SysMetadataActivation, SysMigration, SysMigrationJournal, SysSecret, SysTrustedKey } from './system/index.js'; +import { SysExchangeRate, SysLifecycleState, SysMetadataActivation, SysMigration, SysMigrationJournal, SysRecycleBin, SysSecret, SysTrustedKey } from './system/index.js'; /** * Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this @@ -46,7 +47,7 @@ function makeCtx() { } describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4243, #4270)', () => { - it('registers SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey and SysLifecycleState through the manifest service', async () => { + it('registers SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey, SysLifecycleState and SysRecycleBin through the manifest service', async () => { const ctx = makeCtx(); const manifests: any[] = []; ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); @@ -56,7 +57,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, SysLifecycleState]); + expect(infra.objects).toEqual([SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey, SysLifecycleState, SysRecycleBin]); expect(infra.objects.map((o: any) => o.name)).toEqual([ 'sys_migration', // ADR-0119 D2 (#4617). Registered UNCONDITIONALLY, alongside the flag @@ -76,12 +77,28 @@ describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4 // [ADR-0057] The lifecycle sweeper's clock state, read and written only // by the engine's `LifecycleService`, which reads the store it sweeps. 'sys_lifecycle_state', + // [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 six above must NOT acquire the ledger's routing triple. They + // ⛔ The seven 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(); }); + // [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/plugin.ts b/packages/platform-objects/src/plugin.ts index 3656cdb7f01..345c0953840 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -9,6 +9,7 @@ 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 { SysLifecycleState } from './system/sys-lifecycle-state.object.js'; +import { SysRecycleBin } from './system/sys-recycle-bin.object.js'; import { attestFreshDatastore } from './system/migration-flag.js'; import type { II18nService, @@ -63,6 +64,12 @@ import { SEED_SETTLEMENT_SERVICE } from '@objectstack/spec/contracts'; * re-sweeping within a minute. Its only reader and writer is the engine's * own `LifecycleService`, which treats a kernel without this object as * one that cannot say and sweeps as before. + * - **`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, @@ -209,7 +216,7 @@ export class PlatformObjectsPlugin { version: this.version, type: 'plugin', scope: 'system', - objects: [SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey, SysLifecycleState], + objects: [SysMigration, SysMigrationJournal, SysSecret, SysExchangeRate, SysTrustedKey, SysLifecycleState, 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 8e91f32b711..1a95214edd1 100644 --- a/packages/platform-objects/src/system/index.ts +++ b/packages/platform-objects/src/system/index.ts @@ -25,6 +25,8 @@ export { SysMigration } from './sys-migration.object.js'; export { SysMigrationJournal } from './sys-migration-journal.object.js'; // [ADR-0057] The lifecycle sweeper's clock state, tenant-less. export { SysLifecycleState } from './sys-lifecycle-state.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..f19a654efb3 --- /dev/null +++ b/packages/platform-objects/src/system/sys-recycle-bin.object.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field, RECYCLE_BIN_ENTRY_FIELDS } 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, 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 + * + * `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. 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 + * 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: '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', + 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: { + // 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', + 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/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index 6d5a52c47ca..06d79f83edd 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -548,6 +548,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 91547729802..5a28ecbaf90 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 // [ADR-0057 §3.3] The lifecycle sweeper's own clock state (one row per // datastore, `managedBy: 'engine-owned'`), written ONLY by objectql's 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). diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index a018f33e7d5..37ff1de18bb 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -77,6 +77,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly