From 305271ef26e91898f24810301acd97063cdb18e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:29:37 +0000 Subject: [PATCH] refactor(objectql): move the validate body out of engine.ts into engine-validate.ts The write preview's body moves unedited into a new module behind ValidateHost; the method keeps its name, modifiers, signature, docblock and position, and hands the body the members it reads. Six helpers the body shares with insert/update gain export in place and are imported back, never copied. The preview parity pin reads the preview's calls from the new module, and the system-context site register follows the moved isSystem read. Two staying docblocks stop linking the constant the previous cut moved. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5oafXXU7gvgcHxdZb9 --- content/docs/permissions/system-context.mdx | 2 +- .../engine-readable-ids-permission-formula.ts | 2 +- packages/objectql/src/engine-validate.ts | 581 ++++++++++++++++++ packages/objectql/src/engine.ts | 519 ++-------------- .../src/validate-preview-parent.test.ts | 32 +- 5 files changed, 643 insertions(+), 493 deletions(-) create mode 100644 packages/objectql/src/engine-validate.ts diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b54d045f235..c01db1f6258 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -529,13 +529,13 @@ and CI refuses one that differs from what the tree produces. | `packages/objectql/src/engine-referential-integrity.ts#assertReferencesResolve` | 1 | 23 | | `packages/objectql/src/engine-referential-integrity.ts#masterDetailHeadersCallerReads` | 1 | 29c | | `packages/objectql/src/engine-secret-internal-fields.ts#stripSearchCompanionFromRead` | 1 | 27 | +| `packages/objectql/src/engine-validate.ts#validate` | 1 | 21b | | `packages/objectql/src/engine.ts#dependentCountIsDisclosable` | 1 | 28 | | `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/objectql/src/engine.ts#validate` | 1 | 21b | | `packages/observability/src/perf-timing.ts#isPerfDisclosurePrincipal` | 1 | 13 | | `packages/plugins/organizations/src/organizations-plugin.ts#start` | 1 | 62 | | `packages/plugins/plugin-approvals/src/approval-service.ts#comment` | 1 | 43 | diff --git a/packages/objectql/src/engine-readable-ids-permission-formula.ts b/packages/objectql/src/engine-readable-ids-permission-formula.ts index 099ea596128..a9e688aa27c 100644 --- a/packages/objectql/src/engine-readable-ids-permission-formula.ts +++ b/packages/objectql/src/engine-readable-ids-permission-formula.ts @@ -109,7 +109,7 @@ export interface ReadableIdsPermissionFormulaHost { * the read is REFUSED, loud (D5's envelope), ⛔ never composed partway and * never answered "no restriction". */ -const READABLE_IDS_MAX_CHAIN_DEPTH = 8; +export const READABLE_IDS_MAX_CHAIN_DEPTH = 8; /** The body of `ObjectQL#composeReadableIdsChain`; the method's docblock there is its contract. */ export async function composeReadableIdsChain( diff --git a/packages/objectql/src/engine-validate.ts b/packages/objectql/src/engine-validate.ts new file mode 100644 index 00000000000..fe5f5e60280 --- /dev/null +++ b/packages/objectql/src/engine-validate.ts @@ -0,0 +1,581 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25522 cut 16] The body of `ObjectQL#validate`, the write preview (run the + * write path's own verdict over candidate rows and report it, persisting + * nothing), moved out of `engine.ts`. + * + * A MOVE, not a redesign. The exported function below is the body of the + * `ObjectQL` method of the same name as it stood, re-indented one level and + * otherwise unedited. The method's docblock stays on the method in + * `engine.ts`, which is still the contract. + * + * ## How the moved body reaches the class + * + * The shape #23050 set for `rest-server.ts`, applied as + * `engine-driver-options.ts` applies it. The class keeps `validate` with name, + * modifiers, signature, docblock and position; its body becomes one literal of + * exactly the {@link ValidateHost} members it reads, plus a `.call`. The fields + * it reads (`_registry`, `_writeWallVerdict`, `logger`, `_writeGateProbe`) go + * as GETTERS, so each read happens when the body makes it, as it did; the body + * assigns no field. The methods it calls go as late-bound closures over the + * instance, so a double or spy set on the engine is still the one a call + * reaches. + * + * ## Module helpers + * + * Seven helpers are read by this body AND by bodies that stay: + * `undeclaredWriteFieldErrors` and `stripComputedWriteFields` (`insert`, + * `update`), `droppedFieldEvents` (`insert`), `insertStaticReadonlySubject` + * (`insert`, `withholdInsertReadonlyFromHooks`), `updateStaticReadonlySubject` + * (`update`, `staticReadonlyStoredView`), `shouldSkipStateMachine` (`insert`, + * `update`) and `servedAsStored` (`engine-master-detail-related.ts`). One home + * each: they stay declared in `engine.ts`, exported in place (`servedAsStored` + * already was), and are imported here, never copied. Nothing re-exports them + * from the package entry, so the published surface does not move. + * + * 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 `engine.ts` imports this module after every module + * this one imports, so the engine's evaluation order is what it was. The + * published build bundles both files into one, where the cycle does not + * exist. + */ + +import { RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/api'; +import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; +import { validateRecordInScope, normalizeBlankTypedValues, normalizeNumericStringValues, ValidationError, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; +import { evaluateValidationRules, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields } from './validation/rule-validator.js'; +import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from './engine-update-dispatch.js'; +import { + droppedFieldEvents, + insertStaticReadonlySubject, + servedAsStored, + shouldSkipStateMachine, + stripComputedWriteFields, + undeclaredWriteFieldErrors, + updateStaticReadonlySubject, +} from './engine.js'; +import type { ObjectQL, WriteWallRowVerdict } 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, and which one also has a setter. + */ +export interface ValidateHost { + readonly _registry: ObjectQL['_registry']; + readonly _writeWallVerdict: ObjectQL['_writeWallVerdict']; + readonly logger: ObjectQL['logger']; + readonly _writeGateProbe: ObjectQL['_writeGateProbe']; + resolveObjectName: ObjectQL['resolveObjectName']; + permissionResolution: ObjectQL['permissionResolution']; + resolveDefaultPermissions: ObjectQL['resolveDefaultPermissions']; + initializeSummaryFields: ObjectQL['initializeSummaryFields']; + applyFieldDefaults: ObjectQL['applyFieldDefaults']; + staticReadonlyCreateStrip: ObjectQL['staticReadonlyCreateStrip']; + mediaValueShapeStrictFor: ObjectQL['mediaValueShapeStrictFor']; + valueShapeStrictFor: ObjectQL['valueShapeStrictFor']; + validationMessageContext: ObjectQL['validationMessageContext']; + buildEvalUser: ObjectQL['buildEvalUser']; + findOne: ObjectQL['findOne']; + readUpdatePriorRow: ObjectQL['readUpdatePriorRow']; + resolvePreviewParents: ObjectQL['resolvePreviewParents']; + resolvePredicateRelated: ObjectQL['resolvePredicateRelated']; + resolveOptionPermissions: ObjectQL['resolveOptionPermissions']; +} + +/** The body of `ObjectQL#validate`; the method's docblock there is its contract. */ +export async function validate( + this: Pick, + object: string, + data: Record | Record[], + options?: { + mode?: 'insert' | 'update'; + context?: ExecutionContext; + /** + * [#20805] The write's own strip listener: called once per `reason` for + * the fields the write WOULD drop from these rows — the same events, in + * the same shape, that `insert` / `update` report through + * `WriteObservabilityOptions.onFieldsDropped`. A listener that throws is + * logged and ignored, as on the write. + */ + onFieldsDropped?: WriteObservabilityOptions['onFieldsDropped']; + }, +): Promise { + object = this.resolveObjectName(object); + const mode = options?.mode ?? 'insert'; + const schemaForValidation = this._registry.getObject(object); + + // [#4633] `insert()` resolves `defaultValue`s and seeds owned roll-up + // `summary` fields BEFORE it validates, so a required field carrying a + // default is never missing by the time `validateRecord` runs. The preview + // has to walk the same two steps or it reports `required` on a row the + // write happily creates — a FALSE ALARM, and the one failure mode the + // ruling that created this operation set out to prevent. (Measured on the + // import dry run: `tier: { required: true, defaultValue: 'standard' }` + // unmapped ⇒ preview `failed`, write `created`.) + // + // Both helpers are pure and synchronous: they read the registry, copy the + // row, and touch neither driver nor hook — so running them here keeps the + // "nothing is WRITTEN" contract intact. + // + // ⚠️ "Nothing is executed" is no longer literally true and must not be + // restated as if it were: a traversing validation rule needs its related + // rows, so this operation issues a READ per reference field the rules name + // (see the `previewRelatedForRow` block below), and [#22445] an + // `update`-mode row that carries its address issues two READS of the + // stored row — the read door under the caller's own context, then the + // write's own prior-row read (the `storedRows` block below), and [#22474] + // an object with a parent-scoped `requiredWhen` issues two READS of the + // master-detail headers its rows name, the same two doors in the same + // order (the `previewParents` block below). Nothing is + // written, no write hook runs (a read through the read door fires its read + // hooks, as the related read always has), and the + // RELATED read happens only for a caller who passes the arms the + // write-gate probe RUNS — ⛔ never a category of the write decision, and + // ⛔ not a promise the write would succeed (`registerWriteGateProbe` names + // those arms, and the families it does not carry). + // `update()` deliberately does not default (#2706: a PATCH's explicit + // `null` means "clear it"), so neither does an `update`-mode preview. + // [#20308] The write doors read a blank on a non-string-typed column as + // `null` before anything else; the preview does the same at the same point, + // or a blank on a required field with a `defaultValue` would preview + // `required` while the write takes the default. [#20309] Likewise a + // numeric string on a number field is its number here, as on the write. + const submittedRows = normalizeNumericStringValues( + schemaForValidation, + normalizeBlankTypedValues(schemaForValidation, Array.isArray(data) ? data : [data]), + ); + // [#20805] THE WRITE'S DOORS, run here by the same functions and in the + // write's order, so a dry run predicts what the write will do rather than + // judging a payload the write never stores: + // 1. the declared-field door (`undeclaredWriteFieldErrors`) — an + // undeclared key is refused exactly as `insert()` / `update()` refuse + // it: thrown, `INVALID_FIELD` / 400, naming the field. It used to be + // answered `valid: true` here while the write refused the row. + // 2. the computed-field door (`stripComputedWriteFields`) — a `formula` + // value is taken out and reported under `computed`, in every context. + // 3. the caller-write strips (below, after the defaults, where the write + // runs them) — reported under `readonly`. + // Reported through `options.onFieldsDropped`, the listener the write + // reports through. + const refusal = undeclaredWriteFieldErrors( + object, + schemaForValidation as { fields?: unknown } | undefined, + submittedRows, + ).find((e) => e !== undefined); + if (refusal) throw refusal; + const computedStrip = stripComputedWriteFields( + schemaForValidation as { fields?: unknown } | undefined, + submittedRows, + ); + const rawRows = computedStrip.rows as Record[]; + const nowSnapshot = new Date(); + // [#20082] The preview's ONE permission resolution, shared by its CEL + // defaults and its option gates below, exactly as the write shares one. A + // resolution failure rejects the preview for a row whose `can` default needed + // it, as it fails the write. + const permissionResolution = this.permissionResolution(options?.context); + const defaultPermissionsFor = mode === 'insert' + ? await this.resolveDefaultPermissions(object, rawRows, options?.context, permissionResolution) + : () => undefined; + const rows: Record[] = mode === 'insert' + ? rawRows.map((row) => this.initializeSummaryFields( + object, + this.applyFieldDefaults(object, row, options?.context, nowSnapshot, defaultPermissionsFor(row)), + ) as Record) + : rawRows.slice(); + + // [#25062] THE LAYER 0 TENANT WALL'S VERDICT on each row, from the layer + // that owns the wall ({@link registerWriteWallVerdict}), over the rows as + // the preview holds them: after the defaults, before any hook. The write + // asks its wall twice, and so does this: on the payload before the strips + // (here; the write's middleware), and on the rows the strips leave (below; + // the write's stored-image seam). A refused row is answered with the + // write's refusal (the `results` block below) and reaches nothing the write + // does not run for it; an admitted row whose organization the wall judges + // keeps the column through the static `readonly` strip, as `insert()` / + // `update()` keep it. `null` (nothing registered, or the wall not asked) + // leaves every row to the strip, exactly as before. ⛔ Nothing here reads + // the posture or the context's organizations: the verdict is the wall's + // own, read as a value. + const askWriteWall = async (asked: Record[]) => { + if (!this._writeWallVerdict || asked.length === 0) return null; + const verdicts = await this._writeWallVerdict(object, mode, options?.context, asked); + if (verdicts && verdicts.length !== asked.length) { + throw new Error( + `[ObjectQL] the write-wall verdict answered ${verdicts.length} verdict(s) for ` + + `${asked.length} row(s) of '${object}'`, + ); + } + return verdicts; + }; + const refusalIn = (verdict: WriteWallRowVerdict | undefined) => + verdict && verdict.admitted === false ? verdict : undefined; + const wallVerdicts = await askWriteWall(rows); + const wallRefusals = rows.map((_, i) => refusalIn(wallVerdicts?.[i])); + const wallRefusalOf = (i: number) => wallRefusals[i]; + const wallJudgesOrganizationOf = (i: number): boolean => { + const verdict = wallVerdicts?.[i]; + return verdict?.admitted === true && verdict.judgesOrganization === true; + }; + const heldBeforeStrips = rows.slice(); + + // [#20805] 3. The caller-write strips, over the defaulted rows with the + // caller's rows as `supplied` — where the write runs them (after the + // defaults; the write's hooks, which this preview does not run, sit in + // between) and under the write's `isSystem` gate. The same functions: + // on `insert`, `stripRuntimeOwnedFields` and then + // `staticReadonlyCreateStrip` (the static `readonly` strip with its + // re-default); on `update`, `stripReadonlyFields`, which covers both on + // that verb. No logger: a preview writes nothing, and a strip line says + // the value was not stored. + // + // ⚠️ On `update`, a supplied `id` is read as the address the write binds — + // every update door folds the target id into the payload (`updateData`) — + // and is never judged as a payload key (#8093, ADDRESSING IS NOT PAYLOAD). + // [#22445] That address now names the stored row the rules judge (the + // `storedRows` block below). `readonlyWhen` and the primary-key strip are + // still not run: they remain the named limit `ValidateDataResponse` + // states, so the preview can report fewer drops than the update. + // + // [#20922] Each strip's result is recorded twice at the strip itself: into + // the batch-level union the listener reports (unchanged), and into the + // taking row's own list, which becomes that row's `droppedFields` below. + const readonlyDropped: string[] = []; + const readonlyDroppedPerRow: string[][] = rows.map(() => []); + const recordTaken = (i: number, taken: readonly string[]): void => { + for (const k of taken) { + if (!readonlyDropped.includes(k)) readonlyDropped.push(k); + if (!readonlyDroppedPerRow[i]!.includes(k)) readonlyDroppedPerRow[i]!.push(k); + } + }; + const collectTaken = (i: number, before: Record, after: Record): void => { + recordTaken(i, Object.keys(before).filter((k) => !(k in after))); + }; + if (!options?.context?.isSystem) { + const preserveAudit = options?.context?.preserveAudit === true; + if (mode === 'insert') { + for (let i = 0; i < rows.length; i++) { + if (wallRefusalOf(i)) continue; + const stripped = stripRuntimeOwnedFields( + schemaForValidation as any, rows[i], rawRows[i] ?? {}, undefined, { preserveAudit }, + ) as Record; + if (stripped === rows[i]) continue; + collectTaken(i, rows[i]!, stripped); + rows[i] = stripped; + } + // [#25062] The subject per row, as the write's strip judges that row: + // without `organization_id` where the wall judges it + // (`insertStaticReadonlySubject`, the write's own view). + for (let i = 0; i < rows.length; i++) { + if (wallRefusalOf(i)) continue; + const readonlySubject = insertStaticReadonlySubject(schemaForValidation, wallJudgesOrganizationOf(i)); + if (!readonlySubject) continue; + const pass = await this.staticReadonlyCreateStrip( + object, readonlySubject, rows[i]!, rawRows[i] ?? {}, options?.context, + { logger: undefined, strictReadonlyWrites: false, hookWrittenKeys: undefined, nowSnap: nowSnapshot, permissionResolution }, + ); + recordTaken(i, pass.taken); + // The write fails on this resolution failure outside partial mode; + // a preview has no partial mode, so it fails the same way. + if (pass.error !== undefined) throw pass.error; + rows[i] = pass.row; + } + } else { + for (let i = 0; i < rows.length; i++) { + if (wallRefusalOf(i)) continue; + const supplied: Record = { ...(rawRows[i] ?? {}) }; + delete supplied.id; + // [#25062] The update's own view (`updateStaticReadonlySubject`): + // the whole schema, without `organization_id` where the wall judges it. + const stripped = stripReadonlyFields( + updateStaticReadonlySubject(schemaForValidation, wallJudgesOrganizationOf(i)) as any, + rows[i], supplied, undefined, { preserveAudit }, + ) as Record; + if (stripped === rows[i]) continue; + collectTaken(i, rows[i]!, stripped); + rows[i] = stripped; + } + } + } + // [#25062] …and the wall's STORED-IMAGE half, on each row the strips + // changed: the row as the write would store it, which the wall has not + // seen. Where the wall does not judge the organization (a platform + // operator crossing it with no active organization), the strip takes a + // caller's organization and the stored row has nowhere to land; the write + // refuses it there, after its strips, and so does this. A row the strips + // left alone was answered above. Only a refusal is read here: the spare + // was decided on the first answer, as the write decides it before its + // strips. + const restripped = rows.flatMap((row, i) => (!wallRefusals[i] && row !== heldBeforeStrips[i] ? [i] : [])); + const storedImageVerdicts = await askWriteWall(restripped.map((i) => rows[i]!)); + restripped.forEach((rowIndex, k) => { + wallRefusals[rowIndex] = refusalIn(storedImageVerdicts?.[k]); + }); + + const onFieldsDropped = options?.onFieldsDropped; + if (typeof onFieldsDropped === 'function') { + // [#25062] A row the wall refuses drops nothing: the write that refuses + // it reports no drop. So the batch union is the admitted rows' alone. + const admittedTook = (perRow: readonly (readonly string[])[], key: string) => + perRow.some((taken, i) => !wallRefusals[i] && taken.includes(key)); + const drops = droppedFieldEvents( + object, + computedStrip.dropped.filter((key) => admittedTook(computedStrip.droppedPerRow, key)), + readonlyDropped.filter((key) => admittedTook(readonlyDroppedPerRow, key)), + ); + for (const drop of drops) { + try { + onFieldsDropped(drop); + } catch (err) { + this.logger.warn('onFieldsDropped listener threw — ignored', { object, error: err }); + } + } + } + + // Resolved once for the whole set, exactly as the write path resolves them + // once per batch — this is the "same posture as the real write" guarantee. + const mediaValueShapeStrict = await this.mediaValueShapeStrictFor(schemaForValidation); + const valueShapeStrict = await this.valueShapeStrictFor(schemaForValidation); + const messages = this.validationMessageContext(object, options?.context); + const currentUser = this.buildEvalUser(options?.context); + const skipStateMachine = shouldSkipStateMachine(options?.context); + + // [#22445] THE STORED ROW an `update`-mode row is judged against — the + // by-id update's prior row, read here for the same reason the write reads + // it: a rule that reads a column the patch omits judges the stored value, + // so the rules' record is the stored row merged with the patch and their + // `previous` is the stored row (`evaluateValidationRules` below, called as + // the by-id branch of `update()` calls it). + // + // The address is the write's own: `resolveEngineUpdateDispatch`, the + // ladder `update()` resolves its by-id branch with, asked of the row as + // submitted. Two reads, each answering one question: + // + // 1. ⛔ MAY THIS CALLER SEE THE ROW — the READ DOOR, under the caller's + // own context (`findOne`). The by-id write reads its row raw only after + // its middleware's write gates, which answer 404 for a row the caller + // cannot read; the preview runs none of them. So a row the read door + // does not return is a missing row here too: no image, no verdict about + // its columns. A refused read propagates as raised. + // 2. WHAT IS STORED — the write's own prior-row read + // ({@link readUpdatePriorRow}), so the rules judge the row as STORED, + // exactly as the write does, and not the read door's served image (a + // formula the read door evaluates has no stored column, and the write + // judges it absent). + // + // ⛔ A stored column is kept ONLY where the read door served this caller + // that very value ({@link servedAsStored}): the caller can already read it + // IN FULL. Every other column is left out and judged as empty — one the + // read door hid (field-level security deletes the key), and one it served + // TRANSFORMED: a partial mask, which keeps the key and replaces the value, + // a masked secret, an `internal` column, a read hook's rewrite. A verdict + // over a masked column's stored value is the probe the security plugin + // refuses on a query (a partially masked field is as probe-able as a + // hidden one), and the preview runs none of the write's gates, so it must + // not answer one. So the verdict never depends on a value this caller + // could not have read. The read door is asked for file fields in their + // stored form (`RAW_FILE_VALUES_CONTEXT_KEY`, the declared opt-out for a + // caller whose subject is the stored form), so a readable file reference + // compares as the id it is rather than as the expanded object. + // + // `undefined` = no stored row: the row is judged on the patch alone, as + // it was before these reads existed. + const storedRows: Array | undefined> = []; + for (const submitted of submittedRows) { + let stored: Record | undefined; + if (mode === 'update' && submitted && typeof submitted === 'object') { + const dispatch = resolveEngineUpdateDispatch(submitted as EngineUpdateDispatchData, undefined); + if (dispatch.kind === 'by-id') { + const visible = await this.findOne(object, { + where: { id: dispatch.id }, + context: { ...(options?.context ?? {}), [RAW_FILE_VALUES_CONTEXT_KEY]: true }, + } as EngineQueryOptions); + const prior = visible && typeof visible === 'object' + ? await this.readUpdatePriorRow(object, dispatch.id, options?.context) + : null; + if (visible && prior) { + const served = visible as Record; + stored = {}; + for (const [key, value] of Object.entries(prior)) { + if (Object.prototype.hasOwnProperty.call(served, key) && servedAsStored(served[key], value)) { + stored[key] = value; + } + } + } + } + } + storedRows.push(stored); + } + // The merged view each rule evaluates, and the one the reference reads + // below key on: the write reads a traversed FK off the same merge + // (`updateView` in `update()`), so a FK the patch omits resolves from the + // stored row. + const judgedViews = rows.map((row, i) => (storedRows[i] ? { ...storedRows[i], ...row } : row)); + + // [#22474] THE MASTER-DETAIL HEADER a field `requiredWhen` reads as + // `parent`, bound as the write binds it and under the write's own gate: + // only an object that declares a parent-scoped `requiredWhen` reads a + // header (`hasParentScopedRequiredWhen`, the insert's gate and the + // update's `requiredWhen` half), so every other object's preview reads + // none. The header is named by the judged view, the write's + // payload-FK-first reading (`masterIdOf`): the row's own FK on an insert, + // and on an update the patch's FK, else the stored row's. A repointing + // update also binds the header the stored row hangs off as + // `previousParent`, the header the write's ADR-0113 pre-check reads. + // + // ⛔ Read under the caller's access, as the write reads it since #22519: + // see {@link resolvePreviewParents}. A header the caller cannot read binds + // as a missing header binds; unlike the write, a header column the read + // door did not serve this caller as stored is judged as empty. + // + // Named limit: the update's `readonlyWhen` judgement of the FK itself + // (`settleMasterDetailLanding` ①) is not run, the limit `readonlyWhen` + // already holds in this preview, so a repoint that lock would take back + // out is judged against the header it names. + const previewParents = hasParentScopedRequiredWhen(schemaForValidation as any) + ? await this.resolvePreviewParents(schemaForValidation, judgedViews, storedRows, options?.context) + : undefined; + + // [#18682] The preview owes the SAME relationship resolution the real write + // does. Without it a rule that reads one hop through a reference field + // reports `valid: false` (unevaluable) against a row `insert()` happily + // accepts — the false alarm this operation was created to prevent, and the + // import dry run rides on it. + // + // Resolved once for the whole set, like every other posture input above. + // [#22445] The named limit that sat here — an `update`-mode preview read + // no prior row, so a traversing rule whose FK the PATCH did not carry had + // no id to resolve — is CLOSED for a row that carries its address: the FK + // is read off `judgedViews`, the stored row merged with the patch. A row + // with no address (or no stored row this caller can read) still resolves + // only the FKs its patch carries. + // [#20805] The second named limit that used to sit here is CLOSED: a + // reference field the author declared static `readonly` is stripped from a + // non-system caller's payload by the write, so the write resolves no + // related row for it — and since the caller-write strips run above, the + // preview's `rows` have lost it too, and a traversing rule answers the + // same way on both. + // ⛔ Behind the caller's own create/update gate — see + // {@link registerWriteGateProbe}. A caller the probe refuses gets NO + // elevated read: `related` stays unresolved, and a traversing rule then + // refuses, which is the fail-closed direction and is honest about what it + // did not evaluate. + // ⛔ `submittedRows`, not `rows`: the gate's field-level arm judges WHICH + // FIELDS THE CALLER WROTE, and `rows` has already been through the strips, + // `applyFieldDefaults` / `initializeSummaryFields` above. Handing it the + // defaulted image would offer the plugin keys the caller never sent — the + // exact reading the middleware avoids by gating on `opCtx.data`, which is + // the raw payload (defaults are resolved inside the executor, under it). + // [#20805] Nor the stripped `rawRows`: the middleware runs BEFORE the + // write's computed-field door, so it judges the caller's keys as sent. + const mayWrite = this._writeGateProbe + ? await this._writeGateProbe(object, mode, options?.context, submittedRows).catch(() => false) + : true; + const previewRelatedForRow = mayWrite + ? await this.resolvePredicateRelated(schemaForValidation, judgedViews, options?.context) + : () => undefined; + // [#18783] The preview answers a `can`-gated option with the SAME map the + // write would — resolved once for the whole set, like every posture input + // above. A resolution failure rejects the preview, as it fails the write. + const previewPermissionsFor = await this.resolveOptionPermissions(schemaForValidation, rows, permissionResolution); + + const results: NonNullable = rows.map((row, i) => { + const warnings: ValidateDataIssue[] = []; + // Warn-first admissions are the posture signal the caller came for, so + // they are reported — into this row's own bucket, never into the + // certification sink (see the note above). + // + // Carried under the code the STRICT path would have FAILED with + // (`invalid_type`) rather than a warning-specific one: the same bad + // value is one finding that changed buckets when the posture changed, + // and a caller diffing a preview against a write should see that, not + // two vocabularies. The sink's payload is `{gate, field, type, detail}`, + // so the message is composed the way the warn-first log line composes it. + const onAdmittedValueShapeViolation = (violation: any) => { + const field = String(violation?.field ?? ''); + const type = String(violation?.type ?? ''); + const detail = String(violation?.detail ?? 'invalid value shape'); + warnings.push({ + field, + code: 'invalid_type', + message: `${field} has an invalid ${type} value: ${detail}`, + }); + }; + // [#25062] A row the tenant wall refuses is answered with the write's + // refusal, which the write gives before it validates anything. + const wallRefusal = wallRefusalOf(i); + if (wallRefusal) { + return { + valid: false, + errors: [{ field: wallRefusal.field, code: wallRefusal.code, message: wallRefusal.message }], + warnings, + }; + } + try { + // [#21663] `'include'` in both modes: the caller-write strips ran + // above, so this is the payload the write stores — and the write + // judges its readonly values' shape (insert in the same call, update + // in a second pass after its own strip). + validateRecordInScope(schemaForValidation, row, mode, 'include', { + mediaValueShapeStrict, valueShapeStrict, messages, onAdmittedValueShapeViolation, + // [#22183] The option values an import kept for this write — the + // preview admits exactly what the write would. + keptOptionValues: options?.context?.keptOptionValues, + }); + warnings.push(...evaluateValidationRules(schemaForValidation as any, row, mode, { + // [#22445] The stored row, when this row named one: the by-id + // update's `previous: priorRecord`. Absent, the record is the patch. + ...(storedRows[i] ? { previous: storedRows[i] } : {}), + logger: this.logger, currentUser, skipStateMachine, messages, + // [#22474] The header the write binds: the insert's + // `insertParentForRow`, the update's landing header and, for a + // repoint, `roWhenPreviousParent`. + parent: previewParents?.parents[i], + previousParent: previewParents?.previousParents[i], + related: previewRelatedForRow(judgedViews[i]), + permissions: previewPermissionsFor(row), + })); + } catch (e) { + if (e instanceof ValidationError) { + return { valid: false, errors: e.fields.map((f) => ({ ...f })), warnings }; + } + throw e; + } + // [#20922] THIS row's drops, from the strips' own per-row record above + // — the report `insertMany` hangs on the same row's `ok` outcome, so a + // dry run and its commit answer one row in one vocabulary. Only on a + // row the verdict accepts: a drop is "the write completed without + // them", and the write refuses an invalid row, so it drops nothing + // (`insertMany` answers that row `ok: false`, with no drops). Absent + // when nothing would be taken. + const droppedFields = droppedFieldEvents(object, computedStrip.droppedPerRow[i]!, readonlyDroppedPerRow[i]!); + return { valid: true, errors: [], warnings, ...(droppedFields.length > 0 ? { droppedFields } : {}) }; + }); + + return { + object, + mode, + valid: results.every((r) => r.valid), + results, + // The EFFECTIVE posture, not the raw deployment flag. `validateRecord` + // runs the flag through `valueShapeStrictEffective`, where the ADR-0104 + // env switches take precedence over it, so reporting the flag would + // describe a different posture than the one that just decided the + // verdict — on a deployment holding the flag but running with + // `OS_ALLOW_LAX_VALUE_SHAPES`, exactly backwards. Reporting what decided + // is the whole point of returning it. + posture: { + valueShapeStrict: valueShapeStrictEffective(valueShapeStrict), + mediaValueShapeStrict: mediaStrictEffective(mediaValueShapeStrict), + }, + }; +} diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 19a84bece0b..535b38e25b6 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -25,11 +25,11 @@ import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; // The validate-only result IS the protocol's response shape (commit 18189983d): the // engine is what `metadata-protocol.validateData` returns, so letting the two // drift would put a translation layer between a verdict and its contract. -import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/api'; +import type { ValidateDataResponse } from '@objectstack/spec/api'; // [#22646] The ADR-0112 catalog — `rejectCredentialAggregation`'s refusal // envelope carries `INVALID_FIELD` from it rather than a bare `Error`. import { StandardErrorCode } from '@objectstack/spec/api'; -import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, referenceCarrierOf, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken, isMultiValueField, driverSupportsTransactions, AUDIT_PROVENANCE_FIELDS, resolveInjectedColumnProvenance } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readAutonumberCounter, missingFieldValues, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, referenceCarrierOf, isCurrentUserDefaultToken, isNowDefaultToken, isMultiValueField, driverSupportsTransactions, AUDIT_PROVENANCE_FIELDS, resolveInjectedColumnProvenance } from '@objectstack/spec/data'; // [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1) // runs, so `FilterArray` has exactly one lowering in the product. import { @@ -321,7 +321,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, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; +import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, type FieldValidationError, resolveFieldLabel, 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 { collectReferenceTraversals, referenceTraversalConflicts, evaluateValidationRules, emitValidationAdvisories, optionVisibilityReadsPermissions, readsPermissionPredicate, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; @@ -365,6 +365,9 @@ import { resolveOneHopRelated, resolvePredicateRelated, resolvePreviewParents, s // (`ReadableIdsPermissionFormulaHost`). Imported after every module those bodies import, so // the engine's module evaluation order is what it was. import { composeReadableIdsChain, findUnservableControlledByParentMasters, permissionResolution, readableIdsScopeOf, reportFormulaFault, resolveDefaultPermissions, resolveFormulaPermissions, resolveOptionPermissions } from './engine-readable-ids-permission-formula.js'; +// [#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'; import { applyInMemoryAggregation } from './in-memory-aggregation.js'; import { resolveEngineDeleteDispatch, @@ -382,6 +385,10 @@ import { type EngineUpdateDispatchData, type EngineUpdateDispatchInput, } from './engine-update-dispatch.js'; +// [#25522 cut 16] The write preview's body; `validate` below hands it the +// members it reads (`ValidateHost`). Imported after every module that body +// imports, so the engine's module evaluation order is what it was. +import { validate } from './engine-validate.js'; import { applyHaving, aggregatedRowColumns, @@ -2203,7 +2210,7 @@ function emptySearchSetError(object: string, schema: any, internalExcluded: bool * because the partial-success path (`insertMany`) reports per row and must * cull the bad rows instead of failing the batch around them. */ -function undeclaredWriteFieldErrors( +export function undeclaredWriteFieldErrors( object: string, schema: { fields?: unknown } | undefined, rows: readonly unknown[], @@ -2289,7 +2296,7 @@ function undeclaredWriteFieldErrors( * ways: the per-row lists are what {@link droppedFieldEvents} attributes * to a row on `validate` and on `insertMany`'s outcomes. */ -function stripComputedWriteFields( +export function stripComputedWriteFields( schema: { fields?: unknown } | undefined, rows: readonly unknown[], refused?: readonly unknown[], @@ -2335,7 +2342,7 @@ function stripComputedWriteFields( * union: "which rows supplied N" is not "which rows dropped N" once a hook can * exempt a key on one row and not another. */ -function droppedFieldEvents( +export function droppedFieldEvents( object: string, computed: readonly string[], readonly: readonly string[], @@ -2463,7 +2470,7 @@ function staticReadonlySparesOrganization(schema: unknown, wallJudgesOrganizatio * and the post-hook strip, so the hook view and the stored row cannot * disagree about the column. [#25062] The write preview's strip reads it too. */ -function insertStaticReadonlySubject( +export function insertStaticReadonlySubject( schema: unknown, wallJudgesOrganization: boolean, ): ReturnType { @@ -2490,7 +2497,7 @@ function insertStaticReadonlySubject( * hook view and the stored row cannot disagree about the column. [#25062] The * `update`-mode preview's strip reads it too. */ -function updateStaticReadonlySubject(schema: unknown, wallJudgesOrganization: boolean): unknown { +export function updateStaticReadonlySubject(schema: unknown, wallJudgesOrganization: boolean): unknown { if (!staticReadonlySparesOrganization(schema, wallJudgesOrganization)) return schema; const { name, fields } = schema as { name?: string; fields: Record }; const kept = { ...fields }; @@ -4017,7 +4024,7 @@ function mergeReadContext( * for a "historical" import) turns it off. Both are server-set, never * client-supplied. */ -function shouldSkipStateMachine(ctx?: ExecutionContext): boolean { +export function shouldSkipStateMachine(ctx?: ExecutionContext): boolean { return ctx?.seedReplay === true || ctx?.skipStateMachine === true; } @@ -14142,475 +14149,33 @@ export class ObjectQL implements IObjectQLEngine { onFieldsDropped?: WriteObservabilityOptions['onFieldsDropped']; }, ): Promise { - object = this.resolveObjectName(object); - const mode = options?.mode ?? 'insert'; - const schemaForValidation = this._registry.getObject(object); - - // [#4633] `insert()` resolves `defaultValue`s and seeds owned roll-up - // `summary` fields BEFORE it validates, so a required field carrying a - // default is never missing by the time `validateRecord` runs. The preview - // has to walk the same two steps or it reports `required` on a row the - // write happily creates — a FALSE ALARM, and the one failure mode the - // ruling that created this operation set out to prevent. (Measured on the - // import dry run: `tier: { required: true, defaultValue: 'standard' }` - // unmapped ⇒ preview `failed`, write `created`.) - // - // Both helpers are pure and synchronous: they read the registry, copy the - // row, and touch neither driver nor hook — so running them here keeps the - // "nothing is WRITTEN" contract intact. - // - // ⚠️ "Nothing is executed" is no longer literally true and must not be - // restated as if it were: a traversing validation rule needs its related - // rows, so this operation issues a READ per reference field the rules name - // (see the `previewRelatedForRow` block below), and [#22445] an - // `update`-mode row that carries its address issues two READS of the - // stored row — the read door under the caller's own context, then the - // write's own prior-row read (the `storedRows` block below), and [#22474] - // an object with a parent-scoped `requiredWhen` issues two READS of the - // master-detail headers its rows name, the same two doors in the same - // order (the `previewParents` block below). Nothing is - // written, no write hook runs (a read through the read door fires its read - // hooks, as the related read always has), and the - // RELATED read happens only for a caller who passes the arms the - // write-gate probe RUNS — ⛔ never a category of the write decision, and - // ⛔ not a promise the write would succeed (`registerWriteGateProbe` names - // those arms, and the families it does not carry). - // `update()` deliberately does not default (#2706: a PATCH's explicit - // `null` means "clear it"), so neither does an `update`-mode preview. - // [#20308] The write doors read a blank on a non-string-typed column as - // `null` before anything else; the preview does the same at the same point, - // or a blank on a required field with a `defaultValue` would preview - // `required` while the write takes the default. [#20309] Likewise a - // numeric string on a number field is its number here, as on the write. - const submittedRows = normalizeNumericStringValues( - schemaForValidation, - normalizeBlankTypedValues(schemaForValidation, Array.isArray(data) ? data : [data]), - ); - // [#20805] THE WRITE'S DOORS, run here by the same functions and in the - // write's order, so a dry run predicts what the write will do rather than - // judging a payload the write never stores: - // 1. the declared-field door (`undeclaredWriteFieldErrors`) — an - // undeclared key is refused exactly as `insert()` / `update()` refuse - // it: thrown, `INVALID_FIELD` / 400, naming the field. It used to be - // answered `valid: true` here while the write refused the row. - // 2. the computed-field door (`stripComputedWriteFields`) — a `formula` - // value is taken out and reported under `computed`, in every context. - // 3. the caller-write strips (below, after the defaults, where the write - // runs them) — reported under `readonly`. - // Reported through `options.onFieldsDropped`, the listener the write - // reports through. - const refusal = undeclaredWriteFieldErrors( - object, - schemaForValidation as { fields?: unknown } | undefined, - submittedRows, - ).find((e) => e !== undefined); - if (refusal) throw refusal; - const computedStrip = stripComputedWriteFields( - schemaForValidation as { fields?: unknown } | undefined, - submittedRows, - ); - const rawRows = computedStrip.rows as Record[]; - const nowSnapshot = new Date(); - // [#20082] The preview's ONE permission resolution, shared by its CEL - // defaults and its option gates below, exactly as the write shares one. A - // resolution failure rejects the preview for a row whose `can` default needed - // it, as it fails the write. - const permissionResolution = this.permissionResolution(options?.context); - const defaultPermissionsFor = mode === 'insert' - ? await this.resolveDefaultPermissions(object, rawRows, options?.context, permissionResolution) - : () => undefined; - const rows: Record[] = mode === 'insert' - ? rawRows.map((row) => this.initializeSummaryFields( - object, - this.applyFieldDefaults(object, row, options?.context, nowSnapshot, defaultPermissionsFor(row)), - ) as Record) - : rawRows.slice(); - - // [#25062] THE LAYER 0 TENANT WALL'S VERDICT on each row, from the layer - // that owns the wall ({@link registerWriteWallVerdict}), over the rows as - // the preview holds them: after the defaults, before any hook. The write - // asks its wall twice, and so does this: on the payload before the strips - // (here; the write's middleware), and on the rows the strips leave (below; - // the write's stored-image seam). A refused row is answered with the - // write's refusal (the `results` block below) and reaches nothing the write - // does not run for it; an admitted row whose organization the wall judges - // keeps the column through the static `readonly` strip, as `insert()` / - // `update()` keep it. `null` (nothing registered, or the wall not asked) - // leaves every row to the strip, exactly as before. ⛔ Nothing here reads - // the posture or the context's organizations: the verdict is the wall's - // own, read as a value. - const askWriteWall = async (asked: Record[]) => { - if (!this._writeWallVerdict || asked.length === 0) return null; - const verdicts = await this._writeWallVerdict(object, mode, options?.context, asked); - if (verdicts && verdicts.length !== asked.length) { - throw new Error( - `[ObjectQL] the write-wall verdict answered ${verdicts.length} verdict(s) for ` + - `${asked.length} row(s) of '${object}'`, - ); - } - return verdicts; - }; - const refusalIn = (verdict: WriteWallRowVerdict | undefined) => - verdict && verdict.admitted === false ? verdict : undefined; - const wallVerdicts = await askWriteWall(rows); - const wallRefusals = rows.map((_, i) => refusalIn(wallVerdicts?.[i])); - const wallRefusalOf = (i: number) => wallRefusals[i]; - const wallJudgesOrganizationOf = (i: number): boolean => { - const verdict = wallVerdicts?.[i]; - return verdict?.admitted === true && verdict.judgesOrganization === true; - }; - const heldBeforeStrips = rows.slice(); - - // [#20805] 3. The caller-write strips, over the defaulted rows with the - // caller's rows as `supplied` — where the write runs them (after the - // defaults; the write's hooks, which this preview does not run, sit in - // between) and under the write's `isSystem` gate. The same functions: - // on `insert`, `stripRuntimeOwnedFields` and then - // `staticReadonlyCreateStrip` (the static `readonly` strip with its - // re-default); on `update`, `stripReadonlyFields`, which covers both on - // that verb. No logger: a preview writes nothing, and a strip line says - // the value was not stored. - // - // ⚠️ On `update`, a supplied `id` is read as the address the write binds — - // every update door folds the target id into the payload (`updateData`) — - // and is never judged as a payload key (#8093, ADDRESSING IS NOT PAYLOAD). - // [#22445] That address now names the stored row the rules judge (the - // `storedRows` block below). `readonlyWhen` and the primary-key strip are - // still not run: they remain the named limit `ValidateDataResponse` - // states, so the preview can report fewer drops than the update. - // - // [#20922] Each strip's result is recorded twice at the strip itself: into - // the batch-level union the listener reports (unchanged), and into the - // taking row's own list, which becomes that row's `droppedFields` below. - const readonlyDropped: string[] = []; - const readonlyDroppedPerRow: string[][] = rows.map(() => []); - const recordTaken = (i: number, taken: readonly string[]): void => { - for (const k of taken) { - if (!readonlyDropped.includes(k)) readonlyDropped.push(k); - if (!readonlyDroppedPerRow[i]!.includes(k)) readonlyDroppedPerRow[i]!.push(k); - } - }; - const collectTaken = (i: number, before: Record, after: Record): void => { - recordTaken(i, Object.keys(before).filter((k) => !(k in after))); - }; - if (!options?.context?.isSystem) { - const preserveAudit = options?.context?.preserveAudit === true; - if (mode === 'insert') { - for (let i = 0; i < rows.length; i++) { - if (wallRefusalOf(i)) continue; - const stripped = stripRuntimeOwnedFields( - schemaForValidation as any, rows[i], rawRows[i] ?? {}, undefined, { preserveAudit }, - ) as Record; - if (stripped === rows[i]) continue; - collectTaken(i, rows[i]!, stripped); - rows[i] = stripped; - } - // [#25062] The subject per row, as the write's strip judges that row: - // without `organization_id` where the wall judges it - // (`insertStaticReadonlySubject`, the write's own view). - for (let i = 0; i < rows.length; i++) { - if (wallRefusalOf(i)) continue; - const readonlySubject = insertStaticReadonlySubject(schemaForValidation, wallJudgesOrganizationOf(i)); - if (!readonlySubject) continue; - const pass = await this.staticReadonlyCreateStrip( - object, readonlySubject, rows[i]!, rawRows[i] ?? {}, options?.context, - { logger: undefined, strictReadonlyWrites: false, hookWrittenKeys: undefined, nowSnap: nowSnapshot, permissionResolution }, - ); - recordTaken(i, pass.taken); - // The write fails on this resolution failure outside partial mode; - // a preview has no partial mode, so it fails the same way. - if (pass.error !== undefined) throw pass.error; - rows[i] = pass.row; - } - } else { - for (let i = 0; i < rows.length; i++) { - if (wallRefusalOf(i)) continue; - const supplied: Record = { ...(rawRows[i] ?? {}) }; - delete supplied.id; - // [#25062] The update's own view (`updateStaticReadonlySubject`): - // the whole schema, without `organization_id` where the wall judges it. - const stripped = stripReadonlyFields( - updateStaticReadonlySubject(schemaForValidation, wallJudgesOrganizationOf(i)) as any, - rows[i], supplied, undefined, { preserveAudit }, - ) as Record; - if (stripped === rows[i]) continue; - collectTaken(i, rows[i]!, stripped); - rows[i] = stripped; - } - } - } - // [#25062] …and the wall's STORED-IMAGE half, on each row the strips - // changed: the row as the write would store it, which the wall has not - // seen. Where the wall does not judge the organization (a platform - // operator crossing it with no active organization), the strip takes a - // caller's organization and the stored row has nowhere to land; the write - // refuses it there, after its strips, and so does this. A row the strips - // left alone was answered above. Only a refusal is read here: the spare - // was decided on the first answer, as the write decides it before its - // strips. - const restripped = rows.flatMap((row, i) => (!wallRefusals[i] && row !== heldBeforeStrips[i] ? [i] : [])); - const storedImageVerdicts = await askWriteWall(restripped.map((i) => rows[i]!)); - restripped.forEach((rowIndex, k) => { - wallRefusals[rowIndex] = refusalIn(storedImageVerdicts?.[k]); - }); - - const onFieldsDropped = options?.onFieldsDropped; - if (typeof onFieldsDropped === 'function') { - // [#25062] A row the wall refuses drops nothing: the write that refuses - // it reports no drop. So the batch union is the admitted rows' alone. - const admittedTook = (perRow: readonly (readonly string[])[], key: string) => - perRow.some((taken, i) => !wallRefusals[i] && taken.includes(key)); - const drops = droppedFieldEvents( - object, - computedStrip.dropped.filter((key) => admittedTook(computedStrip.droppedPerRow, key)), - readonlyDropped.filter((key) => admittedTook(readonlyDroppedPerRow, key)), - ); - for (const drop of drops) { - try { - onFieldsDropped(drop); - } catch (err) { - this.logger.warn('onFieldsDropped listener threw — ignored', { object, error: err }); - } - } - } - - // Resolved once for the whole set, exactly as the write path resolves them - // once per batch — this is the "same posture as the real write" guarantee. - const mediaValueShapeStrict = await this.mediaValueShapeStrictFor(schemaForValidation); - const valueShapeStrict = await this.valueShapeStrictFor(schemaForValidation); - const messages = this.validationMessageContext(object, options?.context); - const currentUser = this.buildEvalUser(options?.context); - const skipStateMachine = shouldSkipStateMachine(options?.context); - - // [#22445] THE STORED ROW an `update`-mode row is judged against — the - // by-id update's prior row, read here for the same reason the write reads - // it: a rule that reads a column the patch omits judges the stored value, - // so the rules' record is the stored row merged with the patch and their - // `previous` is the stored row (`evaluateValidationRules` below, called as - // the by-id branch of `update()` calls it). - // - // The address is the write's own: `resolveEngineUpdateDispatch`, the - // ladder `update()` resolves its by-id branch with, asked of the row as - // submitted. Two reads, each answering one question: - // - // 1. ⛔ MAY THIS CALLER SEE THE ROW — the READ DOOR, under the caller's - // own context (`findOne`). The by-id write reads its row raw only after - // its middleware's write gates, which answer 404 for a row the caller - // cannot read; the preview runs none of them. So a row the read door - // does not return is a missing row here too: no image, no verdict about - // its columns. A refused read propagates as raised. - // 2. WHAT IS STORED — the write's own prior-row read - // ({@link readUpdatePriorRow}), so the rules judge the row as STORED, - // exactly as the write does, and not the read door's served image (a - // formula the read door evaluates has no stored column, and the write - // judges it absent). - // - // ⛔ A stored column is kept ONLY where the read door served this caller - // that very value ({@link servedAsStored}): the caller can already read it - // IN FULL. Every other column is left out and judged as empty — one the - // read door hid (field-level security deletes the key), and one it served - // TRANSFORMED: a partial mask, which keeps the key and replaces the value, - // a masked secret, an `internal` column, a read hook's rewrite. A verdict - // over a masked column's stored value is the probe the security plugin - // refuses on a query (a partially masked field is as probe-able as a - // hidden one), and the preview runs none of the write's gates, so it must - // not answer one. So the verdict never depends on a value this caller - // could not have read. The read door is asked for file fields in their - // stored form (`RAW_FILE_VALUES_CONTEXT_KEY`, the declared opt-out for a - // caller whose subject is the stored form), so a readable file reference - // compares as the id it is rather than as the expanded object. - // - // `undefined` = no stored row: the row is judged on the patch alone, as - // it was before these reads existed. - const storedRows: Array | undefined> = []; - for (const submitted of submittedRows) { - let stored: Record | undefined; - if (mode === 'update' && submitted && typeof submitted === 'object') { - const dispatch = resolveEngineUpdateDispatch(submitted as EngineUpdateDispatchData, undefined); - if (dispatch.kind === 'by-id') { - const visible = await this.findOne(object, { - where: { id: dispatch.id }, - context: { ...(options?.context ?? {}), [RAW_FILE_VALUES_CONTEXT_KEY]: true }, - } as EngineQueryOptions); - const prior = visible && typeof visible === 'object' - ? await this.readUpdatePriorRow(object, dispatch.id, options?.context) - : null; - if (visible && prior) { - const served = visible as Record; - stored = {}; - for (const [key, value] of Object.entries(prior)) { - if (Object.prototype.hasOwnProperty.call(served, key) && servedAsStored(served[key], value)) { - stored[key] = value; - } - } - } - } - } - storedRows.push(stored); - } - // The merged view each rule evaluates, and the one the reference reads - // below key on: the write reads a traversed FK off the same merge - // (`updateView` in `update()`), so a FK the patch omits resolves from the - // stored row. - const judgedViews = rows.map((row, i) => (storedRows[i] ? { ...storedRows[i], ...row } : row)); - - // [#22474] THE MASTER-DETAIL HEADER a field `requiredWhen` reads as - // `parent`, bound as the write binds it and under the write's own gate: - // only an object that declares a parent-scoped `requiredWhen` reads a - // header (`hasParentScopedRequiredWhen`, the insert's gate and the - // update's `requiredWhen` half), so every other object's preview reads - // none. The header is named by the judged view, the write's - // payload-FK-first reading (`masterIdOf`): the row's own FK on an insert, - // and on an update the patch's FK, else the stored row's. A repointing - // update also binds the header the stored row hangs off as - // `previousParent`, the header the write's ADR-0113 pre-check reads. - // - // ⛔ Read under the caller's access, as the write reads it since #22519: - // see {@link resolvePreviewParents}. A header the caller cannot read binds - // as a missing header binds; unlike the write, a header column the read - // door did not serve this caller as stored is judged as empty. - // - // Named limit: the update's `readonlyWhen` judgement of the FK itself - // (`settleMasterDetailLanding` ①) is not run, the limit `readonlyWhen` - // already holds in this preview, so a repoint that lock would take back - // out is judged against the header it names. - const previewParents = hasParentScopedRequiredWhen(schemaForValidation as any) - ? await this.resolvePreviewParents(schemaForValidation, judgedViews, storedRows, options?.context) - : undefined; - - // [#18682] The preview owes the SAME relationship resolution the real write - // does. Without it a rule that reads one hop through a reference field - // reports `valid: false` (unevaluable) against a row `insert()` happily - // accepts — the false alarm this operation was created to prevent, and the - // import dry run rides on it. - // - // Resolved once for the whole set, like every other posture input above. - // [#22445] The named limit that sat here — an `update`-mode preview read - // no prior row, so a traversing rule whose FK the PATCH did not carry had - // no id to resolve — is CLOSED for a row that carries its address: the FK - // is read off `judgedViews`, the stored row merged with the patch. A row - // with no address (or no stored row this caller can read) still resolves - // only the FKs its patch carries. - // [#20805] The second named limit that used to sit here is CLOSED: a - // reference field the author declared static `readonly` is stripped from a - // non-system caller's payload by the write, so the write resolves no - // related row for it — and since the caller-write strips run above, the - // preview's `rows` have lost it too, and a traversing rule answers the - // same way on both. - // ⛔ Behind the caller's own create/update gate — see - // {@link registerWriteGateProbe}. A caller the probe refuses gets NO - // elevated read: `related` stays unresolved, and a traversing rule then - // refuses, which is the fail-closed direction and is honest about what it - // did not evaluate. - // ⛔ `submittedRows`, not `rows`: the gate's field-level arm judges WHICH - // FIELDS THE CALLER WROTE, and `rows` has already been through the strips, - // `applyFieldDefaults` / `initializeSummaryFields` above. Handing it the - // defaulted image would offer the plugin keys the caller never sent — the - // exact reading the middleware avoids by gating on `opCtx.data`, which is - // the raw payload (defaults are resolved inside the executor, under it). - // [#20805] Nor the stripped `rawRows`: the middleware runs BEFORE the - // write's computed-field door, so it judges the caller's keys as sent. - const mayWrite = this._writeGateProbe - ? await this._writeGateProbe(object, mode, options?.context, submittedRows).catch(() => false) - : true; - const previewRelatedForRow = mayWrite - ? await this.resolvePredicateRelated(schemaForValidation, judgedViews, options?.context) - : () => undefined; - // [#18783] The preview answers a `can`-gated option with the SAME map the - // write would — resolved once for the whole set, like every posture input - // above. A resolution failure rejects the preview, as it fails the write. - const previewPermissionsFor = await this.resolveOptionPermissions(schemaForValidation, rows, permissionResolution); - - const results: NonNullable = rows.map((row, i) => { - const warnings: ValidateDataIssue[] = []; - // Warn-first admissions are the posture signal the caller came for, so - // they are reported — into this row's own bucket, never into the - // certification sink (see the note above). - // - // Carried under the code the STRICT path would have FAILED with - // (`invalid_type`) rather than a warning-specific one: the same bad - // value is one finding that changed buckets when the posture changed, - // and a caller diffing a preview against a write should see that, not - // two vocabularies. The sink's payload is `{gate, field, type, detail}`, - // so the message is composed the way the warn-first log line composes it. - const onAdmittedValueShapeViolation = (violation: any) => { - const field = String(violation?.field ?? ''); - const type = String(violation?.type ?? ''); - const detail = String(violation?.detail ?? 'invalid value shape'); - warnings.push({ - field, - code: 'invalid_type', - message: `${field} has an invalid ${type} value: ${detail}`, - }); - }; - // [#25062] A row the tenant wall refuses is answered with the write's - // refusal, which the write gives before it validates anything. - const wallRefusal = wallRefusalOf(i); - if (wallRefusal) { - return { - valid: false, - errors: [{ field: wallRefusal.field, code: wallRefusal.code, message: wallRefusal.message }], - warnings, - }; - } - try { - // [#21663] `'include'` in both modes: the caller-write strips ran - // above, so this is the payload the write stores — and the write - // judges its readonly values' shape (insert in the same call, update - // in a second pass after its own strip). - validateRecordInScope(schemaForValidation, row, mode, 'include', { - mediaValueShapeStrict, valueShapeStrict, messages, onAdmittedValueShapeViolation, - // [#22183] The option values an import kept for this write — the - // preview admits exactly what the write would. - keptOptionValues: options?.context?.keptOptionValues, - }); - warnings.push(...evaluateValidationRules(schemaForValidation as any, row, mode, { - // [#22445] The stored row, when this row named one: the by-id - // update's `previous: priorRecord`. Absent, the record is the patch. - ...(storedRows[i] ? { previous: storedRows[i] } : {}), - logger: this.logger, currentUser, skipStateMachine, messages, - // [#22474] The header the write binds: the insert's - // `insertParentForRow`, the update's landing header and, for a - // repoint, `roWhenPreviousParent`. - parent: previewParents?.parents[i], - previousParent: previewParents?.previousParents[i], - related: previewRelatedForRow(judgedViews[i]), - permissions: previewPermissionsFor(row), - })); - } catch (e) { - if (e instanceof ValidationError) { - return { valid: false, errors: e.fields.map((f) => ({ ...f })), warnings }; - } - throw e; - } - // [#20922] THIS row's drops, from the strips' own per-row record above - // — the report `insertMany` hangs on the same row's `ok` outcome, so a - // dry run and its commit answer one row in one vocabulary. Only on a - // row the verdict accepts: a drop is "the write completed without - // them", and the write refuses an invalid row, so it drops nothing - // (`insertMany` answers that row `ok: false`, with no drops). Absent - // when nothing would be taken. - const droppedFields = droppedFieldEvents(object, computedStrip.droppedPerRow[i]!, readonlyDroppedPerRow[i]!); - return { valid: true, errors: [], warnings, ...(droppedFields.length > 0 ? { droppedFields } : {}) }; - }); - - return { - object, - mode, - valid: results.every((r) => r.valid), - results, - // The EFFECTIVE posture, not the raw deployment flag. `validateRecord` - // runs the flag through `valueShapeStrictEffective`, where the ADR-0104 - // env switches take precedence over it, so reporting the flag would - // describe a different posture than the one that just decided the - // verdict — on a deployment holding the flag but running with - // `OS_ALLOW_LAX_VALUE_SHAPES`, exactly backwards. Reporting what decided - // is the whole point of returning it. - posture: { - valueShapeStrict: valueShapeStrictEffective(valueShapeStrict), - mediaValueShapeStrict: mediaStrictEffective(mediaValueShapeStrict), - }, + // [#25522 cut 16] The body moved to `engine-validate.ts` (see `ValidateHost`). + const readRegistry = () => this._registry; + const readWriteWallVerdict = () => this._writeWallVerdict; + const readLogger = () => this.logger; + const readWriteGateProbe = () => this._writeGateProbe; + const host: ThisParameterType = { + resolveObjectName: (...a) => this.resolveObjectName(...a), + get _registry() { return readRegistry(); }, + permissionResolution: (...a) => this.permissionResolution(...a), + resolveDefaultPermissions: (...a) => this.resolveDefaultPermissions(...a), + initializeSummaryFields: (...a) => this.initializeSummaryFields(...a), + applyFieldDefaults: (...a) => this.applyFieldDefaults(...a), + get _writeWallVerdict() { return readWriteWallVerdict(); }, + staticReadonlyCreateStrip: (...a) => this.staticReadonlyCreateStrip(...a), + get logger() { return readLogger(); }, + mediaValueShapeStrictFor: (...a) => this.mediaValueShapeStrictFor(...a), + valueShapeStrictFor: (...a) => this.valueShapeStrictFor(...a), + validationMessageContext: (...a) => this.validationMessageContext(...a), + buildEvalUser: (...a) => this.buildEvalUser(...a), + findOne: (...a) => this.findOne(...a), + readUpdatePriorRow: (...a) => this.readUpdatePriorRow(...a), + resolvePreviewParents: (...a) => this.resolvePreviewParents(...a), + get _writeGateProbe() { return readWriteGateProbe(); }, + resolvePredicateRelated: (...a) => this.resolvePredicateRelated(...a), + resolveOptionPermissions: (...a) => this.resolveOptionPermissions(...a), }; + return validate.call(host, object, data, options); } /** diff --git a/packages/objectql/src/validate-preview-parent.test.ts b/packages/objectql/src/validate-preview-parent.test.ts index 7d8a410093a..36bce7bc9a6 100644 --- a/packages/objectql/src/validate-preview-parent.test.ts +++ b/packages/objectql/src/validate-preview-parent.test.ts @@ -38,7 +38,8 @@ // (e) the real door: the import dry run, through the protocol, of a created // and of a matched row; // (f) parity: every input a write-side `evaluateValidationRules` call passes -// is passed by the preview's call too (read from `engine.ts` itself). +// is passed by the preview's call too (read from the source itself: the +// writes in `engine.ts`, the preview in `engine-validate.ts`). // // (a), (b) and (e) were red before the fix. @@ -456,42 +457,44 @@ describe('#22474 (e) — the import dry run of a detail row, through the protoco // ───────────────────────────────────────────────────────────────────────────── // (f) PARITY — every input a write binds, the preview binds. // -// Read from `engine.ts` itself, so a NEW input added to a write-side +// Read from the source itself, so a NEW input added to a write-side // `evaluateValidationRules` call and not to the preview's fails here. Each call // site is found by its callee name in the TypeScript AST and attributed to its -// enclosing class method: the one in `validate` is the preview, every other is -// a write. A call's inputs are the keys of its options object literal, +// enclosing class method or function: the one in `validate` is the preview, +// every other is a write. [#25522 cut 16] The preview's body moved out of +// `engine.ts` into `engine-validate.ts` (the function `validate`), so both +// files are read; the writes stay in `engine.ts`. A call's inputs are the keys of its options object literal, // including the keys of an object literal spread into it (`...(c ? { k } : {})`). // An options argument this reader cannot see into is a failure, never a pass. // ───────────────────────────────────────────────────────────────────────────── -type RuleCall = { method: string; mode: string; keys: string[]; line: number }; +type RuleCall = { file: string; method: string; mode: string; keys: string[]; line: number }; -function ruleCallsIn(source: string): RuleCall[] { - const sf = ts.createSourceFile('engine.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +function ruleCallsIn(file: string, source: string): RuleCall[] { + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const calls: RuleCall[] = []; const keysOf = (expr: ts.Expression, line: number): string[] => { if (ts.isParenthesizedExpression(expr)) return keysOf(expr.expression, line); if (ts.isConditionalExpression(expr)) return [...keysOf(expr.whenTrue, line), ...keysOf(expr.whenFalse, line)]; if (!ts.isObjectLiteralExpression(expr)) { - throw new Error(`engine.ts:${line}: an evaluateValidationRules options value this test cannot read: ${expr.getText(sf)}`); + throw new Error(`${file}:${line}: an evaluateValidationRules options value this test cannot read: ${expr.getText(sf)}`); } const keys: string[] = []; for (const p of expr.properties) { if (ts.isSpreadAssignment(p)) keys.push(...keysOf(p.expression, line)); else if ((ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && ts.isIdentifier(p.name)) keys.push(p.name.text); - else throw new Error(`engine.ts:${line}: an options member this test cannot read: ${p.getText(sf)}`); + else throw new Error(`${file}:${line}: an options member this test cannot read: ${p.getText(sf)}`); } return keys; }; const visit = (node: ts.Node, method: string | undefined): void => { - const here = ts.isMethodDeclaration(node) && ts.isIdentifier(node.name) ? node.name.text : method; + const here = (ts.isMethodDeclaration(node) || ts.isFunctionDeclaration(node)) && node.name && ts.isIdentifier(node.name) ? node.name.text : method; if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'evaluateValidationRules') { const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; const [, , modeArg, optsArg] = node.arguments; - if (!here || !modeArg || !optsArg) throw new Error(`engine.ts:${line}: a call this test cannot attribute`); + if (!here || !modeArg || !optsArg) throw new Error(`${file}:${line}: a call this test cannot attribute`); const mode = ts.isStringLiteral(modeArg) ? modeArg.text : modeArg.getText(sf); - calls.push({ method: here, mode, keys: [...new Set(keysOf(optsArg, line))].sort(), line }); + calls.push({ file, method: here, mode, keys: [...new Set(keysOf(optsArg, line))].sort(), line }); } ts.forEachChild(node, (child) => visit(child, here)); }; @@ -500,7 +503,8 @@ function ruleCallsIn(source: string): RuleCall[] { } describe('#22474 (f) — parity: the preview passes every input a write passes', () => { - const calls = ruleCallsIn(readFileSync(new URL('./engine.ts', import.meta.url), 'utf8')); + const calls = ['engine.ts', 'engine-validate.ts'] + .flatMap((file) => ruleCallsIn(file, readFileSync(new URL(`./${file}`, import.meta.url), 'utf8'))); const previews = calls.filter((c) => c.method === 'validate'); const writes = calls.filter((c) => c.method !== 'validate'); @@ -516,7 +520,7 @@ describe('#22474 (f) — parity: the preview passes every input a write passes', const previewKeys = new Set(previews[0]!.keys); const missing = writes.flatMap((w) => w.keys .filter((k) => !previewKeys.has(k)) - .map((k) => `${k} (passed by ${w.method}'s ${w.mode} call at engine.ts:${w.line})`)); + .map((k) => `${k} (passed by ${w.method}'s ${w.mode} call at ${w.file}:${w.line})`)); expect(missing).toEqual([]); });