From 3986db0ebb7c6f2659f081c2277cbe89c92f3ac7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:45:47 +0000 Subject: [PATCH 1/4] fix(objectql): a validate() preview binds the master-detail header the write binds The preview's one evaluateValidationRules call bound no `parent`, so a field requiredWhen reading the header refused, as unevaluable, rows the insert and the update admit. It now binds `parent` from the judged view (payload FK first, then the stored row) and, for a repointing update, `previousParent` from the stored row, under the write's own gate (hasParentScopedRequiredWhen). Unlike the write, which reads the header elevated, the preview asks the read door first under the caller's context: a header the caller cannot read binds as a missing one, and a header column is kept only where the read door served the caller that very value (servedAsStored), so a hidden or masked column is judged as empty. A parity test reads engine.ts and fails when a write-side evaluateValidationRules call passes an input the preview's call does not. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude --- packages/objectql/src/engine.ts | 171 +++++- .../src/validate-preview-parent.test.ts | 522 ++++++++++++++++++ 2 files changed, 682 insertions(+), 11 deletions(-) create mode 100644 packages/objectql/src/validate-preview-parent.test.ts diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 5ca77a148de..27ca04411e8 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3207,18 +3207,20 @@ export interface EngineReadOptions { context?: ExecutionContext; } -/** Merge read-path execution context from the query and the trailing options. */ /** * [#22445] Did the read door serve this value AS STORED — can the caller read * the stored value in full? The `update`-mode preview keeps a stored column - * only when this holds for it (see `storedRows` in `ObjectQL.validate`). + * only when this holds for it (see `storedRows` in `ObjectQL.validate`), and + * [#22474] the preview keeps a master-detail header column only when this + * holds for it (see `ObjectQL.resolvePreviewParents`). * * Both values come off the same driver read shape (`driver.findOne` under the - * same `buildDriverOptions`), so a column nothing transformed compares equal - * by construction; what differs is what the read path REWROTE for this caller - * (a partial mask, a masked secret, a read hook's rewrite). Structural, not - * identity: the two reads are two driver calls, so a `Date`, an array or a - * JSON object arrives as two equal objects. + * same `buildDriverOptions`; for a header, the read door's `find` under the + * caller's context and under the write's elevated one), so a column nothing + * transformed compares equal by construction; what differs is what the read + * path REWROTE for this caller (a partial mask, a masked secret, a read hook's + * rewrite). Structural, not identity: the two reads are two driver calls, so a + * `Date`, an array or a JSON object arrives as two equal objects. * * ⛔ Fails CLOSED: a pair it cannot prove equal — another object kind (a * buffer, a class instance), a nesting past the depth bound — answers `false`, @@ -3247,6 +3249,7 @@ function servedAsStored(served: unknown, stored: unknown, depth = 0): boolean { && servedKeys.every((k) => Object.prototype.hasOwnProperty.call(stored, k) && servedAsStored(served[k], stored[k], depth + 1)); } +/** Merge read-path execution context from the query and the trailing options. */ function mergeReadContext( fromQuery?: ExecutionContext, fromOptions?: ExecutionContext, @@ -8718,6 +8721,106 @@ export class ObjectQL implements IObjectQLEngine { }; } + /** + * [#22474] The master-detail headers a `validate()` preview binds, per row: + * `parents[i]` is the header row `i` is judged under (`parent`), and + * `previousParents[i]` the header its stored row hangs off when the row + * REPOINTS it (`previousParent`, for the ADR-0113 pre-check), else + * `undefined`, which the evaluator reads as "the same header", as the + * write's own `undefined` is read. + * + * The header ids are read the way the write reads them ({@link masterIdOf}): + * `views[i]` is the judged view, the row on an insert and the stored row + * merged with the patch on an update, so the patch's FK wins over the stored + * one; `storedRows[i]` names the header a repoint leaves. + * + * ## Read under the caller's access, not elevated + * + * The write reads the header ELEVATED ({@link resolveMasterDetailParents}): + * the caller's right to write the detail was settled by the write's gates + * before the header is read. The preview runs none of those gates, so a + * header read elevated here would answer a predicate over a row the caller + * may not read. Two reads, each answering one question, as the preview's + * stored-row read does: + * + * 1. ⛔ MAY THIS CALLER SEE THE HEADER — the READ DOOR, under the caller's + * own context, one `find` for the whole batch. A header it does not + * return binds as a missing header binds: `null`, so a predicate that + * reads `parent` refuses as unevaluable, whatever the header holds. A + * failed read binds every header as missing, as the write's own failed + * header read does. + * 2. WHAT THE WRITE WOULD JUDGE — the write's own header read + * ({@link resolveMasterDetailParents}), for the headers read 1 returned + * only. + * + * ⛔ A header column is kept only where the read door served this caller + * that very value ({@link servedAsStored}); every other column, hidden or + * served transformed (a partial mask keeps the key and replaces the value), + * is judged as empty: the header is materialised over the master's declared + * fields after the columns are kept, so such a column reads `null`. So the + * verdict never depends on a header value the caller could not have read in + * full. + */ + private async resolvePreviewParents( + schema: unknown, + views: ReadonlyArray>, + storedRows: ReadonlyArray | undefined>, + context: ExecutionContext | undefined, + ): Promise<{ + parents: Array | null>; + previousParents: Array | null | undefined>; + }> { + const rel = resolveMasterDetailRelation(schema as any); + const landingIds = views.map((view) => (rel ? masterIdOf(rel.fk, null, view) : undefined)); + const priorIds = storedRows.map((stored) => (rel && stored ? masterIdOf(rel.fk, null, stored) : undefined)); + // The write resolves a second header only for a repoint (`repointsMaster`). + const repoints = priorIds.map((prior, i) => prior != null && landingIds[i] !== prior); + const ids = new Set(); + landingIds.forEach((id) => { if (id != null) ids.add(String(id)); }); + priorIds.forEach((id, i) => { if (repoints[i]) ids.add(String(id)); }); + + const headers = new Map>(); + if (rel && ids.size > 0) { + // 1. The read door, under the caller's own context. + const served = new Map>(); + try { + const rows = await this.find(rel.master, { + where: { id: { $in: [...ids] } }, + context, + } as any) as Array>; + for (const row of Array.isArray(rows) ? rows : []) { + if (row?.id != null && ids.has(String(row.id))) served.set(String(row.id), row); + } + } catch (err) { + this.logger?.warn?.('validate preview: the caller\'s read of the master-detail header failed — parent stays unbound', { + object: rel.master, error: err, + }); + } + // 2. The write's own header read, for the headers the caller can read. + if (served.size > 0) { + const asWritten = await this.resolveMasterDetailParents( + schema, null, [...served.keys()].map((id) => ({ [rel.fk]: id })), context, + ); + for (const [id, servedRow] of served) { + const header = asWritten({ [rel.fk]: id }); + if (!header) continue; + const kept: Record = {}; + for (const [key, value] of Object.entries(header)) { + if (Object.prototype.hasOwnProperty.call(servedRow, key) && servedAsStored(servedRow[key], value)) { + kept[key] = value; + } + } + headers.set(id, this.materializeParentHeader(rel.master, kept)); + } + } + } + const headerOf = (id: string | number | undefined) => (id == null ? null : (headers.get(String(id)) ?? null)); + return { + parents: landingIds.map(headerOf), + previousParents: priorIds.map((id, i) => (repoints[i] ? headerOf(id) : undefined)), + }; + } + /** * [#18682] Resolve the related rows this object's PREDICATE rules read one * hop through a reference field, for a whole batch of rows at once. @@ -13046,10 +13149,24 @@ export class ObjectQL implements IObjectQLEngine { * refusal it is. A row with no address, or whose id names no row this * caller can read, is judged on the patch alone, as before. * + * ## The master-detail header is bound as the write binds it (#22474) + * + * Every write binds the detail row's master-detail header for a field + * `requiredWhen` that reads `parent`, and an update that repoints the row + * binds the header it leaves as `previousParent` for the ADR-0113 + * pre-check. A preview that bound neither refused, as unevaluable, rows the + * write admits. So the preview binds both, from the same view and under the + * same gate as the write (an object with no parent-scoped `requiredWhen` + * reads no header). ⛔ Unlike the write, it reads the header under the + * caller's own access ({@link resolvePreviewParents}): a header the caller + * cannot read binds as a missing one, and a header column the read door + * served transformed or not at all is judged as empty. + * * Nothing is written and no sequence is consumed. Reads do happen: the - * stored row above, and the related rows a traversing rule names (see the - * `previewRelatedForRow` block below). Validation itself is in-process, - * which is what makes row-by-row dry run of a large import affordable. + * stored row above, the master-detail headers, and the related rows a + * traversing rule names (see the `previewRelatedForRow` block below). + * Validation itself is in-process, which is what makes row-by-row dry run + * of a large import affordable. */ async validate( object: string, @@ -13090,7 +13207,10 @@ export class ObjectQL implements IObjectQLEngine { // (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). Nothing is + // 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 @@ -13310,6 +13430,30 @@ export class ObjectQL implements IObjectQLEngine { // 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, unlike the write, which reads the + // header elevated: see {@link resolvePreviewParents}. A header the caller + // cannot read binds as a missing header binds, and 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 @@ -13391,6 +13535,11 @@ export class ObjectQL implements IObjectQLEngine { // 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), }); diff --git a/packages/objectql/src/validate-preview-parent.test.ts b/packages/objectql/src/validate-preview-parent.test.ts new file mode 100644 index 00000000000..fff1df3c748 --- /dev/null +++ b/packages/objectql/src/validate-preview-parent.test.ts @@ -0,0 +1,522 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #22474 — a `validate()` preview binds the master-detail header the write +// binds: `parent` in both modes and, in `update` mode, `previousParent`. +// +// ## The defect +// +// The preview's one `evaluateValidationRules` call bound no `parent`, while +// every write call binds it. So a field rule reading the header — +// `requiredWhen: "parent.status == 'sent'"` — faulted in the preview +// (`Unknown variable: parent`) and refused the row as unevaluable, while the +// insert or update it predicts admitted it. The import dry run rides on the +// preview, so a detail import was refused for a rule fault that is not one. +// +// ## The fix +// +// The preview resolves the header the way the write resolves it: from the +// row's master-detail reference (payload first, then the stored row) and only +// when the object declares a parent-scoped `requiredWhen`, the write's own +// gate. For a repointing update it also binds the stored row's own header as +// `previousParent`, as the write does for the ADR-0113 pre-check. +// +// ⛔ Read under the caller's access. The write reads the header elevated; the +// preview asks the READ DOOR first, under the caller's own context. A header +// the caller cannot read gets the verdict a missing header gets, and a header +// column is kept only where the read door served the caller that very value, +// so a hidden or masked column is judged as empty. +// +// ## Pins +// +// (a) insert and update: admitted under a draft header and refused under a +// sent header, exactly as the write; +// (b) a repoint reads the prior header for the pre-check, as the write does; +// (c) control: an object with no parent-scoped rule reads no header; +// (d) a caller who cannot read the header, or is served a header column +// hidden or masked, gets a verdict identical whatever the stored header +// holds; a caller who reads it in full gets the write's verdict; +// (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). +// +// (a), (b) and (e) were red before the fix. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import * as ts from 'typescript'; +import { ObjectKernel, runImport, type ImportProtocolLike } from '@objectstack/core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; +import { ValidationError } from './validation/record-validator.js'; + +const HEADER = 'pv_invoice'; +const LINE = 'pv_invoice_line'; +const PLAIN_LINE = 'pv_plain_line'; + +const HEADER_OBJECT = { + name: HEADER, + label: 'Preview Invoice', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + owner_name: { name: 'owner_name', label: 'Owner', type: 'text' }, + status: { + name: 'status', label: 'Status', type: 'select', + options: [{ label: 'Draft', value: 'draft' }, { label: 'Sent', value: 'sent' }], + }, + }, +}; + +const LINE_OBJECT = { + name: LINE, + label: 'Preview Invoice Line', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + invoice: { name: 'invoice', label: 'Invoice', type: 'master_detail', reference: HEADER }, + // The card's subject: once the header is sent, every line carries a description. + description: { name: 'description', label: 'Description', type: 'text', requiredWhen: "parent.status == 'sent'" }, + quantity: { name: 'quantity', label: 'Quantity', type: 'number' }, + }, +}; + +/** The control: the same relation, and only a row-scoped requirement. */ +const PLAIN_LINE_OBJECT = { + name: PLAIN_LINE, + label: 'Preview Plain Line', + fields: { + invoice: { name: 'invoice', label: 'Invoice', type: 'master_detail', reference: HEADER }, + description: { name: 'description', label: 'Description', type: 'text', requiredWhen: 'record.quantity > 100' }, + quantity: { name: 'quantity', label: 'Quantity', type: 'number' }, + }, +}; + +/** A store-backed driver over several objects, counting the reads of each. */ +function makeStoreDriver() { + const stores = new Map>>(); + const reads = new Map(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const read = (o: string) => { reads.set(o, (reads.get(o) ?? 0) + 1); }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k.startsWith('$')) throw new Error(`store driver: unsupported combinator ${k}`); + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + return Object.entries(v).every(([op, target]) => { + if (op === '$eq') return row?.[k] === target; + if (op === '$in') return Array.isArray(target) && target.includes(row?.[k]); + throw new Error(`store driver: unsupported operator ${op}`); + }); + } + return row?.[k] === v; + }); + }; + let n = 0; + const driver: any = { + name: 'pv-parent-store', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async syncSchema() {}, + async find(o: string, ast: any) { + read(o); + const hits = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return (typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits).map((r) => ({ ...r })); + }, + async findOne(o: string, ast: any) { + read(o); + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(o: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `rec_${n}`; + const row = { ...data, id }; + storeFor(o).set(id, row); + return { ...row }; + }, + async update(o: string, id: string, data: Record) { + const row = { ...storeFor(o).get(id), ...data, id }; + storeFor(o).set(id, row); + return { ...row }; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).length; + }, + }; + return { driver, storeFor, reads }; +} + +function makeEngine() { + const store = makeStoreDriver(); + const engine = new ObjectQL(); + engine.registerDriver(store.driver, true); + engine.registerApp({ id: 'pv', name: 'Preview Parent', objects: [HEADER_OBJECT, LINE_OBJECT, PLAIN_LINE_OBJECT] } as any); + const headers = store.storeFor(HEADER); + headers.set('h_draft', { id: 'h_draft', name: 'draft one', owner_name: 'alice', status: 'draft' }); + headers.set('h_sent', { id: 'h_sent', name: 'sent one', owner_name: 'alice', status: 'sent' }); + return { engine, ...store }; +} + +type Finding = { field: string; code: string }; +type Verdict = { valid: boolean; errors: Finding[] }; + +/** The one row's preview verdict, reduced to what a caller branches on. */ +async function preview( + engine: ObjectQL, + object: string, + data: Record, + mode: 'insert' | 'update', + context?: Record, +): Promise { + const out = await engine.validate(object, data, { mode, ...(context ? { context: context as any } : {}) }); + expect(out.results).toHaveLength(1); + const row = out.results![0]!; + return { valid: row.valid, errors: row.errors.map((e) => ({ field: e.field, code: e.code })) }; +} + +/** The write's verdict in the same shape: admitted, or the findings it refused with. */ +async function write(run: () => Promise): Promise { + try { + await run(); + return { valid: true, errors: [] }; + } catch (e) { + expect(e).toBeInstanceOf(ValidationError); + const err = e as ValidationError; + expect(err.code).toBe('VALIDATION_FAILED'); + return { valid: false, errors: err.fields.map((f) => ({ field: f.field, code: f.code })) }; + } +} + +const ADMITTED: Verdict = { valid: true, errors: [] }; +const REQUIRED: Verdict = { valid: false, errors: [{ field: 'description', code: 'required' }] }; + +describe('#22474 — the preview binds the master-detail header the write binds', () => { + let engine: ObjectQL; + let storeFor: (o: string) => Map>; + let reads: Map; + + beforeEach(() => { + ({ engine, storeFor, reads } = makeEngine()); + }); + + describe('(a) insert: admitted under a draft header, refused under a sent one, as the insert', () => { + it.each([ + ['a draft header', 'h_draft', ADMITTED], + ['a sent header', 'h_sent', REQUIRED], + ])('%s', async (_label, invoice, expected) => { + const row = { name: 'l', invoice, quantity: 1 }; + expect(await preview(engine, LINE, row, 'insert')).toEqual(expected); + expect(await write(() => engine.insert(LINE, row))).toEqual(expected); + }); + + it('a supplied description is admitted under a sent header', async () => { + const row = { name: 'l', invoice: 'h_sent', description: 'seat', quantity: 1 }; + expect(await preview(engine, LINE, row, 'insert')).toEqual(ADMITTED); + }); + }); + + describe('(a) update by id: the header is read off the stored row, as the by-id update reads it', () => { + it.each([ + ['a draft header', 'h_draft', ADMITTED], + ['a sent header', 'h_sent', REQUIRED], + ])('clearing the description under %s', async (_label, invoice, expected) => { + storeFor(LINE).set('l1', { id: 'l1', name: 'l', invoice, description: 'seat', quantity: 1 }); + const patch = { description: null, id: 'l1' }; + expect(await preview(engine, LINE, patch, 'update')).toEqual(expected); + expect(await write(() => engine.update(LINE, patch))).toEqual(expected); + }); + + it('an unrelated edit under a draft header is admitted', async () => { + storeFor(LINE).set('l1', { id: 'l1', name: 'l', invoice: 'h_draft', quantity: 1 }); + expect(await preview(engine, LINE, { quantity: 2, id: 'l1' }, 'update')).toEqual(ADMITTED); + }); + }); + + describe('(b) a rule reading the prior header behaves the same in update mode', () => { + it('a repoint from a draft onto a sent header is refused: the stored row complied', async () => { + storeFor(LINE).set('l2', { id: 'l2', name: 'l', invoice: 'h_draft', quantity: 1 }); + const patch = { invoice: 'h_sent', id: 'l2' }; + // Judged against the header it lands on, and pre-checked against the one it leaves. + expect(await write(() => engine.update(LINE, patch))).toEqual(REQUIRED); + expect(await preview(engine, LINE, patch, 'update')).toEqual(REQUIRED); + }); + + it('a legacy row under a sent header rests through a repoint onto another sent header', async () => { + storeFor(HEADER).set('h_sent2', { id: 'h_sent2', name: 'sent two', owner_name: 'alice', status: 'sent' }); + storeFor(LINE).set('l3', { id: 'l3', name: 'l', invoice: 'h_sent', quantity: 1 }); + const patch = { invoice: 'h_sent2', id: 'l3' }; + expect(await write(() => engine.update(LINE, patch))).toEqual(ADMITTED); + expect(await preview(engine, LINE, patch, 'update')).toEqual(ADMITTED); + }); + }); + + describe('(c) control: an object with no parent-scoped rule reads no header', () => { + it('insert and update previews of a plain line issue no read of the header object', async () => { + storeFor(PLAIN_LINE).set('p1', { id: 'p1', invoice: 'h_sent', quantity: 1 }); + reads.clear(); + expect(await preview(engine, PLAIN_LINE, { invoice: 'h_sent', quantity: 1 }, 'insert')).toEqual(ADMITTED); + expect(await preview(engine, PLAIN_LINE, { quantity: 2, id: 'p1' }, 'update')).toEqual(ADMITTED); + expect(reads.get(HEADER) ?? 0).toBe(0); + }); + + it('positive control: the parent-scoped line does read it', async () => { + reads.clear(); + await preview(engine, LINE, { invoice: 'h_draft', quantity: 1 }, 'insert'); + expect(reads.get(HEADER) ?? 0).toBeGreaterThan(0); + }); + }); + + describe('(d) the header is read under the caller\'s access', () => { + /** Read scoping shaped like the security/sharing middlewares: a caller reads only its own headers. */ + function scopeHeaderReadsToOwner(ql: ObjectQL) { + ql.registerMiddleware(async (ctx: any, next: () => Promise) => { + const userId = ctx.context?.userId; + if (ctx.object === HEADER && userId && !ctx.context?.isSystem && ['find', 'findOne'].includes(ctx.operation)) { + const scoped = { owner_name: userId }; + const ast: any = ctx.ast ?? { object: ctx.object }; + ast.where = ast.where ? { $and: [ast.where, scoped] } : scoped; + ctx.ast = ast; + } + await next(); + }); + } + + /** + * A field-level read rule on the header's `status`, shaped like the + * security middleware's result mask: `hide` deletes the key, `mask` keeps + * it and replaces the value (`field-masker.ts` `maskRecord`). A system + * read is not masked, as the security middleware does not mask one. + */ + function restrictStatusFor(ql: ObjectQL, userId: string, how: 'hide' | 'mask') { + ql.registerMiddleware(async (ctx: any, next: () => Promise) => { + await next(); + if (ctx.object !== HEADER || ctx.context?.isSystem || ctx.context?.userId !== userId) return; + if (!['find', 'findOne'].includes(ctx.operation)) return; + const apply = (r: any) => { + if (!r || typeof r !== 'object') return; + if (how === 'hide') delete r.status; + else if (typeof r.status === 'string') r.status = '*'.repeat(r.status.length); + }; + if (Array.isArray(ctx.result)) ctx.result.forEach(apply); + else apply(ctx.result); + }); + } + + const setHeaderStatus = (status: string) => { + storeFor(HEADER).set('h_x', { id: 'h_x', name: 'x', owner_name: 'alice', status }); + }; + + it('a caller who cannot read the header gets the verdict a missing header gets, whatever it holds', async () => { + scopeHeaderReadsToOwner(engine); + const bob = { userId: 'bob' }; + // An update over a stored line naming a header id no row has: the header is missing. + storeFor(LINE).set('l_gone', { id: 'l_gone', name: 'l', invoice: 'nowhere', description: 'seat', quantity: 1 }); + const missing = await preview(engine, LINE, { description: null, id: 'l_gone' }, 'update', bob); + expect(missing.valid).toBe(false); + for (const status of ['draft', 'sent']) { + setHeaderStatus(status); + storeFor(LINE).set('l_x', { id: 'l_x', name: 'l', invoice: 'h_x', description: 'seat', quantity: 1 }); + expect(await preview(engine, LINE, { description: null, id: 'l_x' }, 'update', bob)).toEqual(missing); + // Insert mode answers one verdict too. + expect(await preview(engine, LINE, { name: 'l', invoice: 'h_x', quantity: 1 }, 'insert', bob)) + .toEqual(await preview(engine, LINE, { name: 'l', invoice: 'nowhere', quantity: 1 }, 'insert', bob)); + } + }); + + it.each(['hide', 'mask'] as const)('a header column served %s is judged as empty, whatever it holds', async (how) => { + restrictStatusFor(engine, 'erin', how); + const erin = { userId: 'erin' }; + const verdicts: Verdict[] = []; + for (const status of ['draft', 'sent']) { + setHeaderStatus(status); + // Precondition: the read door serves the header to erin, with `status` restricted. + const served = await engine.findOne(HEADER, { where: { id: 'h_x' }, context: erin } as any); + expect(served).toMatchObject({ id: 'h_x' }); + expect(served?.status === status).toBe(false); + verdicts.push(await preview(engine, LINE, { name: 'l', invoice: 'h_x', quantity: 1 }, 'insert', erin)); + storeFor(LINE).set('l_x', { id: 'l_x', name: 'l', invoice: 'h_x', description: 'seat', quantity: 1 }); + verdicts.push(await preview(engine, LINE, { description: null, id: 'l_x' }, 'update', erin)); + } + // Identical per mode whatever the header holds: [insert, update] for draft equals it for sent. + expect(verdicts.slice(2)).toEqual(verdicts.slice(0, 2)); + // Judged as empty: `null == 'sent'` is a clean false, so nothing is required. + expect(verdicts.slice(0, 2)).toEqual([ADMITTED, ADMITTED]); + }); + + it('control: a caller who reads the header in full gets the write\'s verdict', async () => { + scopeHeaderReadsToOwner(engine); + restrictStatusFor(engine, 'erin', 'mask'); + const alice = { userId: 'alice' }; + for (const [status, expected] of [['draft', ADMITTED], ['sent', REQUIRED]] as const) { + setHeaderStatus(status); + const row = { name: 'l', invoice: 'h_x', quantity: 1 }; + expect(await preview(engine, LINE, row, 'insert', alice)).toEqual(expected); + expect(await write(() => engine.insert(LINE, row, { context: alice } as any))).toEqual(expected); + storeFor(LINE).set('l_x', { id: 'l_x', name: 'l', invoice: 'h_x', description: 'seat', quantity: 1 }); + const patch = { description: null, id: 'l_x' }; + expect(await preview(engine, LINE, patch, 'update', alice)).toEqual(expected); + expect(await write(() => engine.update(LINE, patch, { context: alice } as any))).toEqual(expected); + } + }); + }); +}); + +describe('#22474 (e) — the import dry run of a detail row, through the protocol', () => { + let kernel: ObjectKernel; + let objectql: ObjectQL; + let store: ReturnType; + + beforeEach(async () => { + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + store = makeStoreDriver(); + await kernel.use({ + name: 'pv-parent-store-plugin', type: 'driver', version: '1.0.0', + init: async (ctx: any) => { ctx.registerService('driver.pv-parent-store', store.driver); }, + } as any); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + objectql = kernel.getService('objectql'); + for (const obj of [HEADER_OBJECT, LINE_OBJECT]) { + objectql.registry.registerObject({ ...obj, datasource: 'pv-parent-store' } as any, 'test', 'test'); + } + store.storeFor(HEADER).set('h_draft', { id: 'h_draft', name: 'draft one', status: 'draft' }); + store.storeFor(HEADER).set('h_sent', { id: 'h_sent', name: 'sent one', status: 'sent' }); + }); + + afterEach(async () => { + if (kernel.getState() === 'running') await kernel.shutdown(); + }); + + // The FK cell carries the header id itself: no `reference` in the column + // meta, so the importer hands it to the engine as written. + const metaMap = new Map([ + ['name', { name: 'name', type: 'text' }], + ['invoice', { name: 'invoice', type: 'master_detail' }], + ['description', { name: 'description', type: 'text' }], + ['quantity', { name: 'quantity', type: 'number' }], + ]); + + const importLines = (rowsIn: Record[], writeMode: 'insert' | 'update', dryRun: boolean) => runImport({ + p: new ObjectStackProtocolImplementation(objectql as never) as unknown as ImportProtocolLike, + objectName: LINE, + metaMap, + writeMode, + ...(writeMode === 'update' ? { matchFields: ['name'] } : {}), + dryRun, + runAutomations: false, + trimWhitespace: true, + createMissingOptions: false, + skipBlankMatchKey: false, + context: { userId: 'usr_importer' }, + rows: rowsIn, + } as any); + + const outcome = (s: Awaited>) => + s.results.map((r) => ({ ok: r.ok, action: r.action, ...(r.ok ? {} : { field: r.field, code: r.code }) })); + + it.each([ + ['a draft header', 'h_draft', [{ ok: true, action: 'created' }]], + ['a sent header', 'h_sent', [{ ok: false, action: 'failed', field: 'description', code: 'required' }]], + ])('a created line under %s — the dry run answers what the import does', async (_label, invoice, expected) => { + const cells = { name: 'new line', invoice, quantity: '1' }; + const dry = await importLines([cells], 'insert', true); + expect(outcome(dry)).toEqual(expected); + expect(store.storeFor(LINE).size).toBe(0); + const real = await importLines([cells], 'insert', false); + expect(outcome(real)).toEqual(outcome(dry)); + }); + + // A matched line with no description, under a draft header: an edit keeps it + // there, and a repoint moves it onto a sent header, which the stored row's + // own header (`previousParent`) shows it did not violate before. + it.each([ + ['an edit under its draft header', { name: 'line', quantity: '2' }, [{ ok: true, action: 'updated' }]], + ['a repoint onto a sent header', { name: 'line', invoice: 'h_sent' }, + [{ ok: false, action: 'failed', field: 'description', code: 'required' }]], + ])('a matched line, %s — the dry run answers what the import does', async (_label, cells, expected) => { + store.storeFor(LINE).set('rec_l', { id: 'rec_l', name: 'line', invoice: 'h_draft', quantity: 1 }); + const dry = await importLines([cells], 'update', true); + expect(outcome(dry)).toEqual(expected); + expect(store.storeFor(LINE).get('rec_l')).toEqual({ id: 'rec_l', name: 'line', invoice: 'h_draft', quantity: 1 }); + const real = await importLines([cells], 'update', false); + expect(outcome(real)).toEqual(outcome(dry)); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// (f) PARITY — every input a write binds, the preview binds. +// +// Read from `engine.ts` 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, +// 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 }; + +function ruleCallsIn(source: string): RuleCall[] { + const sf = ts.createSourceFile('engine.ts', 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)}`); + } + 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)}`); + } + return keys; + }; + const visit = (node: ts.Node, method: string | undefined): void => { + const here = ts.isMethodDeclaration(node) && 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`); + const mode = ts.isStringLiteral(modeArg) ? modeArg.text : modeArg.getText(sf); + calls.push({ method: here, mode, keys: [...new Set(keysOf(optsArg, line))].sort(), line }); + } + ts.forEachChild(node, (child) => visit(child, here)); + }; + visit(sf, undefined); + return calls; +} + +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 previews = calls.filter((c) => c.method === 'validate'); + const writes = calls.filter((c) => c.method !== 'validate'); + + it('finds the one preview call and the write calls of both modes', () => { + expect(previews).toHaveLength(1); + // The preview evaluates the caller's mode; the writes name theirs. + expect(previews[0]!.mode).toBe('mode'); + expect(writes.length).toBeGreaterThanOrEqual(4); + expect(new Set(writes.map((w) => w.mode))).toEqual(new Set(['insert', 'update'])); + }); + + it('every key a write call passes, the preview 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})`)); + expect(missing).toEqual([]); + }); + + it('the preview passes no input that no write passes', () => { + const writeKeys = new Set(writes.flatMap((w) => w.keys)); + expect(previews[0]!.keys.filter((k) => !writeKeys.has(k))).toEqual([]); + }); +}); From 1fa886dccab0bd2756b56ac9ce67b0e41deb6a05 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:46:58 +0000 Subject: [PATCH 2/4] chore(changeset): objectql minor for the preview binding the master-detail header Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude --- .changeset/22474-preview-binds-parent.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/22474-preview-binds-parent.md diff --git a/.changeset/22474-preview-binds-parent.md b/.changeset/22474-preview-binds-parent.md new file mode 100644 index 00000000000..6fcb244db2f --- /dev/null +++ b/.changeset/22474-preview-binds-parent.md @@ -0,0 +1,14 @@ +--- +'@objectstack/objectql': minor +--- + +A `validate()` preview binds the master-detail header the write binds, so an import dry run of a detail row whose field rule reads `parent` admits the rows the import admits (#22474) + +Clause-②: yes + +The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. + +- **Before.** A preview (`engine.validate(object, rows, { mode })`, `validateData`, and the import dry run) bound no master-detail header. A field `requiredWhen` that reads the header, such as `parent.status == 'sent'` on an invoice line, faulted there and the preview refused the row with `rule_violation` / `unevaluable`, in both modes, although the insert and the update resolve the header and admit it under a draft header. +- **Now.** The preview binds `parent` as the write binds it: from the row's master-detail reference on an insert, and on an update from the reference the patch carries, else from the stored row the row's `id` names. An update that moves the row to another header also binds the header the stored row hangs off, which the write reads to decide whether the stored row already violated the rule. So a row under a draft header is admitted and a row that leaves the field empty under a sent header is refused with `required`, as the write refuses it. Only an object that declares a field `requiredWhen` reading `parent` reads a header, as on the write. +- **Read under the caller's access.** The write reads the header with system access, kept to the caller's organization. The preview first reads it through the engine's read door, under the caller's own context: a header the caller cannot read gets the verdict a missing header gets. A header column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a header value the caller could not read in full. For such a caller the preview can therefore answer differently from the write, which judges the stored header. +- **Unchanged.** An object with no field `requiredWhen` reading `parent` reads no header and gets the verdict it got before. The preview still runs no `readonlyWhen` strip, so an update whose `readonlyWhen` would keep the row on its stored header is judged against the header the patch names. From 754bd6119c7a37328e3920bfd4b9ed5a4e72c496 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:10:49 +0000 Subject: [PATCH 3/4] test(objectql): count header reads through the read door, not inside the driver double The driver double's find carried a read counter backed by a Map, which the objectql-double-limit gate's probe stubs, so the double could not be judged. The control now counts header reads with an engine middleware, which sees every read of the header object under any context. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude --- .../src/validate-preview-parent.test.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/objectql/src/validate-preview-parent.test.ts b/packages/objectql/src/validate-preview-parent.test.ts index fff1df3c748..4e00d47bfc8 100644 --- a/packages/objectql/src/validate-preview-parent.test.ts +++ b/packages/objectql/src/validate-preview-parent.test.ts @@ -91,16 +91,14 @@ const PLAIN_LINE_OBJECT = { }, }; -/** A store-backed driver over several objects, counting the reads of each. */ +/** A store-backed driver over several objects: what is stored is what a later read answers. */ function makeStoreDriver() { const stores = new Map>>(); - const reads = new Map(); const storeFor = (o: string) => { let s = stores.get(o); if (!s) { s = new Map(); stores.set(o, s); } return s; }; - const read = (o: string) => { reads.set(o, (reads.get(o) ?? 0) + 1); }; const matches = (row: any, where: any): boolean => { if (!where || typeof where !== 'object') return true; return Object.entries(where).every(([k, v]: [string, any]) => { @@ -122,12 +120,10 @@ function makeStoreDriver() { async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async syncSchema() {}, async find(o: string, ast: any) { - read(o); const hits = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); return (typeof ast?.limit === 'number' ? hits.slice(0, ast.limit) : hits).map((r) => ({ ...r })); }, async findOne(o: string, ast: any) { - read(o); for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return { ...r }; return null; }, @@ -148,7 +144,7 @@ function makeStoreDriver() { return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).length; }, }; - return { driver, storeFor, reads }; + return { driver, storeFor }; } function makeEngine() { @@ -198,10 +194,9 @@ const REQUIRED: Verdict = { valid: false, errors: [{ field: 'description', code: describe('#22474 — the preview binds the master-detail header the write binds', () => { let engine: ObjectQL; let storeFor: (o: string) => Map>; - let reads: Map; beforeEach(() => { - ({ engine, storeFor, reads } = makeEngine()); + ({ engine, storeFor } = makeEngine()); }); describe('(a) insert: admitted under a draft header, refused under a sent one, as the insert', () => { @@ -256,18 +251,28 @@ describe('#22474 — the preview binds the master-detail header the write binds' }); describe('(c) control: an object with no parent-scoped rule reads no header', () => { + /** Counts every read of the header object, through the read door, under any context. */ + function countHeaderReads(ql: ObjectQL) { + const count = { reads: 0 }; + ql.registerMiddleware(async (ctx: any, next: () => Promise) => { + if (ctx.object === HEADER && ['find', 'findOne'].includes(ctx.operation)) count.reads += 1; + await next(); + }); + return count; + } + it('insert and update previews of a plain line issue no read of the header object', async () => { storeFor(PLAIN_LINE).set('p1', { id: 'p1', invoice: 'h_sent', quantity: 1 }); - reads.clear(); + const count = countHeaderReads(engine); expect(await preview(engine, PLAIN_LINE, { invoice: 'h_sent', quantity: 1 }, 'insert')).toEqual(ADMITTED); expect(await preview(engine, PLAIN_LINE, { quantity: 2, id: 'p1' }, 'update')).toEqual(ADMITTED); - expect(reads.get(HEADER) ?? 0).toBe(0); + expect(count.reads).toBe(0); }); it('positive control: the parent-scoped line does read it', async () => { - reads.clear(); + const count = countHeaderReads(engine); await preview(engine, LINE, { invoice: 'h_draft', quantity: 1 }, 'insert'); - expect(reads.get(HEADER) ?? 0).toBeGreaterThan(0); + expect(count.reads).toBeGreaterThan(0); }); }); From 57c3ce9f575d7ee796af805916f2aaf661d6e393 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:20:31 +0000 Subject: [PATCH 4/4] fix(objectql): type the preview's header read options instead of erasing them check:query-options-erasure counts an any-cast on engine read options; the preview's caller read of the header now carries EngineQueryOptions, and the test's reads and writes pass their context without a cast. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude --- packages/objectql/src/engine.ts | 2 +- packages/objectql/src/validate-preview-parent.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 27ca04411e8..df2300664b3 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -8787,7 +8787,7 @@ export class ObjectQL implements IObjectQLEngine { const rows = await this.find(rel.master, { where: { id: { $in: [...ids] } }, context, - } as any) as Array>; + } as EngineQueryOptions) as Array>; for (const row of Array.isArray(rows) ? rows : []) { if (row?.id != null && ids.has(String(row.id))) served.set(String(row.id), row); } diff --git a/packages/objectql/src/validate-preview-parent.test.ts b/packages/objectql/src/validate-preview-parent.test.ts index 4e00d47bfc8..b21683afd50 100644 --- a/packages/objectql/src/validate-preview-parent.test.ts +++ b/packages/objectql/src/validate-preview-parent.test.ts @@ -340,7 +340,7 @@ describe('#22474 — the preview binds the master-detail header the write binds' for (const status of ['draft', 'sent']) { setHeaderStatus(status); // Precondition: the read door serves the header to erin, with `status` restricted. - const served = await engine.findOne(HEADER, { where: { id: 'h_x' }, context: erin } as any); + const served = await engine.findOne(HEADER, { where: { id: 'h_x' }, context: erin }); expect(served).toMatchObject({ id: 'h_x' }); expect(served?.status === status).toBe(false); verdicts.push(await preview(engine, LINE, { name: 'l', invoice: 'h_x', quantity: 1 }, 'insert', erin)); @@ -361,11 +361,11 @@ describe('#22474 — the preview binds the master-detail header the write binds' setHeaderStatus(status); const row = { name: 'l', invoice: 'h_x', quantity: 1 }; expect(await preview(engine, LINE, row, 'insert', alice)).toEqual(expected); - expect(await write(() => engine.insert(LINE, row, { context: alice } as any))).toEqual(expected); + expect(await write(() => engine.insert(LINE, row, { context: alice }))).toEqual(expected); storeFor(LINE).set('l_x', { id: 'l_x', name: 'l', invoice: 'h_x', description: 'seat', quantity: 1 }); const patch = { description: null, id: 'l_x' }; expect(await preview(engine, LINE, patch, 'update', alice)).toEqual(expected); - expect(await write(() => engine.update(LINE, patch, { context: alice } as any))).toEqual(expected); + expect(await write(() => engine.update(LINE, patch, { context: alice }))).toEqual(expected); } }); });