From df1ae02829de568f378d64499ffb57456ca5494b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 12:11:27 +0000 Subject: [PATCH 1/2] refactor(objectql): move the find and findOne bodies into the find facet codemod-split-engine --members find,findOne on main 5f003744, proved by its byte-exact undo; the two facade-delegation exception rows go with them. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fL8RrJDkowuFhXUPG --- .../30020-objectql-find-findone-facet.md | 11 + packages/objectql/src/engine-find.ts | 446 ++++++++++++++++- packages/objectql/src/engine.ts | 450 ++---------------- scripts/facade-delegation-registry.json | 14 - 4 files changed, 490 insertions(+), 431 deletions(-) create mode 100644 .changeset/30020-objectql-find-findone-facet.md diff --git a/.changeset/30020-objectql-find-findone-facet.md b/.changeset/30020-objectql-find-findone-facet.md new file mode 100644 index 00000000000..77522e7f34d --- /dev/null +++ b/.changeset/30020-objectql-find-findone-facet.md @@ -0,0 +1,11 @@ +--- +'@objectstack/objectql': patch +--- + +refactor(objectql): the `ObjectQL#find` and `ObjectQL#findOne` bodies move into the find facet + +Clause-②: no +Metadata-protocol: none + +- The bodies of `find` and `findOne` move out of `engine.ts` into `engine-find.ts`, the facet that already holds their read helpers. Each method stays on the class as a one-call stub with its signature and its docblock. Where a body handed the engine itself on (`ql: this` in the before/after hook context), it reads the same engine through the host's `facade` entry. +- No behaviour changes and nothing to do on upgrade. The built declaration files keep every member and every docblock; the source maps move. diff --git a/packages/objectql/src/engine-find.ts b/packages/objectql/src/engine-find.ts index b4538a6a955..3ff00370cb8 100644 --- a/packages/objectql/src/engine-find.ts +++ b/packages/objectql/src/engine-find.ts @@ -4,8 +4,8 @@ * [#29986 codemod] The "Query lowering, search, find and findOne" section of * `ObjectQL`: the bodies of `resolveRelateThenLowerWhere`, `withResolvedWhere`, * `expandSearchOnAst`, `expandSearchOnOptions`, `fillQueryAstDefaults`, - * `requireFindOnePredicate`, `reportFindFailure` moved out of `engine.ts` whole - * by `scripts/codemod-split-engine.mjs`. + * `requireFindOnePredicate`, `find`, `reportFindFailure`, `findOne` moved out + * of `engine.ts` whole by `scripts/codemod-split-engine.mjs`. * * A MOVE, not a redesign. {@link FindFacet} is a stateless facet of `ObjectQL` * (ruling 6624345766): each static method of it is the body of the `ObjectQL` @@ -30,16 +30,27 @@ */ import { SortNodeSchema, lowerFilterCondition, resolveSearchFieldResolution } from '@objectstack/spec/data'; -import type { EngineAggregateOptions, EngineCountOptions, FilterLoweringOptions, QueryAST, QueryInput, ServiceObject } from '@objectstack/spec/data'; +import type { EngineAggregateOptions, EngineCountOptions, EngineQueryOptions, FilterLoweringOptions, HookContext, QueryAST, QueryInput, ServiceObject } from '@objectstack/spec/data'; +import { mergeDriverOptionsIntoHookView } from './engine-driver-options.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { isMissingTableError } from '@objectstack/metadata/errors'; import { collectCredentialFields, collectInternalReadFields } from './secret-fields.js'; import { expandSearchToFilter, normalizeSearch } from './search-filter.js'; +import { FindHookResultNotArrayError } from './find-hook-result-shape.js'; +import { FindOneHookResultNotRecordError, isFindOneResultShape } from './verb-hook-result-shape.js'; +import { rejectCredentialEvaluation } from './credential-evaluation-guard.js'; +import { rejectInternalFieldEvaluation } from './internal-field-evaluation-guard.js'; +import { assertProjectionNamesAField, refuseProjectionOfUnknownColumns } from './empty-projection-door.js'; +import { PLATFORM_PROVISIONED_COLUMNS, declaredColumnSet, rowsWithDeclaredColumnsOnly, rowsWithSelectedColumnsAnswered, selectedStoredColumns, withDeclaredColumnsOnly, withSelectedColumnsAnswered } from './declared-read-columns.js'; import { - emptySearchSetError, mergeReadContext, refuseSearchOverrideNamingNoField, - refuseSearchOverrideNamingNoSearchableField, resolveWhereFilterTokens, whereResolvedTokenJudge, writeFailureLogMeta, + ENGINE_FIND_OPTION_KEYS, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS, applyFormulaPlan, + assertOrderByIsMaterializable, assertProjectionHasNoDottedPaths, declaredDatetimeLowering, emptySearchSetError, + foldEngineOptionAliases, lowerWhereFilterArray, mergeReadContext, planFormulaProjection, + refuseSearchOverrideNamingNoField, refuseSearchOverrideNamingNoSearchableField, rejectUnknownEngineOptions, + resolveWhereFilterTokens, rowsWithoutFormulaWidening, whereResolvedTokenJudge, withFileOwnerKey, + withoutFormulaWidening, writeFailureLogMeta, } from './engine-top-level.js'; -import type { EngineReadOptions } from './engine-top-level.js'; +import type { EngineReadOptions, OperationContext } from './engine-top-level.js'; import type { ObjectQL } from './engine.js'; /** @@ -51,12 +62,37 @@ import type { ObjectQL } from './engine.js'; */ export interface FindHost { readonly _registry: ObjectQL['_registry']; + buildDriverOptions: ObjectQL['buildDriverOptions']; + buildHookApi: ObjectQL['buildHookApi']; + buildProvenance: ObjectQL['buildProvenance']; + buildSession: ObjectQL['buildSession']; + buildUser: ObjectQL['buildUser']; + composeReadableIdsAtBoundary: ObjectQL['composeReadableIdsAtBoundary']; + executeWithMiddleware: ObjectQL['executeWithMiddleware']; + expandRelatedRecords: ObjectQL['expandRelatedRecords']; expandSearchOnAst: ObjectQL['expandSearchOnAst']; + /** The engine itself: a moved body reads `this.facade` where the method read a bare `this` (#30020). */ + readonly facade: ObjectQL; fillQueryAstDefaults: ObjectQL['fillQueryAstDefaults']; + formulaFaultSink: ObjectQL['formulaFaultSink']; + formulaOptionLabels: ObjectQL['formulaOptionLabels']; + getDriver: ObjectQL['getDriver']; readonly logger: ObjectQL['logger']; lowerRelationConditions: ObjectQL['lowerRelationConditions']; + maskSecretFields: ObjectQL['maskSecretFields']; + permissionResolution: ObjectQL['permissionResolution']; + readonly relatedSchemaOf: ObjectQL['relatedSchemaOf']; + reportFindFailure: ObjectQL['reportFindFailure']; + requireFindOnePredicate: ObjectQL['requireFindOnePredicate']; + resolveFileReferences: ObjectQL['resolveFileReferences']; + resolveFormulaPermissions: ObjectQL['resolveFormulaPermissions']; + resolveFormulaRelated: ObjectQL['resolveFormulaRelated']; + resolveObjectName: ObjectQL['resolveObjectName']; resolveRelateThenLowerWhere: ObjectQL['resolveRelateThenLowerWhere']; resolveSearchQueryableFields: ObjectQL['resolveSearchQueryableFields']; + resolveWhereTokens: ObjectQL['resolveWhereTokens']; + stripSearchCompanionFromRead: ObjectQL['stripSearchCompanionFromRead']; + triggerHooks: ObjectQL['triggerHooks']; } /** The bodies of the "Query lowering, search, find and findOne" section of `ObjectQL`: a stateless facet, called by the class's stubs. */ @@ -379,6 +415,236 @@ export class FindFacet { ); } + /** The body of `ObjectQL#find`; the method's docblock there is its contract. */ + static async find(this: Pick, object: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise { + object = this.resolveObjectName(object); + // Normalize the alias spellings (`filter`→`where`, `top`→`limit`) by the + // spec's slot table — the driver AST only understands the canonical keys, + // so an unfolded `{ filter }` would match ALL rows (silent over-grant — + // surfaced by ADR-0057's sharing/graph read path). Same fold, same + // conflict rule on every engine entry point (#4346). The wire-only + // spellings (`sort`/`select`/`skip`/`populate`) are REJECTED instead of + // folded — a direct call carrying one used to have it silently dropped + // (#4371, three shipped instances in #4370). + query = foldEngineOptionAliases(object, 'find', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS); + rejectUnknownEngineOptions(object, 'find', query, ENGINE_FIND_OPTION_KEYS); + // [#27008] An empty projection is refused, never read as every column. + assertProjectionNamesAField(object, 'find', query); + query = lowerWhereFilterArray(object, 'find', query, this._registry.getObject(object), this.relatedSchemaOf); + this.logger.debug('Find operation starting', { object, query }); + const driver = this.getDriver(object); + // `object` LAST: the resolved name must win. Spread-first used to let a + // stray `query.object` overwrite it, splitting the AST's object from the + // table actually queried (#4371 option-2 survey) — every middleware and + // hook reading `ast.object` would have been lied to. + // `context` is dropped HERE rather than `delete`d from the built AST: since + // ADR-0122 the caller-supplied `context` is the AUTHOR state (every key + // optional) while `QueryAST` carries the parsed one, so spreading it in and + // removing it a line later would type the AST with a context it never holds. + // [commit 74155c735] The rest of the bag is author state too now — the defaults its + // schemas declare are filled here, before anything downstream reads the AST. + const { context: _findContext, ...findQuery } = query ?? {}; + const ast: QueryAST = { ...this.fillQueryAstDefaults(findQuery), object }; + // [ADR-0100] A `secret` / `password` column is never a filter or sort key + // for a non-system caller — judged on the caller's own `where` / `orderBy` + // / `expand`, before the search term is expanded into `where` and before + // the middlewares inject their server-authored filters. + // [internal: true] …and a flagged column is never evaluated for one either, + // judged first so the refusal is the data door's own (the door asks the + // same definition before the engine runs). + rejectInternalFieldEvaluation(object, ast, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); + rejectCredentialEvaluation(object, 'find', ast, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); + + // Plan formula projection: rewrite ast.fields to drop virtual formula + // names and inject their dependencies, so the driver returns the raw + // fields needed to compute the formulas after fetch. + const _findSchema = this._registry.getObject(object); + + await this.expandSearchOnAst(ast, _findSchema, mergeReadContext(query?.context, options?.context)); + // [#7642] The caller's OWN projection, captured before any planning pass + // rewrites it — the only thing that can answer "did this caller ask for + // `__search`?". See `stripSearchCompanionFromRead`. + const _findRequestedFields = Array.isArray(ast.fields) ? [...ast.fields] : undefined; + // [#7095] Before the projection is planned and before anything is handed to + // a driver: an ORDER BY this engine cannot materialise is refused, not + // dropped. `fillQueryAstDefaults` has already normalised `orderBy` into + // `SortNode[]`, so the names read here are the ones the driver would get. + assertOrderByIsMaterializable(object, 'find', _findSchema, ast.orderBy); + // [#7589] The projection's DOTTED leg, judged on the caller's own + // spellings BEFORE the formula planner rewrites the projection: a dotted + // entry is structurally unresolvable (no populate step exists — #7601) + // and is refused loudly instead of riding its head segment into the + // driver, where the #3821 ladder answered it with EVERY field. + assertProjectionHasNoDottedPaths(object, 'find', _findSchema, ast.fields); + const _findFormula = planFormulaProjection(_findSchema, ast.fields); + // [#26672] …and a file field's owner key — see `withFileOwnerKey`. + const _findProjection = withFileOwnerKey(_findSchema, ast.fields, _findFormula); + if (_findProjection.projected) ast.fields = _findProjection.projected; + // [#22300] The columns that widening added, cut back off the answer at the + // `return` — see `withoutFormulaWidening`. + const _findWidened = _findProjection.widened ? new Set(_findProjection.widened) : undefined; + + // Drop any requested PLAIN field that doesn't exist on the schema. + // Without this, drivers (notably SqlDriver) emit `SELECT unknown_col + // FROM ...` which the DB rejects ("no such column") — and SqlDriver + // swallows that error and returns `[]`, making a frontend bug (e.g. a + // generic view requesting `name`/`due_date` on every object) look like + // "no records exist". Silently filtering matches the existing OData + // tolerance and Salesforce/Postgres behavior of `SELECT *` semantics. + // + // [#7589] This tolerance is for unknown PLAIN columns ONLY, and it is + // KEPT deliberately (ruled 2026-08-12): the "no records exist" failure it + // prevents is real, and the driver-side half of the same tolerance + // backstops registry-less hosts. A structurally unresolvable (dotted) + // projection is a different fact and no longer reaches this filter via + // the engine — `assertProjectionHasNoDottedPaths` above refused it. + if (_findSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) { + // Always allow the primary key + audit columns even if not present in + // schema.fields. Without this, callers requesting `select=id,name` + // silently get the `id` projected away, breaking record navigation. + // [#21571] The same three the default projection and the write door + // admit — `PLATFORM_PROVISIONED_COLUMNS`, one list. + const known = new Set([...Object.keys(_findSchema.fields), ...PLATFORM_PROVISIONED_COLUMNS]); + // Whole names, no head-splitting: only plain entries reach here (the + // dotted refusal above fired on anything carrying a '.'). + const filtered = ast.fields.filter(f => known.has(f)); + // [#27165] Nothing left is an empty projection, refused as `fields: []` + // is — never read as every column. + if (filtered.length === 0) refuseProjectionOfUnknownColumns(object, 'find', ast.fields); + ast.fields = filtered; + } + + const opCtx: OperationContext = { + object, + operation: 'find', + ast, + options: query, + context: mergeReadContext(query?.context, options?.context), + }; + // [ADR-0053 D-D1, amended — #5930] Resolve, then lower (the shared + // lowering), against the object's declared field types. + await this.resolveWhereTokens(opCtx.ast as QueryAST, opCtx.context, declaredDatetimeLowering(_findSchema), 'where', 'find'); + + await this.executeWithMiddleware(opCtx, async () => { + const hookContext: HookContext = { + object, + event: 'beforeFind', + input: { ast: opCtx.ast, options: opCtx.options }, + session: this.buildSession(opCtx.context), + provenance: this.buildProvenance(opCtx.context), + user: this.buildUser(opCtx.context), + api: this.buildHookApi(opCtx.context), + transaction: opCtx.context?.transaction, + ql: this.facade + }; + await this.triggerHooks('beforeFind', hookContext); + // The driver is handed `driverOptions` — the declared driver options and + // nothing else; `input.options` stays the hooks' additive view of the bag. + const driverOptions = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); + hookContext.input.options = mergeDriverOptionsIntoHookView(hookContext.input.options, driverOptions); + // [ADR-0139 D2] The executor boundary: the AST is final here — after the + // doors, the middleware chain and `beforeFind` — so every `ReadableIds` + // leaf is composed (or refused) on it, immediately before the driver. + const driverAst = await this.composeReadableIdsAtBoundary(object, 'find', hookContext.input.ast as QueryAST, opCtx.context); + + try { + let result = await driver.find(object, driverAst, driverOptions); + + // [#21571] The read's default projection is the DECLARED field set: + // a column no metadata declares (a field retired in an upgrade, + // whose column additive sync leaves behind) never leaves the engine. + // Shaped here, on the rows as the driver returned them, so it holds + // whichever driver answered and whichever rung of a driver's + // recovery ladder answered (driver-sql retries `select('*')` when a + // projected statement names a missing column) — and before formulas, + // `expand`, file references and the hooks, so all of them see the + // declared record. See `declared-read-columns.ts`. + if (Array.isArray(result)) { + result = rowsWithDeclaredColumnsOnly(result, declaredColumnSet(_findSchema)); + // [#29408] …and answered on every stored declared column the read + // SELECTED: one the driver returned no key for (a driver that + // stores only written columns) is `null`, as driver-sql's column + // answers — the row's persisted state is in hand. Unselected, + // formula and FLS-refused columns stay absent; see + // `selectedStoredColumns`. + result = rowsWithSelectedColumnsAnswered(result, selectedStoredColumns(_findSchema, driverAst?.fields)); + } + + // Post-process: evaluate formula virtual fields against the raw rows. + // [#20082] With the caller's permission map when a formula calls + // `can` — one resolution for the whole result set, never per row. + if (Array.isArray(result)) { + applyFormulaPlan(_findFormula.plan, result, opCtx.context, await this.resolveFormulaPermissions( + object, _findFormula.plan, result, opCtx.context, this.permissionResolution(opCtx.context), + ), this.formulaFaultSink(object), await this.resolveFormulaRelated( + // [#24197] One batched read per reference field for this PAGE — + // the rows the driver returned, after limit/offset. + _findSchema, _findFormula.plan, result, opCtx.context, query, + ), this.formulaOptionLabels(_findSchema, _findFormula.plan, opCtx.context)); + } + + // Post-process: expand related records if expand is requested + if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) { + result = await this.expandRelatedRecords(object, result, ast.expand, 0, opCtx.context); + } + + // Post-process: resolve file-field id references to their expanded + // read form (ADR-0104 D3), one of three: the FileValueSchema file + // object, the bare id when nothing resolves it, or + // FileRefusedValueSchema's `{ id, metadataRefused: true }` when this + // caller is refused the `sys_file` read. Always-on but free unless a + // file field holds an id string; dual-mode-safe (blobs pass through). + if (Array.isArray(result)) { + result = await this.resolveFileReferences(object, result, opCtx.context); + } + + hookContext.event = 'afterFind'; + hookContext.result = result; + await this.triggerHooks('afterFind', hookContext); + + // [#15823] `find()` GUARANTEES the array, so the one seam that can + // break the declaration is closed here. An `afterFind` handler may + // SHAPE a read — mutate rows, drop keys, filter rows out, assign a + // different ARRAY — but replacing the container is a hook-contract + // violation, refused loudly rather than returned as an envelope a + // caller's `Promise` says cannot occur (ruled 2026-09-06). + // + // ⛔ Placement is load-bearing and not the `return`: `maskSecretFields` + // and `stripSearchCompanionFromRead` below BOTH assume the array and + // both run on `hookContext.result`, so the check has to precede them — + // otherwise the first consumer to walk a replaced container is the + // one that reports the problem, from the wrong place. + if (!Array.isArray(hookContext.result)) { + throw new FindHookResultNotArrayError({ + object, + event: 'afterFind', + result: hookContext.result, + }); + } + + // Never let secret-field plaintext (or its ref) leave through the + // generic read path — mask after hooks run. Privileged consumers use + // resolveSecret() against the stored ref instead. + this.maskSecretFields(object, hookContext.result); + + // [#7642] …and never let the hidden `__search` companion column out + // through the default projection either. After the hooks, for the + // same reason the mask is: a server-side `afterFind` handler is not + // the client this column is hidden from. + this.stripSearchCompanionFromRead(hookContext.result, _findRequestedFields, opCtx.context); + + return hookContext.result; + } catch (e) { + this.reportFindFailure(object, e); + throw e; + } + }); + + // [#22300] The formula widening is cut back off the answer here, after the + // middleware chain — every internal consumer above read the full row. + return rowsWithoutFormulaWidening(opCtx.result as any[], _findWidened); + } + /** * Report a failed {@link find} at the level its CAUSE earns — then leave the * throw exactly where it was. @@ -473,4 +739,172 @@ export class FindFacet { // is told — see the docblock's last section. this.logger.warn('Find operation failed', writeFailureLogMeta(error, { object })); } + + /** The body of `ObjectQL#findOne`; the method's docblock there is its contract. */ + static async findOne(this: Pick, objectName: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise | null> { + objectName = this.resolveObjectName(objectName); + // Same alias fold as find() (#4346). Without it, `findOne({ filter })` + // matched the first row of the WHOLE table rather than the predicate. + // `top` folds into `limit` here too, but findOne is single-row by + // contract, so the literal `limit: 1` below wins over both spellings. + // Wire-only spellings are rejected, same as find() (#4371) — `sort` + // matters here too: findOne({ sort }) means "first row of THIS order". + query = foldEngineOptionAliases(objectName, 'findOne', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS); + rejectUnknownEngineOptions(objectName, 'findOne', query, ENGINE_FIND_OPTION_KEYS); + // [#27008] Same refusal as find(). + assertProjectionNamesAField(objectName, 'findOne', query); + query = lowerWhereFilterArray(objectName, 'findOne', query, this._registry.getObject(objectName), this.relatedSchemaOf); + this.logger.debug('FindOne operation', { objectName }); + const driver = this.getDriver(objectName); + // `object` after the spread for the same reason as find(); `limit: 1` + // last — findOne is single-row by contract. + // Same reason as find(): the caller's `context` is the author state and the + // AST carries the parsed one, so it leaves before the AST is typed. + // [commit 74155c735] And the same default-filling as find(), for the same reason. + const { context: _findOneContext, ...findOneQuery } = query ?? {}; + const ast: QueryAST = { ...this.fillQueryAstDefaults(findOneQuery), object: objectName, limit: 1 }; + // [ADR-0100] Same internal-field and credential-column refusals as `find`, same position. + rejectInternalFieldEvaluation(objectName, ast, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); + rejectCredentialEvaluation(objectName, 'findOne', ast, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); + + // Plan formula projection (same as find): rewrite ast.fields so the driver + // returns the raw dependency fields, then evaluate formulas after fetch. + const _findOneSchema = this._registry.getObject(objectName); + // Before the guard below, so a `search` that resolves to a real filter + // counts as the predicate it is (#4419). + await this.expandSearchOnAst(ast, _findOneSchema, mergeReadContext(query?.context, options?.context)); + this.requireFindOnePredicate(objectName, ast); + // [#7095] Same refusal as `find`, and it matters MORE here: `findOne` + // applies `limit: 1`, so `orderBy` is the whole of "which record" — a + // dropped sort does not merely reorder the answer, it returns a DIFFERENT + // record, and the one it returns looks exactly as legitimate. + assertOrderByIsMaterializable(objectName, 'findOne', _findOneSchema, ast.orderBy); + // [#7589] Same dotted-projection refusal as `find`, same position: on the + // caller's own spellings, before the formula planner rewrites them. The + // measured flow chain (`get_record` → `data.findOne`) reaches THIS verb + // whenever `limit` is absent or 1, so a hole here would be the same hole. + assertProjectionHasNoDottedPaths(objectName, 'findOne', _findOneSchema, ast.fields); + // [#7642] Caller's own projection, before planning rewrites it — see `find`. + const _findOneRequestedFields = Array.isArray(ast.fields) ? [...ast.fields] : undefined; + const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields); + // [#26672] Same as `find`: a file field's owner key is read too. + const _findOneProjection = withFileOwnerKey(_findOneSchema, ast.fields, _findOneFormula); + if (_findOneProjection.projected) ast.fields = _findOneProjection.projected; + // [#22300] Same as `find`: what the widening added is cut at the `return`. + const _findOneWidened = _findOneProjection.widened ? new Set(_findOneProjection.widened) : undefined; + + // Drop unknown PLAIN fields — see the equivalent block in `find()` for + // the rationale, and for why this tolerance is plain-columns-only ([#7589] + // refused any dotted entry above, so none reaches this filter). + if (_findOneSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) { + // Always allow the primary key + audit columns even if not present + // in schema.fields (matches `find()` behavior, one list). + const known = new Set([...Object.keys(_findOneSchema.fields), ...PLATFORM_PROVISIONED_COLUMNS]); + const filtered = ast.fields.filter(f => known.has(f)); + // [#27165] Same refusal as `find`. + if (filtered.length === 0) refuseProjectionOfUnknownColumns(objectName, 'findOne', ast.fields); + ast.fields = filtered; + } + + const opCtx: OperationContext = { + object: objectName, + operation: 'findOne', + ast, + options: query, + context: mergeReadContext(query?.context, options?.context), + }; + // [ADR-0053 D-D1, amended — #5930] Resolve, then lower. + await this.resolveWhereTokens(opCtx.ast as QueryAST, opCtx.context, declaredDatetimeLowering(_findOneSchema), 'where', 'findOne'); + + await this.executeWithMiddleware(opCtx, async () => { + // [#3195] `findOne` fires the SAME `beforeFind`/`afterFind` hooks as + // `find` — the read event attaches to record materialization, not to the + // engine method, so one subscription covers every read shape (there is no + // separate `beforeFindOne`/`afterFindOne`). Mirrors `find()` above. + const hookContext: HookContext = { + object: objectName, + event: 'beforeFind', + input: { ast: opCtx.ast, options: opCtx.options }, + session: this.buildSession(opCtx.context), + provenance: this.buildProvenance(opCtx.context), + user: this.buildUser(opCtx.context), + api: this.buildHookApi(opCtx.context), + transaction: opCtx.context?.transaction, + ql: this.facade + }; + await this.triggerHooks('beforeFind', hookContext); + // The driver is handed `driverOptions` alone, as in `find`. + const driverOptions = this.buildDriverOptions(objectName, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); + hookContext.input.options = mergeDriverOptionsIntoHookView(hookContext.input.options, driverOptions); + // [ADR-0139 D2] The executor boundary, as in `find`. + const driverAst = await this.composeReadableIdsAtBoundary(objectName, 'findOne', hookContext.input.ast as QueryAST, opCtx.context); + + let result = await driver.findOne(objectName, driverAst, driverOptions); + + // [#21571] Same default projection as `find`, same position: the + // declared field set, applied to the row as the driver returned it. + result = withDeclaredColumnsOnly(result, declaredColumnSet(_findOneSchema)); + // [#29408] Same answer on the selected stored columns as `find`. + result = withSelectedColumnsAnswered(result, selectedStoredColumns(_findOneSchema, driverAst?.fields)); + + // Post-process: evaluate formula virtual fields against the raw row + // ([#20082] with the caller's permission map when a formula calls `can`). + if (result != null) { + applyFormulaPlan(_findOneFormula.plan, [result], opCtx.context, await this.resolveFormulaPermissions( + objectName, _findOneFormula.plan, [result], opCtx.context, this.permissionResolution(opCtx.context), + ), this.formulaFaultSink(objectName), await this.resolveFormulaRelated( + _findOneSchema, _findOneFormula.plan, [result], opCtx.context, query, + ), this.formulaOptionLabels(_findOneSchema, _findOneFormula.plan, opCtx.context)); + } + + // Post-process: expand related records if expand is requested + if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) { + const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context); + result = expanded[0]; + } + + // Post-process: resolve file-field id references (ADR-0104 D3). + if (result != null) { + const resolved = await this.resolveFileReferences(objectName, [result], opCtx.context); + result = resolved[0]; + } + + hookContext.event = 'afterFind'; + hookContext.result = result; + await this.triggerHooks('afterFind', hookContext); + + // [#16231] `findOne()` now DECLARES what it answers — the one record the + // query selects, or `null` — so the seam that can break the declaration + // is closed here, on the terms #15823 set for `find()`. An `afterFind` + // handler may SHAPE the record (mutate it, drop keys, assign a different + // RECORD built from it); replacing it with something that is neither a + // record nor `null` is a hook-contract violation, refused loudly rather + // than returned as a value the caller's type says cannot occur. + // + // ⛔ Placement is load-bearing and not the `return`, exactly as on + // `find()`: `maskSecretFields` and `stripSearchCompanionFromRead` below + // both run on `hookContext.result`, so the check has to precede them — + // otherwise the first consumer to walk a replaced value is the one that + // reports the problem, from the wrong place. + if (!isFindOneResultShape(hookContext.result)) { + throw new FindOneHookResultNotRecordError({ + object: objectName, + event: 'afterFind', + result: hookContext.result, + }); + } + + // Mask secret fields — plaintext never leaves through the read path. + this.maskSecretFields(objectName, hookContext.result); + // [#7642] Hidden `__search` companion — same door, same rule as `find`. + // This is the `GET /data/:object/:id` surface (`getData` reads through + // findOne), one of the four the issue measured. + this.stripSearchCompanionFromRead(hookContext.result, _findOneRequestedFields, opCtx.context); + + return hookContext.result; + }); + + // [#22300] Same cut as `find`, same position: after the middleware chain. + return withoutFormulaWidening(opCtx.result, _findOneWidened); + } } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 61f500b7f0c..d24a5642b7a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -155,12 +155,9 @@ import { import { refreshSummariesExtendedBy, startSummaryRefreshOnEnable, summaryRefreshesSettled, type SummaryEnableRefreshReport } from './summary-enable-refresh.js'; import { ReadonlyFieldRejectedError } from './readonly-strict-errors.js'; import { HookTargetRebindError } from './hook-target-rebind-errors.js'; -import { FindHookResultNotArrayError } from './find-hook-result-shape.js'; import { - isFindOneResultShape, isUpdateResultShape, isDeleteResultShape, - FindOneHookResultNotRecordError, UpdateHookResultNotWriteShapeError, DeleteHookResultNotWriteShapeError, } from './verb-hook-result-shape.js'; @@ -240,8 +237,6 @@ import { count, rejectCredentialAggregation } from './engine-count-aggregate.js' // ADR-0100: the credential-column guard's filter / sort half, beside the // aggregate half above — one collector, one envelope. import { rejectCredentialEvaluation } from './credential-evaluation-guard.js'; -import { rejectInternalFieldEvaluation } from './internal-field-evaluation-guard.js'; -import { assertProjectionNamesAField, refuseProjectionOfUnknownColumns } from './empty-projection-door.js'; import { assertRecordCurrencyCodes, touchesRecordCurrency } from './validation/record-currency-code.js'; import { EXCHANGE_RATE_OBJECT, @@ -291,10 +286,8 @@ import { resolveMasterDetailRelation } from './master-detail.js'; // [#21571] The declared column set — the read verbs' default projection, their // explicit-projection filter and the write path's undeclared-key door, one list. import { - PLATFORM_PROVISIONED_COLUMNS, declaredColumnSet, rowsWithDeclaredColumnsOnly, - rowsWithSelectedColumnsAnswered, selectedStoredColumns, storedColumnsLeftUnwritten, withDeclaredColumnsOnly, @@ -357,18 +350,15 @@ import { } from './integrity/dangling-reference-audit.js'; // [#29986 codemod] The top-level declarations that moved whole to `engine-top-level.ts`, as this file still reads them. import { - DISPATCHABLE_HOOK_EVENTS, ENGINE_DELETE_OPTION_KEYS, ENGINE_FIND_OPTION_KEYS, ENGINE_QUERY_SLOTS, - ENGINE_UPDATE_OPTION_KEYS, ENGINE_WHERE_SLOTS, ENGINE_WIRE_ONLY_SLOTS, SECURITY_SERVICE_SOURCE_SLOT, - WRITE_PRECONDITION_OPTION, applyFormulaPlan, applyStoredExpressionsToPatch, assertDispatchableHookEvent, - assertOrderByIsMaterializable, assertProjectionHasNoDottedPaths, assertValidHookExcludeObjects, - autonumberFieldNames, callerKeySets, callerSuppliedRow, computeStoredExpressions, declaredDatetimeLowering, - droppedFieldEvents, foldEngineOptionAliases, handBackWithheldInsertReadonly, hydrateWriteFormulas, - insertStaticReadonlySubject, lowerWhereFilterArray, masterIdOf, mergeReadContext, - organizationPredicateDeleteRefusal, planFormulaProjection, planStoredExpressions, recomputeAutonumberReaders, - rejectUnknownEngineOptions, replacedStoredExpressionFields, rowsWithoutFormulaWidening, settleBulkStoredExpressions, - shouldSkipStateMachine, staticReadonlyStoredView, storedExpressionsReading, stripComputedWriteFields, - undeclaredWriteFieldErrors, updateStaticReadonlySubject, withFileOwnerKey, withholdInsertReadonlyFromHooks, - withoutFormulaWidening, writeFailureLogMeta, + DISPATCHABLE_HOOK_EVENTS, ENGINE_DELETE_OPTION_KEYS, ENGINE_UPDATE_OPTION_KEYS, ENGINE_WHERE_SLOTS, + SECURITY_SERVICE_SOURCE_SLOT, WRITE_PRECONDITION_OPTION, applyStoredExpressionsToPatch, assertDispatchableHookEvent, + assertValidHookExcludeObjects, autonumberFieldNames, callerKeySets, callerSuppliedRow, computeStoredExpressions, + declaredDatetimeLowering, droppedFieldEvents, foldEngineOptionAliases, handBackWithheldInsertReadonly, + hydrateWriteFormulas, insertStaticReadonlySubject, lowerWhereFilterArray, masterIdOf, + organizationPredicateDeleteRefusal, planStoredExpressions, recomputeAutonumberReaders, rejectUnknownEngineOptions, + replacedStoredExpressionFields, settleBulkStoredExpressions, shouldSkipStateMachine, staticReadonlyStoredView, + storedExpressionsReading, stripComputedWriteFields, undeclaredWriteFieldErrors, updateStaticReadonlySubject, + withholdInsertReadonlyFromHooks, writeFailureLogMeta, } from './engine-top-level.js'; import type { AdmittedValueShapeViolationTally, AutonumberReaders, CascadeDeleteSet, CascadeIssuedDelete, DatasourceDef, @@ -4347,233 +4337,9 @@ export class ObjectQL implements IObjectQLEngine { return FindFacet.requireFindOnePredicate(object, ast); } - async find(object: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise { - object = this.resolveObjectName(object); - // Normalize the alias spellings (`filter`→`where`, `top`→`limit`) by the - // spec's slot table — the driver AST only understands the canonical keys, - // so an unfolded `{ filter }` would match ALL rows (silent over-grant — - // surfaced by ADR-0057's sharing/graph read path). Same fold, same - // conflict rule on every engine entry point (#4346). The wire-only - // spellings (`sort`/`select`/`skip`/`populate`) are REJECTED instead of - // folded — a direct call carrying one used to have it silently dropped - // (#4371, three shipped instances in #4370). - query = foldEngineOptionAliases(object, 'find', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS); - rejectUnknownEngineOptions(object, 'find', query, ENGINE_FIND_OPTION_KEYS); - // [#27008] An empty projection is refused, never read as every column. - assertProjectionNamesAField(object, 'find', query); - query = lowerWhereFilterArray(object, 'find', query, this._registry.getObject(object), this.relatedSchemaOf); - this.logger.debug('Find operation starting', { object, query }); - const driver = this.getDriver(object); - // `object` LAST: the resolved name must win. Spread-first used to let a - // stray `query.object` overwrite it, splitting the AST's object from the - // table actually queried (#4371 option-2 survey) — every middleware and - // hook reading `ast.object` would have been lied to. - // `context` is dropped HERE rather than `delete`d from the built AST: since - // ADR-0122 the caller-supplied `context` is the AUTHOR state (every key - // optional) while `QueryAST` carries the parsed one, so spreading it in and - // removing it a line later would type the AST with a context it never holds. - // [commit 74155c735] The rest of the bag is author state too now — the defaults its - // schemas declare are filled here, before anything downstream reads the AST. - const { context: _findContext, ...findQuery } = query ?? {}; - const ast: QueryAST = { ...this.fillQueryAstDefaults(findQuery), object }; - // [ADR-0100] A `secret` / `password` column is never a filter or sort key - // for a non-system caller — judged on the caller's own `where` / `orderBy` - // / `expand`, before the search term is expanded into `where` and before - // the middlewares inject their server-authored filters. - // [internal: true] …and a flagged column is never evaluated for one either, - // judged first so the refusal is the data door's own (the door asks the - // same definition before the engine runs). - rejectInternalFieldEvaluation(object, ast, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); - rejectCredentialEvaluation(object, 'find', ast, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); - - // Plan formula projection: rewrite ast.fields to drop virtual formula - // names and inject their dependencies, so the driver returns the raw - // fields needed to compute the formulas after fetch. - const _findSchema = this._registry.getObject(object); - - await this.expandSearchOnAst(ast, _findSchema, mergeReadContext(query?.context, options?.context)); - // [#7642] The caller's OWN projection, captured before any planning pass - // rewrites it — the only thing that can answer "did this caller ask for - // `__search`?". See `stripSearchCompanionFromRead`. - const _findRequestedFields = Array.isArray(ast.fields) ? [...ast.fields] : undefined; - // [#7095] Before the projection is planned and before anything is handed to - // a driver: an ORDER BY this engine cannot materialise is refused, not - // dropped. `fillQueryAstDefaults` has already normalised `orderBy` into - // `SortNode[]`, so the names read here are the ones the driver would get. - assertOrderByIsMaterializable(object, 'find', _findSchema, ast.orderBy); - // [#7589] The projection's DOTTED leg, judged on the caller's own - // spellings BEFORE the formula planner rewrites the projection: a dotted - // entry is structurally unresolvable (no populate step exists — #7601) - // and is refused loudly instead of riding its head segment into the - // driver, where the #3821 ladder answered it with EVERY field. - assertProjectionHasNoDottedPaths(object, 'find', _findSchema, ast.fields); - const _findFormula = planFormulaProjection(_findSchema, ast.fields); - // [#26672] …and a file field's owner key — see `withFileOwnerKey`. - const _findProjection = withFileOwnerKey(_findSchema, ast.fields, _findFormula); - if (_findProjection.projected) ast.fields = _findProjection.projected; - // [#22300] The columns that widening added, cut back off the answer at the - // `return` — see `withoutFormulaWidening`. - const _findWidened = _findProjection.widened ? new Set(_findProjection.widened) : undefined; - - // Drop any requested PLAIN field that doesn't exist on the schema. - // Without this, drivers (notably SqlDriver) emit `SELECT unknown_col - // FROM ...` which the DB rejects ("no such column") — and SqlDriver - // swallows that error and returns `[]`, making a frontend bug (e.g. a - // generic view requesting `name`/`due_date` on every object) look like - // "no records exist". Silently filtering matches the existing OData - // tolerance and Salesforce/Postgres behavior of `SELECT *` semantics. - // - // [#7589] This tolerance is for unknown PLAIN columns ONLY, and it is - // KEPT deliberately (ruled 2026-08-12): the "no records exist" failure it - // prevents is real, and the driver-side half of the same tolerance - // backstops registry-less hosts. A structurally unresolvable (dotted) - // projection is a different fact and no longer reaches this filter via - // the engine — `assertProjectionHasNoDottedPaths` above refused it. - if (_findSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) { - // Always allow the primary key + audit columns even if not present in - // schema.fields. Without this, callers requesting `select=id,name` - // silently get the `id` projected away, breaking record navigation. - // [#21571] The same three the default projection and the write door - // admit — `PLATFORM_PROVISIONED_COLUMNS`, one list. - const known = new Set([...Object.keys(_findSchema.fields), ...PLATFORM_PROVISIONED_COLUMNS]); - // Whole names, no head-splitting: only plain entries reach here (the - // dotted refusal above fired on anything carrying a '.'). - const filtered = ast.fields.filter(f => known.has(f)); - // [#27165] Nothing left is an empty projection, refused as `fields: []` - // is — never read as every column. - if (filtered.length === 0) refuseProjectionOfUnknownColumns(object, 'find', ast.fields); - ast.fields = filtered; - } - - const opCtx: OperationContext = { - object, - operation: 'find', - ast, - options: query, - context: mergeReadContext(query?.context, options?.context), - }; - // [ADR-0053 D-D1, amended — #5930] Resolve, then lower (the shared - // lowering), against the object's declared field types. - await this.resolveWhereTokens(opCtx.ast as QueryAST, opCtx.context, declaredDatetimeLowering(_findSchema), 'where', 'find'); - - await this.executeWithMiddleware(opCtx, async () => { - const hookContext: HookContext = { - object, - event: 'beforeFind', - input: { ast: opCtx.ast, options: opCtx.options }, - session: this.buildSession(opCtx.context), - provenance: this.buildProvenance(opCtx.context), - user: this.buildUser(opCtx.context), - api: this.buildHookApi(opCtx.context), - transaction: opCtx.context?.transaction, - ql: this - }; - await this.triggerHooks('beforeFind', hookContext); - // The driver is handed `driverOptions` — the declared driver options and - // nothing else; `input.options` stays the hooks' additive view of the bag. - const driverOptions = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); - hookContext.input.options = mergeDriverOptionsIntoHookView(hookContext.input.options, driverOptions); - // [ADR-0139 D2] The executor boundary: the AST is final here — after the - // doors, the middleware chain and `beforeFind` — so every `ReadableIds` - // leaf is composed (or refused) on it, immediately before the driver. - const driverAst = await this.composeReadableIdsAtBoundary(object, 'find', hookContext.input.ast as QueryAST, opCtx.context); - - try { - let result = await driver.find(object, driverAst, driverOptions); - - // [#21571] The read's default projection is the DECLARED field set: - // a column no metadata declares (a field retired in an upgrade, - // whose column additive sync leaves behind) never leaves the engine. - // Shaped here, on the rows as the driver returned them, so it holds - // whichever driver answered and whichever rung of a driver's - // recovery ladder answered (driver-sql retries `select('*')` when a - // projected statement names a missing column) — and before formulas, - // `expand`, file references and the hooks, so all of them see the - // declared record. See `declared-read-columns.ts`. - if (Array.isArray(result)) { - result = rowsWithDeclaredColumnsOnly(result, declaredColumnSet(_findSchema)); - // [#29408] …and answered on every stored declared column the read - // SELECTED: one the driver returned no key for (a driver that - // stores only written columns) is `null`, as driver-sql's column - // answers — the row's persisted state is in hand. Unselected, - // formula and FLS-refused columns stay absent; see - // `selectedStoredColumns`. - result = rowsWithSelectedColumnsAnswered(result, selectedStoredColumns(_findSchema, driverAst?.fields)); - } - - // Post-process: evaluate formula virtual fields against the raw rows. - // [#20082] With the caller's permission map when a formula calls - // `can` — one resolution for the whole result set, never per row. - if (Array.isArray(result)) { - applyFormulaPlan(_findFormula.plan, result, opCtx.context, await this.resolveFormulaPermissions( - object, _findFormula.plan, result, opCtx.context, this.permissionResolution(opCtx.context), - ), this.formulaFaultSink(object), await this.resolveFormulaRelated( - // [#24197] One batched read per reference field for this PAGE — - // the rows the driver returned, after limit/offset. - _findSchema, _findFormula.plan, result, opCtx.context, query, - ), this.formulaOptionLabels(_findSchema, _findFormula.plan, opCtx.context)); - } - - // Post-process: expand related records if expand is requested - if (ast.expand && Object.keys(ast.expand).length > 0 && Array.isArray(result)) { - result = await this.expandRelatedRecords(object, result, ast.expand, 0, opCtx.context); - } - - // Post-process: resolve file-field id references to their expanded - // read form (ADR-0104 D3), one of three: the FileValueSchema file - // object, the bare id when nothing resolves it, or - // FileRefusedValueSchema's `{ id, metadataRefused: true }` when this - // caller is refused the `sys_file` read. Always-on but free unless a - // file field holds an id string; dual-mode-safe (blobs pass through). - if (Array.isArray(result)) { - result = await this.resolveFileReferences(object, result, opCtx.context); - } - - hookContext.event = 'afterFind'; - hookContext.result = result; - await this.triggerHooks('afterFind', hookContext); - - // [#15823] `find()` GUARANTEES the array, so the one seam that can - // break the declaration is closed here. An `afterFind` handler may - // SHAPE a read — mutate rows, drop keys, filter rows out, assign a - // different ARRAY — but replacing the container is a hook-contract - // violation, refused loudly rather than returned as an envelope a - // caller's `Promise` says cannot occur (ruled 2026-09-06). - // - // ⛔ Placement is load-bearing and not the `return`: `maskSecretFields` - // and `stripSearchCompanionFromRead` below BOTH assume the array and - // both run on `hookContext.result`, so the check has to precede them — - // otherwise the first consumer to walk a replaced container is the - // one that reports the problem, from the wrong place. - if (!Array.isArray(hookContext.result)) { - throw new FindHookResultNotArrayError({ - object, - event: 'afterFind', - result: hookContext.result, - }); - } - - // Never let secret-field plaintext (or its ref) leave through the - // generic read path — mask after hooks run. Privileged consumers use - // resolveSecret() against the stored ref instead. - this.maskSecretFields(object, hookContext.result); - - // [#7642] …and never let the hidden `__search` companion column out - // through the default projection either. After the hooks, for the - // same reason the mask is: a server-side `afterFind` handler is not - // the client this column is hidden from. - this.stripSearchCompanionFromRead(hookContext.result, _findRequestedFields, opCtx.context); - - return hookContext.result; - } catch (e) { - this.reportFindFailure(object, e); - throw e; - } - }); - - // [#22300] The formula widening is cut back off the answer here, after the - // middleware chain — every internal consumer above read the full row. - return rowsWithoutFormulaWidening(opCtx.result as any[], _findWidened); + find(object: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise { + // [#29986 codemod] The body moved to `engine-find.ts` (see `FindHost`). + return FindFacet.find.call(findHost.of(this), object, query, options); } private reportFindFailure(object: string, error: unknown): void { @@ -4608,171 +4374,9 @@ export class ObjectQL implements IObjectQLEngine { * * Fires the same `beforeFind`/`afterFind` hooks as `find` (#3195). */ - async findOne(objectName: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise | null> { - objectName = this.resolveObjectName(objectName); - // Same alias fold as find() (#4346). Without it, `findOne({ filter })` - // matched the first row of the WHOLE table rather than the predicate. - // `top` folds into `limit` here too, but findOne is single-row by - // contract, so the literal `limit: 1` below wins over both spellings. - // Wire-only spellings are rejected, same as find() (#4371) — `sort` - // matters here too: findOne({ sort }) means "first row of THIS order". - query = foldEngineOptionAliases(objectName, 'findOne', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS); - rejectUnknownEngineOptions(objectName, 'findOne', query, ENGINE_FIND_OPTION_KEYS); - // [#27008] Same refusal as find(). - assertProjectionNamesAField(objectName, 'findOne', query); - query = lowerWhereFilterArray(objectName, 'findOne', query, this._registry.getObject(objectName), this.relatedSchemaOf); - this.logger.debug('FindOne operation', { objectName }); - const driver = this.getDriver(objectName); - // `object` after the spread for the same reason as find(); `limit: 1` - // last — findOne is single-row by contract. - // Same reason as find(): the caller's `context` is the author state and the - // AST carries the parsed one, so it leaves before the AST is typed. - // [commit 74155c735] And the same default-filling as find(), for the same reason. - const { context: _findOneContext, ...findOneQuery } = query ?? {}; - const ast: QueryAST = { ...this.fillQueryAstDefaults(findOneQuery), object: objectName, limit: 1 }; - // [ADR-0100] Same internal-field and credential-column refusals as `find`, same position. - rejectInternalFieldEvaluation(objectName, ast, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); - rejectCredentialEvaluation(objectName, 'findOne', ast, mergeReadContext(query?.context, options?.context), this.relatedSchemaOf); - - // Plan formula projection (same as find): rewrite ast.fields so the driver - // returns the raw dependency fields, then evaluate formulas after fetch. - const _findOneSchema = this._registry.getObject(objectName); - // Before the guard below, so a `search` that resolves to a real filter - // counts as the predicate it is (#4419). - await this.expandSearchOnAst(ast, _findOneSchema, mergeReadContext(query?.context, options?.context)); - this.requireFindOnePredicate(objectName, ast); - // [#7095] Same refusal as `find`, and it matters MORE here: `findOne` - // applies `limit: 1`, so `orderBy` is the whole of "which record" — a - // dropped sort does not merely reorder the answer, it returns a DIFFERENT - // record, and the one it returns looks exactly as legitimate. - assertOrderByIsMaterializable(objectName, 'findOne', _findOneSchema, ast.orderBy); - // [#7589] Same dotted-projection refusal as `find`, same position: on the - // caller's own spellings, before the formula planner rewrites them. The - // measured flow chain (`get_record` → `data.findOne`) reaches THIS verb - // whenever `limit` is absent or 1, so a hole here would be the same hole. - assertProjectionHasNoDottedPaths(objectName, 'findOne', _findOneSchema, ast.fields); - // [#7642] Caller's own projection, before planning rewrites it — see `find`. - const _findOneRequestedFields = Array.isArray(ast.fields) ? [...ast.fields] : undefined; - const _findOneFormula = planFormulaProjection(_findOneSchema, ast.fields); - // [#26672] Same as `find`: a file field's owner key is read too. - const _findOneProjection = withFileOwnerKey(_findOneSchema, ast.fields, _findOneFormula); - if (_findOneProjection.projected) ast.fields = _findOneProjection.projected; - // [#22300] Same as `find`: what the widening added is cut at the `return`. - const _findOneWidened = _findOneProjection.widened ? new Set(_findOneProjection.widened) : undefined; - - // Drop unknown PLAIN fields — see the equivalent block in `find()` for - // the rationale, and for why this tolerance is plain-columns-only ([#7589] - // refused any dotted entry above, so none reaches this filter). - if (_findOneSchema?.fields && Array.isArray(ast.fields) && ast.fields.length > 0) { - // Always allow the primary key + audit columns even if not present - // in schema.fields (matches `find()` behavior, one list). - const known = new Set([...Object.keys(_findOneSchema.fields), ...PLATFORM_PROVISIONED_COLUMNS]); - const filtered = ast.fields.filter(f => known.has(f)); - // [#27165] Same refusal as `find`. - if (filtered.length === 0) refuseProjectionOfUnknownColumns(objectName, 'findOne', ast.fields); - ast.fields = filtered; - } - - const opCtx: OperationContext = { - object: objectName, - operation: 'findOne', - ast, - options: query, - context: mergeReadContext(query?.context, options?.context), - }; - // [ADR-0053 D-D1, amended — #5930] Resolve, then lower. - await this.resolveWhereTokens(opCtx.ast as QueryAST, opCtx.context, declaredDatetimeLowering(_findOneSchema), 'where', 'findOne'); - - await this.executeWithMiddleware(opCtx, async () => { - // [#3195] `findOne` fires the SAME `beforeFind`/`afterFind` hooks as - // `find` — the read event attaches to record materialization, not to the - // engine method, so one subscription covers every read shape (there is no - // separate `beforeFindOne`/`afterFindOne`). Mirrors `find()` above. - const hookContext: HookContext = { - object: objectName, - event: 'beforeFind', - input: { ast: opCtx.ast, options: opCtx.options }, - session: this.buildSession(opCtx.context), - provenance: this.buildProvenance(opCtx.context), - user: this.buildUser(opCtx.context), - api: this.buildHookApi(opCtx.context), - transaction: opCtx.context?.transaction, - ql: this - }; - await this.triggerHooks('beforeFind', hookContext); - // The driver is handed `driverOptions` alone, as in `find`. - const driverOptions = this.buildDriverOptions(objectName, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); - hookContext.input.options = mergeDriverOptionsIntoHookView(hookContext.input.options, driverOptions); - // [ADR-0139 D2] The executor boundary, as in `find`. - const driverAst = await this.composeReadableIdsAtBoundary(objectName, 'findOne', hookContext.input.ast as QueryAST, opCtx.context); - - let result = await driver.findOne(objectName, driverAst, driverOptions); - - // [#21571] Same default projection as `find`, same position: the - // declared field set, applied to the row as the driver returned it. - result = withDeclaredColumnsOnly(result, declaredColumnSet(_findOneSchema)); - // [#29408] Same answer on the selected stored columns as `find`. - result = withSelectedColumnsAnswered(result, selectedStoredColumns(_findOneSchema, driverAst?.fields)); - - // Post-process: evaluate formula virtual fields against the raw row - // ([#20082] with the caller's permission map when a formula calls `can`). - if (result != null) { - applyFormulaPlan(_findOneFormula.plan, [result], opCtx.context, await this.resolveFormulaPermissions( - objectName, _findOneFormula.plan, [result], opCtx.context, this.permissionResolution(opCtx.context), - ), this.formulaFaultSink(objectName), await this.resolveFormulaRelated( - _findOneSchema, _findOneFormula.plan, [result], opCtx.context, query, - ), this.formulaOptionLabels(_findOneSchema, _findOneFormula.plan, opCtx.context)); - } - - // Post-process: expand related records if expand is requested - if (ast.expand && Object.keys(ast.expand).length > 0 && result != null) { - const expanded = await this.expandRelatedRecords(objectName, [result], ast.expand, 0, opCtx.context); - result = expanded[0]; - } - - // Post-process: resolve file-field id references (ADR-0104 D3). - if (result != null) { - const resolved = await this.resolveFileReferences(objectName, [result], opCtx.context); - result = resolved[0]; - } - - hookContext.event = 'afterFind'; - hookContext.result = result; - await this.triggerHooks('afterFind', hookContext); - - // [#16231] `findOne()` now DECLARES what it answers — the one record the - // query selects, or `null` — so the seam that can break the declaration - // is closed here, on the terms #15823 set for `find()`. An `afterFind` - // handler may SHAPE the record (mutate it, drop keys, assign a different - // RECORD built from it); replacing it with something that is neither a - // record nor `null` is a hook-contract violation, refused loudly rather - // than returned as a value the caller's type says cannot occur. - // - // ⛔ Placement is load-bearing and not the `return`, exactly as on - // `find()`: `maskSecretFields` and `stripSearchCompanionFromRead` below - // both run on `hookContext.result`, so the check has to precede them — - // otherwise the first consumer to walk a replaced value is the one that - // reports the problem, from the wrong place. - if (!isFindOneResultShape(hookContext.result)) { - throw new FindOneHookResultNotRecordError({ - object: objectName, - event: 'afterFind', - result: hookContext.result, - }); - } - - // Mask secret fields — plaintext never leaves through the read path. - this.maskSecretFields(objectName, hookContext.result); - // [#7642] Hidden `__search` companion — same door, same rule as `find`. - // This is the `GET /data/:object/:id` surface (`getData` reads through - // findOne), one of the four the issue measured. - this.stripSearchCompanionFromRead(hookContext.result, _findOneRequestedFields, opCtx.context); - - return hookContext.result; - }); - - // [#22300] Same cut as `find`, same position: after the middleware chain. - return withoutFormulaWidening(opCtx.result, _findOneWidened); + findOne(objectName: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise | null> { + // [#29986 codemod] The body moved to `engine-find.ts` (see `FindHost`). + return FindFacet.findOne.call(findHost.of(this), objectName, query, options); } private staticReadonlyCreateStrip( @@ -10237,12 +9841,36 @@ export class ObjectQL implements IObjectQLEngine { })); findHost.install((engine) => ({ get _registry() { return engine._registry; }, + buildDriverOptions: (...a) => engine.buildDriverOptions(...a), + buildHookApi: (...a) => engine.buildHookApi(...a), + buildProvenance: (...a) => engine.buildProvenance(...a), + buildSession: (...a) => engine.buildSession(...a), + buildUser: (...a) => engine.buildUser(...a), + composeReadableIdsAtBoundary: (...a) => engine.composeReadableIdsAtBoundary(...a), + executeWithMiddleware: (...a) => engine.executeWithMiddleware(...a), + expandRelatedRecords: (...a) => engine.expandRelatedRecords(...a), expandSearchOnAst: (...a) => engine.expandSearchOnAst(...a), + facade: engine, fillQueryAstDefaults: (...a) => engine.fillQueryAstDefaults(...a), + formulaFaultSink: (...a) => engine.formulaFaultSink(...a), + formulaOptionLabels: (...a) => engine.formulaOptionLabels(...a), + getDriver: (...a) => engine.getDriver(...a), get logger() { return engine.logger; }, lowerRelationConditions: (...a) => engine.lowerRelationConditions(...a), + maskSecretFields: (...a) => engine.maskSecretFields(...a), + permissionResolution: (...a) => engine.permissionResolution(...a), + get relatedSchemaOf() { return engine.relatedSchemaOf; }, + reportFindFailure: (...a) => engine.reportFindFailure(...a), + requireFindOnePredicate: (...a) => engine.requireFindOnePredicate(...a), + resolveFileReferences: (...a) => engine.resolveFileReferences(...a), + resolveFormulaPermissions: (...a) => engine.resolveFormulaPermissions(...a), + resolveFormulaRelated: (...a) => engine.resolveFormulaRelated(...a), + resolveObjectName: (...a) => engine.resolveObjectName(...a), resolveRelateThenLowerWhere: (...a) => engine.resolveRelateThenLowerWhere(...a), resolveSearchQueryableFields: (...a) => engine.resolveSearchQueryableFields(...a), + resolveWhereTokens: (...a) => engine.resolveWhereTokens(...a), + stripSearchCompanionFromRead: (...a) => engine.stripSearchCompanionFromRead(...a), + triggerHooks: (...a) => engine.triggerHooks(...a), })); hooksAndFunctionsHost.install((engine) => ({ get _defaultActionRunner() { return engine._defaultActionRunner; }, diff --git a/scripts/facade-delegation-registry.json b/scripts/facade-delegation-registry.json index 73e1acfa60b..c0e59d8afe4 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -72,20 +72,6 @@ "card": "#30020", "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "find", - "card": "#30020", - "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "findOne", - "card": "#30020", - "reason": "state-owning: bare-this, the body hands the instance itself on (#29986 report: stays on the class)" - }, { "file": "packages/objectql/src/engine.ts", "class": "ObjectQL", From 8c46a17a83effd0a1c24f7f3e7c9cf5d5515d75e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 13:01:18 +0000 Subject: [PATCH 2/2] fix(spec,qa): repoint the anchors engine.ts lost to the find/findOne move engine.ts no longer imports planFormulaProjection, ENGINE_QUERY_SLOTS or applyFormulaPlan, so the query.fields and query.top liveness rows and the api-backend checklist item cite their declarations in engine-top-level.ts, and the alias fold its new call site in engine-find.ts. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fL8RrJDkowuFhXUPG --- .changeset/30020-objectql-find-findone-facet.md | 2 ++ docs/qa/platform-checklist/areas/api-backend.json | 2 +- packages/spec/liveness/query.json | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.changeset/30020-objectql-find-findone-facet.md b/.changeset/30020-objectql-find-findone-facet.md index 77522e7f34d..c22da08f466 100644 --- a/.changeset/30020-objectql-find-findone-facet.md +++ b/.changeset/30020-objectql-find-findone-facet.md @@ -1,5 +1,6 @@ --- '@objectstack/objectql': patch +'@objectstack/spec': patch --- refactor(objectql): the `ObjectQL#find` and `ObjectQL#findOne` bodies move into the find facet @@ -9,3 +10,4 @@ Metadata-protocol: none - The bodies of `find` and `findOne` move out of `engine.ts` into `engine-find.ts`, the facet that already holds their read helpers. Each method stays on the class as a one-call stub with its signature and its docblock. Where a body handed the engine itself on (`ql: this` in the before/after hook context), it reads the same engine through the host's `facade` entry. - No behaviour changes and nothing to do on upgrade. The built declaration files keep every member and every docblock; the source maps move. +- The `query.fields` and `query.top` liveness rows in `@objectstack/spec` now cite `engine-top-level.ts`, where `planFormulaProjection` and `ENGINE_QUERY_SLOTS` are declared, and `engine-find.ts`, where the `find` / `findOne` bodies fold the `top` alias, since `engine.ts` no longer imports those names; the evidence they cite is unchanged. diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 80a2219c32e..c5c12263899 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -2264,7 +2264,7 @@ "packages/formula/src/stdlib.ts#registerStdLib (registerStdLib — the 29 chained registerFunction calls, isoDate / isoDatetime since #22347, plus label(), the macro bindOptionLabel registers (#26608): 30 bare-callable; the receiver methods `can` and `hasCapability`, from bindPermissionPredicate and bindCapabilityPredicate, are outside the count; calendarDayUtc ADR-0053 D1; addMonthsUtc clamp; the floor/ceil #3306 note), (registerNumericCoercions #1928)", "packages/formula/src/validate.ts#CEL_STDLIB_FUNCTIONS (the advertised bare-callable list; its docblock tallies the 30 registerStdLib adds and why a receiver-only name such as `can` is never in it)", "packages/lint/src/authoring-rules.ts#EXPRESSION_INVALID (the expression-invalid rule the object save door now dispatches on a formula field, #22031) + packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts (the #22019 block: sqrt(record.amount) refused 422 INVALID_METADATA on an active save and on a draft's promotion, nothing lands) + packages/lint/src/runtime-gate.object-formula-writes.test.ts + packages/objectql/src/engine-formula-fault-log.test.ts (the read-path residual: a pre-gate row still reads null, one warn line per object and field)", - "packages/objectql/src/engine.ts#applyFormulaPlan (applyFormulaPlan — evaluate-fault → null, the silent-null contract; the evaluateFormulaField docblock states the compile-throws vs evaluate-nulls split)", + "packages/objectql/src/engine-top-level.ts#applyFormulaPlan (applyFormulaPlan — evaluate-fault → null, the silent-null contract; the evaluateFormulaField docblock states the compile-throws vs evaluate-nulls split)", "content/docs/data-modeling/formulas.mdx (the advertised catalog the matrix cross-checks)", "examples/app-showcase/src/data/objects/field-zoo.object.ts + project.object.ts (the stock live-leg formulas)", "ADR-0053 Phase 2 D1 (calendar-day timezone contract)", diff --git a/packages/spec/liveness/query.json b/packages/spec/liveness/query.json index 339b71fab0c..a08645d37ae 100644 --- a/packages/spec/liveness/query.json +++ b/packages/spec/liveness/query.json @@ -11,8 +11,8 @@ "fields": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/drivers/driver-sql/src/sql-driver.ts#findRows (`builder.select(query.fields.map(...))`, else `select('*')`, with the unknown-column ladder that retries all-columns rather than answering an empty page); packages/objectql/src/engine.ts#planFormulaProjection (rewrites `ast.fields` to drop virtual formula fields before the driver sees them, and adds back the stored fields they read)", - "note": "2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — `sql-driver.ts:1353-1354` had rotted onto `) as Error & { code?: string; status?: number };` inside an error-constructor helper, ~4,200 lines above the projection. sql-driver.ts is 16,289 lines; the whole find path moved into `findRows` under a citation nothing re-read. The `driver-memory projectFields` / `objectql formula projection` halves were prose with no path at all and are now a repo-rooted anchor on the formula side. Re-closed by hand against 93ea19bca." + "evidence": "packages/drivers/driver-sql/src/sql-driver.ts#findRows (`builder.select(query.fields.map(...))`, else `select('*')`, with the unknown-column ladder that retries all-columns rather than answering an empty page); packages/objectql/src/engine-top-level.ts#planFormulaProjection (rewrites `ast.fields` to drop virtual formula fields before the driver sees them, and adds back the stored fields they read)", + "note": "2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — `sql-driver.ts:1353-1354` had rotted onto `) as Error & { code?: string; status?: number };` inside an error-constructor helper, ~4,200 lines above the projection. sql-driver.ts is 16,289 lines; the whole find path moved into `findRows` under a citation nothing re-read. The `driver-memory projectFields` / `objectql formula projection` halves were prose with no path at all and are now a repo-rooted anchor on the formula side. Re-closed by hand against 93ea19bca. 2026-11-07: REPOINTED to `engine-top-level.ts#planFormulaProjection`, its declaration: since #30020 E2 moved the `find` / `findOne` bodies that call it into `engine-find.ts`, engine.ts no longer imports the name." }, "where": { "status": "live", @@ -53,8 +53,8 @@ "top": { "status": "live", "verifiedAt": "2026-08-28", - "evidence": "packages/objectql/src/engine.ts#ENGINE_QUERY_SLOTS (the engine's slot set — the `where` and `limit` canonicals, of which `top` is the OData alias); packages/objectql/src/engine.ts#foldEngineOptionAliases (applied on `find`/`findOne` before anything reads the AST, so the driver only ever sees `limit`); packages/metadata-protocol/src/protocol-list-query-params.ts#WIRE_DOLLAR_ALIASES (`['$top', 'top']` — the wire spelling folded on the REST list path)", - "note": "2026-08-28: RE-ANCHORED (commit 8cb96ec41), REPOINTED, and the MECHANISM had moved too — this is the one entry in the batch where re-closing the call graph changed the story rather than just the coordinates. `engine.ts:2850-2853` claimed an inline `top → limit` normalization; both citations had rotted onto docblocks (`:2853` in engine.ts, `:3486` in protocol.ts), and the inline fold no longer exists: the alias now lives in the spec's ONE alias table (`RPC_QUERY_ALIAS_SLOTS`), filtered into `ENGINE_QUERY_SLOTS` and applied by `foldEngineOptionAliases` at every engine entry point (#4346/#4371). The verdict `live` was never in doubt; the pointer had stopped describing how. Re-closed by hand against 93ea19bca." + "evidence": "packages/objectql/src/engine-top-level.ts#ENGINE_QUERY_SLOTS (the engine's slot set — the `where` and `limit` canonicals, of which `top` is the OData alias); packages/objectql/src/engine-find.ts#foldEngineOptionAliases (applied on `find`/`findOne` before anything reads the AST, so the driver only ever sees `limit`); packages/metadata-protocol/src/protocol-list-query-params.ts#WIRE_DOLLAR_ALIASES (`['$top', 'top']` — the wire spelling folded on the REST list path)", + "note": "2026-08-28: RE-ANCHORED (commit 8cb96ec41), REPOINTED, and the MECHANISM had moved too — this is the one entry in the batch where re-closing the call graph changed the story rather than just the coordinates. `engine.ts:2850-2853` claimed an inline `top → limit` normalization; both citations had rotted onto docblocks (`:2853` in engine.ts, `:3486` in protocol.ts), and the inline fold no longer exists: the alias now lives in the spec's ONE alias table (`RPC_QUERY_ALIAS_SLOTS`), filtered into `ENGINE_QUERY_SLOTS` and applied by `foldEngineOptionAliases` at every engine entry point (#4346/#4371). The verdict `live` was never in doubt; the pointer had stopped describing how. Re-closed by hand against 93ea19bca. 2026-11-07: REPOINTED the two engine legs: the slot set to its declaration, `engine-top-level.ts#ENGINE_QUERY_SLOTS`, and the fold to `engine-find.ts`, where the `find` / `findOne` bodies that apply it have lived since #30020 E2 moved them out of engine.ts, which no longer imports `ENGINE_QUERY_SLOTS`." }, "cursor": { "status": "dead",