From 68a4a63eac9d671be20d38d336db2aa7989db79b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:20:33 +0000 Subject: [PATCH 1/2] refactor(objectql): move five delete-cascade bodies out of engine.ts into engine-delete-cascade.ts Part of the engine.ts seam split: collectCascadeDeleteSet, refuseRestrictsInsideOwnCascade, deleteRestrictedError, referenceProbeFilter and recordReferenceCheckElevation move unedited behind DeleteCascadeHost; cascadeReachOf moves with its only reader; four shared helpers and CascadeDeleteSet are exported in place. Claude-Session: https://claude.ai/code/session_01L6bhgXPvYEKGGY6sms31q2 Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 2 +- .../objectql/src/engine-delete-cascade.ts | 429 ++++++++++++++++++ packages/objectql/src/engine.ts | 320 ++----------- 3 files changed, 472 insertions(+), 279 deletions(-) create mode 100644 packages/objectql/src/engine-delete-cascade.ts diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 54eec363455..4d9cd55326c 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -523,6 +523,7 @@ and CI refuses one that differs from what the tree produces. | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | 1 | 51 | | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict` | 1 | 55b | | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | 1 | 8 | +| `packages/objectql/src/engine-delete-cascade.ts#recordReferenceCheckElevation` | 1 | 29 | | `packages/objectql/src/engine-driver-options.ts#buildDriverOptions` | 1 | 24 | | `packages/objectql/src/engine-execution-plumbing.ts#buildSession` | 1 | 63 | | `packages/objectql/src/engine-master-detail-related.ts#resolveOneHopRelated` | 1 | 29b | @@ -534,7 +535,6 @@ and CI refuses one that differs from what the tree produces. | `packages/objectql/src/engine.ts#inheritsElevation` | 1 | 1b | | `packages/objectql/src/engine.ts#insert` | 3 | 21, 22 | | `packages/objectql/src/engine.ts#isSystem` | 1 | 64 | -| `packages/objectql/src/engine.ts#recordReferenceCheckElevation` | 1 | 29 | | `packages/objectql/src/engine.ts#update` | 4 | 19, 20 | | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | 1 | 13 | | `packages/plugins/organizations/src/organizations-plugin.ts#start` | 1 | 62 | diff --git a/packages/objectql/src/engine-delete-cascade.ts b/packages/objectql/src/engine-delete-cascade.ts new file mode 100644 index 00000000000..67c81eaa479 --- /dev/null +++ b/packages/objectql/src/engine-delete-cascade.ts @@ -0,0 +1,429 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25522 cut 19] Five bodies of the `ObjectQL` "Delete cascade and + * reference walking" section, moved out of `engine.ts`: the read-only + * collection walk that builds a cascade's delete set + * (`collectCascadeDeleteSet`), the in-cascade `restrict` judge + * (`refuseRestrictsInsideOwnCascade`), the `409 DELETE_RESTRICTED` refusal + * it and the deleting walk throw (`deleteRestrictedError`), the + * reference-probe filter (`referenceProbeFilter`) and the reference-check + * elevation record (`recordReferenceCheckElevation`). + * + * A MOVE, not a redesign. Each exported function below is the body of the + * `ObjectQL` method of the same name as it stood, re-indented one level and + * otherwise unedited. Each method's docblock stays on the method in + * `engine.ts`, which is still the contract. + * + * ## How the moved bodies reach the class + * + * The shape #23050 set for `rest-server.ts`, applied as + * `engine-count-aggregate.ts` applies it. The class keeps each method with + * name, modifiers, signature, docblock and position; its body becomes one + * literal of exactly the {@link DeleteCascadeHost} members it reads, plus a + * `.call` (`referenceProbeFilter` reads no member and is called directly). + * The one field read (`logger`) goes as a GETTER, so the read happens when + * the body makes it, as it did; no body assigns a field. The methods called go + * as late-bound closures over the instance, so a double or spy set on the + * engine is still the one a call reaches. + * + * ## What stays in the class + * + * - Every body of the section that reads a private static + * (`MAX_CASCADE_DEPTH`, `REFERENCE_PROBE_BATCH`, `storedReferenceIncludes`, + * `remainderAfterMemberRemoval`, `referenceCheckContext`, + * `outlivesOrganization`, `keepsOrganizationReference`, + * `organizationCountContext`), since a module function cannot read one, + * and the statics with them: `planCascadeAtomicity`, + * `cascadeDeleteRelations`, `dependentCountIsDisclosable`, + * `probeReferencingRows`, `probeReferencingRowsOfSet`, + * `walkReferencingRelations`, `countReferencingRowsOutside`, + * `refuseOrganizationDeleteWhileOwned` and `organizationOwnedRowCounts`. + * - `cascadeRelationBehavior`: `federated-injected-column-readers.test.ts` + * checks a `via` row by reading the helper it names in the SAME file as the + * site that calls it (`organizationOwnedRowCounts`). + * - The bodies of a few lines that would not pay for their host. + * + * ## Module helpers + * + * The bodies read four top-level `engine.ts` helpers that staying bodies + * read too: `cascadeMemberKey`, `cascadeSetAdd`, `cascadeSetHas` and + * `declaredMultiValued`, and the `CascadeDeleteSet` type. One home each: + * they stay declared in `engine.ts`, exported in place, and are imported + * here, never copied. Nothing re-exports them from the package entry, so the + * published surface does not move. `cascadeReachOf`, read only by + * `refuseRestrictsInsideOwnCascade`, moved here with it. + * + * That import is a run-time module cycle (`engine.ts` imports this module, + * this module imports `engine.ts`). Nothing here runs at module evaluation, + * only declarations, and the helpers are read only when a body runs, after + * both modules have evaluated. `engine.ts` imports this module after every + * other module this one imports, so the evaluation order of every module is + * what it was. The published build bundles both files into one, where the + * cycle does not exist. + */ + +import type { ServiceObject } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { renderOperationMessage } from '@objectstack/spec/system'; +import { resolveFieldLabel } from './validation/record-validator.js'; +import { + cascadeMemberKey, + cascadeSetAdd, + cascadeSetHas, + declaredMultiValued, +} from './engine.js'; +import type { CascadeDeleteSet, ObjectQL } from './engine.js'; + +/** + * The `ObjectQL` members the functions below read — every one of them, and no + * other; each function's `this` is the `Pick` of the members IT reads. Each is + * typed AS the class member (an indexed access on the class), so a change to a + * member's type on the class is a change here too, and naming a member the + * class does not have is a compile error. See the module header for which + * members are getters. + */ +export interface DeleteCascadeHost { + readonly logger: ObjectQL['logger']; + cascadeRelationBehavior: ObjectQL['cascadeRelationBehavior']; + fileReferenceCheckElevation: ObjectQL['fileReferenceCheckElevation']; + probeReferencingRowsOfSet: ObjectQL['probeReferencingRowsOfSet']; + deleteRestrictedError: ObjectQL['deleteRestrictedError']; + referenceProbeFilter: ObjectQL['referenceProbeFilter']; + validationMessageContext: ObjectQL['validationMessageContext']; + dependentCountIsDisclosable: ObjectQL['dependentCountIsDisclosable']; + objectDisplayLabel: ObjectQL['objectDisplayLabel']; +} + +/** + * [#26718] Every member a cascade reaches from `object`/`id` through the + * set's own {@link CascadeDeleteSet.cascadeEdges}: its details, theirs, and so + * on, indexed like {@link CascadeDeleteSet.members}. The member itself is in + * it only when a cycle in the data leads back. + */ +function cascadeReachOf(set: CascadeDeleteSet, object: string, id: unknown): Map> { + const reach = new Map>(); + const queue: Array<{ object: string; id: unknown }> = [{ object, id }]; + for (const from of queue) { + for (const next of set.cascadeEdges.get(cascadeMemberKey(from.object, from.id)) ?? []) { + if (cascadeSetAdd(reach, next.object, next.id)) queue.push(next); + } + } + return reach; +} + +/** The body of `ObjectQL#referenceProbeFilter`; the method's docblock there is its contract. */ +export function referenceProbeFilter( + fieldName: string, + fdef: { type?: unknown; multiple?: unknown }, + id: string | number, +): Record { + // [#18408] Was `fdef?.multiple !== true`. See {@link declaredMultiValued}. + if (!declaredMultiValued(fdef)) return { [fieldName]: id }; + const raw = String(id); + // The BODY of the JSON string form — `JSON.stringify('a"b')` is `"a\"b"`, + // and the quotes are the serialization's, not the id's. + const escaped = JSON.stringify(raw).slice(1, -1); + if (escaped === raw) return { [fieldName]: { $contains: raw } }; + return { + $or: [ + { [fieldName]: { $contains: raw } }, + { [fieldName]: { $contains: escaped } }, + ], + }; +} + +/** The body of `ObjectQL#recordReferenceCheckElevation`; the method's docblock there is its contract. */ +export function recordReferenceCheckElevation( + this: Pick, + object: string, + id: string | number, + childName: string, + fieldName: string, + context?: ExecutionContext, +): void { + // The triggered-by half. `userId` survives the `sudo()`-shaped elevation, + // which is what makes the two halves recordable at all; `actor` is the + // service-principal channel (the `ExecutionContext.actor` contract, + // `@objectstack/spec` kernel/execution-context.zod.ts) that keeps a + // non-user-authenticated caller attributable instead of anonymous. + const triggeredBy = + (context?.userId != null && String(context.userId)) || + (typeof (context as any)?.actor === 'string' && (context as any).actor.trim()) || + // Not "unknown": a context with neither channel IS the platform acting + // for itself (boot provisioning, seed replay, an inner cascade), and + // naming that honestly is more useful than a null. + (context?.isSystem === true ? 'system' : 'anonymous'); + this.logger.info( + `[reference-cleanup] referential integrity check on '${childName}' executed as SYSTEM ` + + `for delete of ${object}/${String(id)} triggered by ${triggeredBy}`, + { + triggeredBy, + executedAs: 'system', + object, + recordId: String(id), + referencedObject: childName, + relationField: fieldName, + }, + ); +} + +/** The body of `ObjectQL#collectCascadeDeleteSet`; the method's docblock there is its contract. */ +export async function collectCascadeDeleteSet( + this: Pick, + object: string, + ids: ReadonlyArray, + context: ExecutionContext | undefined, + objects: ServiceObject[], + opts: { fileElevations: boolean } = { fileElevations: true }, +): Promise { + const set: CascadeDeleteSet = { + members: new Map(), entered: new Map(), elevationsFiled: new Set(), cascadeEdges: new Map(), cleared: new Map(), + }; + let frontier = new Map>([[object, []]]); + for (const id of ids) { + if (cascadeSetAdd(set.members, object, id)) frontier.get(object)!.push(id); + } + // The cascading relations pointing at each object, scanned once per object + // rather than once per record: a wide cascade has many records of few + // objects. + const cascading = new Map>(); + const cascadingInto = (target: string) => { + let relations = cascading.get(target); + if (!relations) { + relations = []; + for (const child of objects) { + const childName = (child as any)?.name as string | undefined; + const fields = (child as any)?.fields as Record | undefined; + if (!childName || !fields) continue; + for (const [fieldName, fdef] of Object.entries(fields)) { + if (this.cascadeRelationBehavior(target, child, fieldName, fdef) === 'cascade') { + relations.push({ childName, fieldName, fdef }); + } + } + } + cascading.set(target, relations); + } + return relations; + }; + // Breadth-first, one depth at a time: the records each depth adds are the + // next one's targets, grouped by object. + while (frontier.size > 0) { + const next = new Map>(); + for (const [target, targetIds] of frontier) { + if (targetIds.length === 0) continue; + for (const { childName, fieldName, fdef } of cascadingInto(target)) { + if (opts.fileElevations) { + for (const id of targetIds) { + this.fileReferenceCheckElevation(set, target, id, childName, fieldName, context); + } + } + const referencing = await this.probeReferencingRowsOfSet(childName, fieldName, fdef, targetIds, context); + for (const id of targetIds) { + for (const row of referencing.get(String(id)) ?? []) { + const depId = row?.id; + if (depId == null) continue; + // [#26718] The edge is kept even for a row already in the set: a + // row reached by two paths is in the reach of both. + const from = cascadeMemberKey(target, id); + let edges = set.cascadeEdges.get(from); + if (!edges) { edges = []; set.cascadeEdges.set(from, edges); } + edges.push({ object: childName, id: depId }); + if (cascadeSetAdd(set.members, childName, depId)) { + let found = next.get(childName); + if (!found) { found = []; next.set(childName, found); } + found.push(depId); + } + } + } + } + } + frontier = next; + } + return set; +} + +/** The body of `ObjectQL#refuseRestrictsInsideOwnCascade`; the method's docblock there is its contract. */ +export async function refuseRestrictsInsideOwnCascade( + this: Pick, + cascadeSet: CascadeDeleteSet, + roots: ReadonlyArray<{ object: string; id: string | number }>, + context: ExecutionContext | undefined, + objects: ServiceObject[], +): Promise { + // The members with the ids as the rows carried them (the set's own index + // holds strings), so each probe below spells its filter as the walk does: + // the roots, then every member in the order the collection found it. + const members: Array<{ object: string; id: string | number }> = []; + const seen = new Set(); + const visit = (member: { object: string; id: string | number }) => { + const key = cascadeMemberKey(member.object, member.id); + if (seen.has(key)) return; + seen.add(key); + members.push(member); + }; + for (const root of roots) visit(root); + for (const member of members) { + for (const next of cascadeSet.cascadeEdges.get(cascadeMemberKey(member.object, member.id)) ?? []) visit(next); + } + // The restrict relations pointing at each object whose own object has a + // member, scanned once per object. + const restricting = new Map>(); + const restrictingInto = (target: string) => { + let relations = restricting.get(target); + if (!relations) { + relations = []; + for (const child of objects) { + const childName = (child as any)?.name as string | undefined; + const fields = (child as any)?.fields as Record | undefined; + if (!childName || !fields || !cascadeSet.members.has(childName)) continue; + for (const [fieldName, fdef] of Object.entries(fields)) { + if (this.cascadeRelationBehavior(target, child, fieldName, fdef) === 'restrict') { + relations.push({ child, childName, fieldName, fdef }); + } + } + } + restricting.set(target, relations); + } + return relations; + }; + // [#27004] Which member asks which relation, in the order it is judged: + // a relation is asked about a member only when its object has a member in + // that member's reach. + type Relation = { child: ServiceObject; childName: string; fieldName: string; fdef: any }; + const asks: Array<{ object: string; id: string | number; relation: Relation; reach: Map> }> = []; + const askedOf = new Map }>(); + for (const { object, id } of members) { + const relations = restrictingInto(object); + if (relations.length === 0) continue; + const reach = cascadeReachOf(cascadeSet, object, id); + for (const relation of relations) { + if (!reach.has(relation.childName)) continue; + asks.push({ object, id, relation, reach }); + let asked = askedOf.get(relation); + if (!asked) { asked = { object, ids: [] }; askedOf.set(relation, asked); } + asked.ids.push(id); + } + } + // One probe per relation per batch of the members asking it. + const referencing = new Map>(); + for (const [relation, { object, ids }] of askedOf) { + const { childName, fieldName, fdef } = relation; + for (const id of ids) this.fileReferenceCheckElevation(cascadeSet, object, id, childName, fieldName, context); + referencing.set(relation, await this.probeReferencingRowsOfSet(childName, fieldName, fdef, ids, context)); + } + for (const { object, id, relation, reach } of asks) { + const { child, childName, fieldName, fdef } = relation; + const rows = referencing.get(relation)!.get(String(id)) ?? []; + const dependents = rows.filter((row) => cascadeSetHas(reach, childName, row?.id)); + if (dependents.length === 0) continue; + throw await this.deleteRestrictedError({ + object, id, child, childName, fieldName, fdef, dependents, + probeWhere: this.referenceProbeFilter(fieldName, fdef, id), + probedIds: rows.map((r: any) => String(r?.id)), + multiValued: declaredMultiValued(fdef), + objects, context, + }); + } +} + +/** The body of `ObjectQL#deleteRestrictedError`; the method's docblock there is its contract. */ +export async function deleteRestrictedError( + this: Pick, + args: { + object: string; + id: string | number; + child: ServiceObject; + childName: string; + fieldName: string; + fdef: any; + dependents: readonly any[]; + probeWhere: Record; + probedIds: readonly string[]; + multiValued: boolean; + objects: ServiceObject[]; + context: ExecutionContext | undefined; + }, +): Promise { + const { + object, id, child, childName, fieldName, fdef, dependents, probeWhere, probedIds, multiValued, objects, context, + } = args; + // [#7307] TWO messages, two audiences — because this error has two + // and they were sharing one string. + // + // `message` is what a BUSINESS USER reads: REST ships it verbatim as + // `body.error` in the flat 409 envelope (`mapDataError`), and Console + // renders that as-is in a toast. It was composed English-only with the + // API names concatenated in and a metadata-authoring instruction on + // the end, so an operator deleting a 部门 in a fully Chinese app got an + // English sentence naming two tables and a column they have never + // seen, ending in advice only a developer can act on. It is now + // rendered through the operation-message catalog in the caller's + // locale against resolved LABELS — the same fix #3957 made one layer + // down for field constraints, reached from the operation side. + // + // `developerMessage` is what the DEVELOPER reads, and is the previous + // sentence unchanged, byte for byte: English, API names, and the + // `deleteBehavior:'cascade'` hint — which is correct and useful, and + // is not lost, it is addressed. It rides the structured half of the + // envelope alongside `dependentObject` / `dependentCount`, which + // already carry API names, so it discloses nothing new; no + // user-facing surface reads it. + // + // The wire code does NOT split: one `DELETE_RESTRICTED` (ADR-0112), + // two SENTENCES, exactly as the field catalog splits a message key + // without splitting `FieldErrorCode`. + const required = fdef.deleteBehavior !== 'restrict' && fdef.required === true; + const msgCtx = this.validationMessageContext(object, context); + const parent = objects.find((o) => (o as any)?.name === object); + // [#12166, ruling constraint 2] Whether this refusal may carry the + // row COUNT — see `dependentCountIsDisclosable` for why the elevation + // above is what makes the question exist, and why the answer is "only + // if the caller's own identity would have produced the same rows". + // The OBJECT is named either way: that half is what the ruling + // requires the caller to learn. + const discloseCount = await this.dependentCountIsDisclosable( + childName, probeWhere, probedIds, fieldName, multiValued, id, context, + ); + const err: any = new Error( + renderOperationMessage( + { + messageKey: discloseCount + ? (required ? 'delete_restricted_required' : 'delete_restricted') + : (required ? 'delete_restricted_required_opaque' : 'delete_restricted_opaque'), + params: { + object: this.objectDisplayLabel(object, (parent as any)?.label, msgCtx), + dependentObject: this.objectDisplayLabel(childName, (child as any)?.label, msgCtx), + field: resolveFieldLabel(fieldName, fdef, { ...msgCtx, objectName: childName }), + ...(discloseCount ? { count: dependents.length } : {}), + }, + }, + { locale: msgCtx.locale, translate: msgCtx.translate }, + ), + ); + // The DEVELOPER half is subject to the same rule, and the reason is + // measured rather than assumed: for `DELETE_RESTRICTED` (unlike the + // #7414 permission denial) this sentence RIDES THE ENVELOPE — REST's + // `mapDataError` ships it — so a count withheld from `message` and + // left here would not be withheld at all. + err.developerMessage = + `Cannot delete ${object} (${id}): ` + + `${discloseCount ? `${dependents.length} dependent ${childName} record(s) reference it` : `dependent ${childName} record(s) reference it`} via ${fieldName}` + + `${required ? ` (${fieldName} is required, so it cannot be cleared)` : ''}. ` + + `Delete or reassign them first, or set deleteBehavior:'cascade' on ${childName}.${fieldName}.`; + err.code = 'DELETE_RESTRICTED'; + err.status = 409; + err.httpStatus = 409; + err.object = object; + // Constraint 2's REQUIRED half — the referenced object is named + // unconditionally, because "which table is blocking me" is the one + // thing the reporting deployment's admins could not self-diagnose. + err.dependentObject = childName; + // …and its withheld half. Absent rather than zeroed: `0` would be a + // false statement about the rows (there is at least one, or this + // branch would not have been reached), and REST's envelope builder + // already omits the key for a non-number, so an absent count reaches + // the client as an absent key rather than as a lie. + if (discloseCount) err.dependentCount = dependents.length; + return err; +} diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 50a504332d7..a5687d70a2f 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -90,7 +90,6 @@ import { DATA_MIGRATION_FLAG_OBJECT, FILE_REFERENCES_MIGRATION_ID, isDataMigrationFlagVerified, - renderOperationMessage, SystemFieldName, } from '@objectstack/spec/system'; // [#25522 cut 1] The validation-message localisation helpers; the methods of the @@ -333,7 +332,7 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container' import { viewContainerNameRefusal } from './view-container-name-refusal.js'; import { bindHooksToEngine, type BindHooksOptions } from './hook-binder.js'; import { declaredHookCondition } from './hook-wrappers.js'; -import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, type FieldValidationError, resolveFieldLabel, isScannableValueShapeField } from './validation/record-validator.js'; +import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, type FieldValidationError, isScannableValueShapeField } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js'; import { createBatchSummaryAccumulator } from './summary-batch-accumulation.js'; @@ -381,6 +380,13 @@ import { composeReadableIdsChain, findUnservableControlledByParentMasters, permi // [#25522 cut 16] Type-only, for the `{@link}`s on `composeReadableIdsAtBoundary`'s and // `composeReadableIdsChain`'s docblocks: the constant moved with its reader in cut 15. import type { READABLE_IDS_MAX_CHAIN_DEPTH } from './engine-readable-ids-permission-formula.js'; +// [#25522 cut 19] The delete cascade's collection walk, the in-cascade +// `restrict` judge, the `DELETE_RESTRICTED` refusal, the reference-probe +// filter and the reference-check elevation record; the methods of the same +// names below hand each body the members it reads (`DeleteCascadeHost`). +// Imported after every module those bodies import, so the engine's module +// evaluation order is what it was. +import { collectCascadeDeleteSet, deleteRestrictedError, recordReferenceCheckElevation, referenceProbeFilter, refuseRestrictsInsideOwnCascade } from './engine-delete-cascade.js'; import { applyInMemoryAggregation } from './in-memory-aggregation.js'; import { resolveEngineDeleteDispatch, @@ -1659,7 +1665,7 @@ function formulaRoundingScale(def: { scale?: unknown } | undefined): number | un * `MULTI_OPTION_TYPES` membership written out at a site would be a second * answer to a question the ruling gave exactly one. */ -function declaredMultiValued(field: { type?: unknown; multiple?: unknown } | null | undefined): boolean { +export function declaredMultiValued(field: { type?: unknown; multiple?: unknown } | null | undefined): boolean { return isMultiValueField({ type: typeof field?.type === 'string' ? field.type : '', multiple: field?.multiple === true, @@ -4549,7 +4555,7 @@ export interface DatasourceDef { * input can name a record into it, and a caller that passed no context still * passes none. */ -interface CascadeDeleteSet { +export interface CascadeDeleteSet { /** * Object name → ids the cascade deletes, the root included. Collected by a * read-only walk across every CASCADING relation before any write, so it is @@ -4589,34 +4595,17 @@ interface CascadeDeleteSet { } /** [#26718] One member of a {@link CascadeDeleteSet}, as a single key. */ -function cascadeMemberKey(object: string, id: unknown): string { +export function cascadeMemberKey(object: string, id: unknown): string { return JSON.stringify([object, String(id)]); } -/** - * [#26718] Every member a cascade reaches from `object`/`id` through the - * set's own {@link CascadeDeleteSet.cascadeEdges}: its details, theirs, and so - * on, indexed like {@link CascadeDeleteSet.members}. The member itself is in - * it only when a cycle in the data leads back. - */ -function cascadeReachOf(set: CascadeDeleteSet, object: string, id: unknown): Map> { - const reach = new Map>(); - const queue: Array<{ object: string; id: unknown }> = [{ object, id }]; - for (const from of queue) { - for (const next of set.cascadeEdges.get(cascadeMemberKey(from.object, from.id)) ?? []) { - if (cascadeSetAdd(reach, next.object, next.id)) queue.push(next); - } - } - return reach; -} - /** Is `id` of `object` in this index of a {@link CascadeDeleteSet}? */ -function cascadeSetHas(index: Map>, object: string, id: unknown): boolean { +export function cascadeSetHas(index: Map>, object: string, id: unknown): boolean { return id != null && index.get(object)?.has(String(id)) === true; } /** Add `id` of `object`; `true` when it was not there yet. */ -function cascadeSetAdd(index: Map>, object: string, id: unknown): boolean { +export function cascadeSetAdd(index: Map>, object: string, id: unknown): boolean { let ids = index.get(object); if (!ids) { ids = new Set(); index.set(object, ids); } const key = String(id); @@ -17968,19 +17957,8 @@ export class ObjectQL implements IObjectQLEngine { fdef: { type?: unknown; multiple?: unknown }, id: string | number, ): Record { - // [#18408] Was `fdef?.multiple !== true`. See {@link declaredMultiValued}. - if (!declaredMultiValued(fdef)) return { [fieldName]: id }; - const raw = String(id); - // The BODY of the JSON string form — `JSON.stringify('a"b')` is `"a\"b"`, - // and the quotes are the serialization's, not the id's. - const escaped = JSON.stringify(raw).slice(1, -1); - if (escaped === raw) return { [fieldName]: { $contains: raw } }; - return { - $or: [ - { [fieldName]: { $contains: raw } }, - { [fieldName]: { $contains: escaped } }, - ], - }; + // [#25522 cut 19] The body moved to `engine-delete-cascade.ts`. + return referenceProbeFilter(fieldName, fdef, id); } /** @@ -18113,30 +18091,12 @@ export class ObjectQL implements IObjectQLEngine { fieldName: string, context?: ExecutionContext, ): void { - // The triggered-by half. `userId` survives the `sudo()`-shaped elevation, - // which is what makes the two halves recordable at all; `actor` is the - // service-principal channel (the `ExecutionContext.actor` contract, - // `@objectstack/spec` kernel/execution-context.zod.ts) that keeps a - // non-user-authenticated caller attributable instead of anonymous. - const triggeredBy = - (context?.userId != null && String(context.userId)) || - (typeof (context as any)?.actor === 'string' && (context as any).actor.trim()) || - // Not "unknown": a context with neither channel IS the platform acting - // for itself (boot provisioning, seed replay, an inner cascade), and - // naming that honestly is more useful than a null. - (context?.isSystem === true ? 'system' : 'anonymous'); - this.logger.info( - `[reference-cleanup] referential integrity check on '${childName}' executed as SYSTEM ` + - `for delete of ${object}/${String(id)} triggered by ${triggeredBy}`, - { - triggeredBy, - executedAs: 'system', - object, - recordId: String(id), - referencedObject: childName, - relationField: fieldName, - }, - ); + // [#25522 cut 19] The body moved to `engine-delete-cascade.ts` (see `DeleteCascadeHost`). + const readLogger = () => this.logger; + const host: ThisParameterType = { + get logger() { return readLogger(); }, + }; + return recordReferenceCheckElevation.call(host, object, id, childName, fieldName, context); } /** @@ -18404,70 +18364,13 @@ export class ObjectQL implements IObjectQLEngine { objects: ServiceObject[], opts: { fileElevations: boolean } = { fileElevations: true }, ): Promise { - const set: CascadeDeleteSet = { - members: new Map(), entered: new Map(), elevationsFiled: new Set(), cascadeEdges: new Map(), cleared: new Map(), + // [#25522 cut 19] The body moved to `engine-delete-cascade.ts` (see `DeleteCascadeHost`). + const host: ThisParameterType = { + cascadeRelationBehavior: (...a) => this.cascadeRelationBehavior(...a), + fileReferenceCheckElevation: (...a) => this.fileReferenceCheckElevation(...a), + probeReferencingRowsOfSet: (...a) => this.probeReferencingRowsOfSet(...a), }; - let frontier = new Map>([[object, []]]); - for (const id of ids) { - if (cascadeSetAdd(set.members, object, id)) frontier.get(object)!.push(id); - } - // The cascading relations pointing at each object, scanned once per object - // rather than once per record: a wide cascade has many records of few - // objects. - const cascading = new Map>(); - const cascadingInto = (target: string) => { - let relations = cascading.get(target); - if (!relations) { - relations = []; - for (const child of objects) { - const childName = (child as any)?.name as string | undefined; - const fields = (child as any)?.fields as Record | undefined; - if (!childName || !fields) continue; - for (const [fieldName, fdef] of Object.entries(fields)) { - if (this.cascadeRelationBehavior(target, child, fieldName, fdef) === 'cascade') { - relations.push({ childName, fieldName, fdef }); - } - } - } - cascading.set(target, relations); - } - return relations; - }; - // Breadth-first, one depth at a time: the records each depth adds are the - // next one's targets, grouped by object. - while (frontier.size > 0) { - const next = new Map>(); - for (const [target, targetIds] of frontier) { - if (targetIds.length === 0) continue; - for (const { childName, fieldName, fdef } of cascadingInto(target)) { - if (opts.fileElevations) { - for (const id of targetIds) { - this.fileReferenceCheckElevation(set, target, id, childName, fieldName, context); - } - } - const referencing = await this.probeReferencingRowsOfSet(childName, fieldName, fdef, targetIds, context); - for (const id of targetIds) { - for (const row of referencing.get(String(id)) ?? []) { - const depId = row?.id; - if (depId == null) continue; - // [#26718] The edge is kept even for a row already in the set: a - // row reached by two paths is in the reach of both. - const from = cascadeMemberKey(target, id); - let edges = set.cascadeEdges.get(from); - if (!edges) { edges = []; set.cascadeEdges.set(from, edges); } - edges.push({ object: childName, id: depId }); - if (cascadeSetAdd(set.members, childName, depId)) { - let found = next.get(childName); - if (!found) { found = []; next.set(childName, found); } - found.push(depId); - } - } - } - } - } - frontier = next; - } - return set; + return collectCascadeDeleteSet.call(host, object, ids, context, objects, opts); } /** @@ -19149,80 +19052,15 @@ export class ObjectQL implements IObjectQLEngine { context: ExecutionContext | undefined, objects: ServiceObject[], ): Promise { - // The members with the ids as the rows carried them (the set's own index - // holds strings), so each probe below spells its filter as the walk does: - // the roots, then every member in the order the collection found it. - const members: Array<{ object: string; id: string | number }> = []; - const seen = new Set(); - const visit = (member: { object: string; id: string | number }) => { - const key = cascadeMemberKey(member.object, member.id); - if (seen.has(key)) return; - seen.add(key); - members.push(member); - }; - for (const root of roots) visit(root); - for (const member of members) { - for (const next of cascadeSet.cascadeEdges.get(cascadeMemberKey(member.object, member.id)) ?? []) visit(next); - } - // The restrict relations pointing at each object whose own object has a - // member, scanned once per object. - const restricting = new Map>(); - const restrictingInto = (target: string) => { - let relations = restricting.get(target); - if (!relations) { - relations = []; - for (const child of objects) { - const childName = (child as any)?.name as string | undefined; - const fields = (child as any)?.fields as Record | undefined; - if (!childName || !fields || !cascadeSet.members.has(childName)) continue; - for (const [fieldName, fdef] of Object.entries(fields)) { - if (this.cascadeRelationBehavior(target, child, fieldName, fdef) === 'restrict') { - relations.push({ child, childName, fieldName, fdef }); - } - } - } - restricting.set(target, relations); - } - return relations; + // [#25522 cut 19] The body moved to `engine-delete-cascade.ts` (see `DeleteCascadeHost`). + const host: ThisParameterType = { + cascadeRelationBehavior: (...a) => this.cascadeRelationBehavior(...a), + fileReferenceCheckElevation: (...a) => this.fileReferenceCheckElevation(...a), + probeReferencingRowsOfSet: (...a) => this.probeReferencingRowsOfSet(...a), + deleteRestrictedError: (...a) => this.deleteRestrictedError(...a), + referenceProbeFilter: (...a) => this.referenceProbeFilter(...a), }; - // [#27004] Which member asks which relation, in the order it is judged: - // a relation is asked about a member only when its object has a member in - // that member's reach. - type Relation = { child: ServiceObject; childName: string; fieldName: string; fdef: any }; - const asks: Array<{ object: string; id: string | number; relation: Relation; reach: Map> }> = []; - const askedOf = new Map }>(); - for (const { object, id } of members) { - const relations = restrictingInto(object); - if (relations.length === 0) continue; - const reach = cascadeReachOf(cascadeSet, object, id); - for (const relation of relations) { - if (!reach.has(relation.childName)) continue; - asks.push({ object, id, relation, reach }); - let asked = askedOf.get(relation); - if (!asked) { asked = { object, ids: [] }; askedOf.set(relation, asked); } - asked.ids.push(id); - } - } - // One probe per relation per batch of the members asking it. - const referencing = new Map>(); - for (const [relation, { object, ids }] of askedOf) { - const { childName, fieldName, fdef } = relation; - for (const id of ids) this.fileReferenceCheckElevation(cascadeSet, object, id, childName, fieldName, context); - referencing.set(relation, await this.probeReferencingRowsOfSet(childName, fieldName, fdef, ids, context)); - } - for (const { object, id, relation, reach } of asks) { - const { child, childName, fieldName, fdef } = relation; - const rows = referencing.get(relation)!.get(String(id)) ?? []; - const dependents = rows.filter((row) => cascadeSetHas(reach, childName, row?.id)); - if (dependents.length === 0) continue; - throw await this.deleteRestrictedError({ - object, id, child, childName, fieldName, fdef, dependents, - probeWhere: this.referenceProbeFilter(fieldName, fdef, id), - probedIds: rows.map((r: any) => String(r?.id)), - multiValued: declaredMultiValued(fdef), - objects, context, - }); - } + return refuseRestrictsInsideOwnCascade.call(host, cascadeSet, roots, context, objects); } /** @@ -19246,87 +19084,13 @@ export class ObjectQL implements IObjectQLEngine { objects: ServiceObject[]; context: ExecutionContext | undefined; }): Promise { - const { - object, id, child, childName, fieldName, fdef, dependents, probeWhere, probedIds, multiValued, objects, context, - } = args; - // [#7307] TWO messages, two audiences — because this error has two - // and they were sharing one string. - // - // `message` is what a BUSINESS USER reads: REST ships it verbatim as - // `body.error` in the flat 409 envelope (`mapDataError`), and Console - // renders that as-is in a toast. It was composed English-only with the - // API names concatenated in and a metadata-authoring instruction on - // the end, so an operator deleting a 部门 in a fully Chinese app got an - // English sentence naming two tables and a column they have never - // seen, ending in advice only a developer can act on. It is now - // rendered through the operation-message catalog in the caller's - // locale against resolved LABELS — the same fix #3957 made one layer - // down for field constraints, reached from the operation side. - // - // `developerMessage` is what the DEVELOPER reads, and is the previous - // sentence unchanged, byte for byte: English, API names, and the - // `deleteBehavior:'cascade'` hint — which is correct and useful, and - // is not lost, it is addressed. It rides the structured half of the - // envelope alongside `dependentObject` / `dependentCount`, which - // already carry API names, so it discloses nothing new; no - // user-facing surface reads it. - // - // The wire code does NOT split: one `DELETE_RESTRICTED` (ADR-0112), - // two SENTENCES, exactly as the field catalog splits a message key - // without splitting `FieldErrorCode`. - const required = fdef.deleteBehavior !== 'restrict' && fdef.required === true; - const msgCtx = this.validationMessageContext(object, context); - const parent = objects.find((o) => (o as any)?.name === object); - // [#12166, ruling constraint 2] Whether this refusal may carry the - // row COUNT — see `dependentCountIsDisclosable` for why the elevation - // above is what makes the question exist, and why the answer is "only - // if the caller's own identity would have produced the same rows". - // The OBJECT is named either way: that half is what the ruling - // requires the caller to learn. - const discloseCount = await this.dependentCountIsDisclosable( - childName, probeWhere, probedIds, fieldName, multiValued, id, context, - ); - const err: any = new Error( - renderOperationMessage( - { - messageKey: discloseCount - ? (required ? 'delete_restricted_required' : 'delete_restricted') - : (required ? 'delete_restricted_required_opaque' : 'delete_restricted_opaque'), - params: { - object: this.objectDisplayLabel(object, (parent as any)?.label, msgCtx), - dependentObject: this.objectDisplayLabel(childName, (child as any)?.label, msgCtx), - field: resolveFieldLabel(fieldName, fdef, { ...msgCtx, objectName: childName }), - ...(discloseCount ? { count: dependents.length } : {}), - }, - }, - { locale: msgCtx.locale, translate: msgCtx.translate }, - ), - ); - // The DEVELOPER half is subject to the same rule, and the reason is - // measured rather than assumed: for `DELETE_RESTRICTED` (unlike the - // #7414 permission denial) this sentence RIDES THE ENVELOPE — REST's - // `mapDataError` ships it — so a count withheld from `message` and - // left here would not be withheld at all. - err.developerMessage = - `Cannot delete ${object} (${id}): ` + - `${discloseCount ? `${dependents.length} dependent ${childName} record(s) reference it` : `dependent ${childName} record(s) reference it`} via ${fieldName}` + - `${required ? ` (${fieldName} is required, so it cannot be cleared)` : ''}. ` + - `Delete or reassign them first, or set deleteBehavior:'cascade' on ${childName}.${fieldName}.`; - err.code = 'DELETE_RESTRICTED'; - err.status = 409; - err.httpStatus = 409; - err.object = object; - // Constraint 2's REQUIRED half — the referenced object is named - // unconditionally, because "which table is blocking me" is the one - // thing the reporting deployment's admins could not self-diagnose. - err.dependentObject = childName; - // …and its withheld half. Absent rather than zeroed: `0` would be a - // false statement about the rows (there is at least one, or this - // branch would not have been reached), and REST's envelope builder - // already omits the key for a non-number, so an absent count reaches - // the client as an absent key rather than as a lie. - if (discloseCount) err.dependentCount = dependents.length; - return err; + // [#25522 cut 19] The body moved to `engine-delete-cascade.ts` (see `DeleteCascadeHost`). + const host: ThisParameterType = { + validationMessageContext: (...a) => this.validationMessageContext(...a), + dependentCountIsDisclosable: (...a) => this.dependentCountIsDisclosable(...a), + objectDisplayLabel: (...a) => this.objectDisplayLabel(...a), + }; + return deleteRestrictedError.call(host, args); } /** From b3a0abd0f6304407b0b162eb6f44662f234aeeeb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:27:26 +0000 Subject: [PATCH 2/2] docs(qa): re-point the delete-restricted checklist citation at engine-delete-cascade.ts The `required` local it names moved with deleteRestrictedError. Claude-Session: https://claude.ai/code/session_01L6bhgXPvYEKGGY6sms31q2 Co-authored-by: Claude --- docs/qa/platform-checklist/areas/records-forms.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 068000b9ca8..11379f0ea84 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -5001,7 +5001,7 @@ }, "source": [ "packages/spec/src/data/field.zod.ts#deleteBehavior (deleteBehavior inline enum; #9689 parse rejection, #9784 default materialization)", - "packages/objectql/src/engine.ts#required (defaults, coercion + [cascade-delete] log, required-FK escalation incl. #9688 multi-value deferral), (DELETE_RESTRICTED envelope, #12166 count disclosure, cascade recursion)", + "packages/objectql/src/engine-delete-cascade.ts#required (defaults, coercion + [cascade-delete] log, required-FK escalation incl. #9688 multi-value deferral), (DELETE_RESTRICTED envelope, #12166 count disclosure, cascade recursion)", "examples/app-showcase/src/data/objects/invoice.object.ts#InvoiceLine (its invoice master_detail, deleteBehavior 'cascade') + examples/app-showcase/src/data/objects/invoice.object.ts#Invoice (its required account lookup, the required-FK escalation); contact.object.ts; field-zoo.object.ts; expense-report.object.ts (the four stock arms)" ], "history": [