Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/engine-findone-predicate-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/metadata-core": minor
"@objectstack/objectql": minor
---

feat(metadata-core,objectql): publish `assertEngineFindOnePredicate` — the read-side member of the engine-double contract family (#11957)

`ObjectQL.findOne` applies `limit: 1`, so a query naming no particular record
would return an ARBITRARY row. `requireFindOnePredicate` (#4419) REFUSES that
call. Every in-memory test double in the repo instead read an absent filter as
"match everything" and answered happily, so a production call site that violates
#4419 read as *working* under every unit suite and only failed on a real engine.

That is measured, not hypothetical. `AuthManager.isBootstrapCreation` probed the
bootstrap population with `findOne({ where: [] })` inside a `try/catch`; on a
real engine that throws, the `catch` read the refusal as "users exist", and the
declared first-run bypass became permanently inert on real deployments — while a
641-line unit matrix over the double stayed green, including a case named
"bootstrap: the very first signup is admitted" (#11767).

New public API, mirroring the two write-side dispatch predicates
(`assertEngineDeleteDispatch`, `assertEngineUpdateDispatch`) exactly — the
implementation lives in `@objectstack/metadata-core` so that packages
`@objectstack/objectql` itself depends on can reach it, and `@objectstack/objectql`
re-exports every symbol:

- `assertEngineFindOnePredicate(object, query)` — the line a fake engine's
`findOne` opens with; throws the engine's own message, object name included.
- `resolveEngineFindOnePredicate(object, query)` — the same decision without the
throw, for a double that wants to classify.
- `engineFindOnePredicateRefusalMessage(object)` — the refusal text, so an
assertion pins the producer's wording rather than a paraphrase.
- `ENGINE_FINDONE_PREDICATE_CASES` — the shared conformance case-set, driven
against the REAL engine by
`packages/objectql/src/engine-findone-predicate.test.ts`, so the predicate
cannot drift from `engine.ts` unnoticed.

Nothing is removed and no existing behaviour changes: the engine's own guard is
untouched, and this publishes the decision it already makes so a double can
import it instead of re-deriving it.
287 changes: 287 additions & 0 deletions packages/metadata-core/src/engine-findone-predicate.ts

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions packages/metadata-core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,11 @@ export * from './engine-update-dispatch.js';
// [#11009] The refusal both write dispatches share: a by-id call whose
// `where` carries keys the by-id path would silently discard.
export * from './engine-dispatch-unhonoured-predicate.js';
// [#11957] The READ-side sibling: `ObjectQL.findOne` REFUSES a call that selects
// no particular record (#4419), and every in-memory double answered it happily —
// which is how #11767 shipped a bootstrap bypass that was permanently inert on
// real deployments under a 641-line all-green matrix.
export * from './engine-findone-predicate.js';

// [#4513] The audit-family GOVERNANCE table (#4447) and its normalizer, sunk
// here for the same reason and by the same criterion as the two dispatch
Expand Down
165 changes: 165 additions & 0 deletions packages/objectql/src/engine-findone-predicate.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// objectstack#11957 — the shared `findOne` predicate must be the REAL engine's
// answer, not a second opinion that happens to agree today.
//
// A shared predicate that drifted from `ObjectQL.findOne` would be worse than no
// predicate at all: every fake engine pinned to it would be confidently,
// uniformly wrong, and the gate over them would report success (route-ownership
// rule 3 — prefer failing to falling back). So this file does not test the
// predicate against a table of expectations written next to it. It drives the
// **real engine** with a recording driver over `ENGINE_FINDONE_PREDICATE_CASES`
// and asserts the engine's observed behaviour equals the predicate's verdict,
// case by case — the same construction `engine-delete-dispatch.test.ts` uses for
// the write side, and for the same reason.
//
// If someone changes `requireFindOnePredicate` in `engine.ts` without changing
// `engine-findone-predicate.ts`, this goes red here — the one place where both
// halves are in the room together.

import { describe, it, expect } from 'vitest';
import type { EngineQueryOptions } from '@objectstack/spec/data';
import { ObjectQL } from './engine.js';
import {
ENGINE_FINDONE_PREDICATE_CASES,
engineFindOnePredicateRefusalMessage,
resolveEngineFindOnePredicate,
assertEngineFindOnePredicate,
} from './engine-findone-predicate.js';

const OBJECT = 'task';

/** Records whether the engine ever reached the driver's read path. */
function makeRecordingDriver() {
const calls: Array<{ fn: 'find' | 'findOne'; ast: unknown }> = [];
const driver: any = {
name: 'recording',
version: '0.0.0',
supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
async find(_o: string, ast: unknown) { calls.push({ fn: 'find', ast }); return []; },
async findOne(_o: string, ast: unknown) { calls.push({ fn: 'findOne', ast }); return null; },
async create(_o: string, data: Record<string, unknown>) { return { id: 'r1', ...data }; },
async update(_o: string, id: string, data: Record<string, unknown>) { return { id, ...data }; },
async delete() { return true; },
async deleteMany() { return 0; },
async count() { return 0; },
async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {},
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
};
return { driver, calls };
}

async function makeEngine() {
const engine = new ObjectQL();
const { driver, calls } = makeRecordingDriver();
engine.registerDriver(driver, true);
await engine.init();
// `title`/`status` are declared because the filter doors that run BEFORE the
// #4419 guard (`assertFilterIsMaterializable`, `assertOrderByIsMaterializable`)
// judge against the real field map — a case naming an undeclared column would
// die at a different door and tell us nothing about this one.
// `searchableFields` is explicit so the `search` case resolves deterministically
// rather than through the auto-default.
// `packageId` is REQUIRED (`registerObject(schema, packageId, …)`); the
// one-argument spelling some older doubles in this package still use is a
// TS2554 that objectql's own `tsconfig.json` hides, because it excludes
// `**/*.test.ts` — visible only to the TEST_DEBT re-measure, which is a
// shrink-only ratchet. Passing it keeps this file out of that pile.
engine.registry.registerObject(
{
name: OBJECT,
fields: { title: { type: 'text' }, status: { type: 'text' } },
searchableFields: ['title', 'status'],
},
'test-package',
);
return { engine, calls };
}

/** What the real engine actually did with this query bag. */
async function observeEngine(query: unknown): Promise<'selective' | 'reject'> {
const { engine, calls } = await makeEngine();
try {
// `as unknown as EngineQueryOptions`, never a bare `as any`: the case-set
// deliberately carries OFF-CONTRACT bags (`where: []`, an `orderBy` record)
// because those are the shapes the guard exists to refuse, and this spelling
// names the contract being bypassed instead of erasing it (#4674/#4918).
await engine.findOne(OBJECT, query as unknown as EngineQueryOptions);
} catch (e) {
const message = (e as Error).message;
// Only the #4419 refusal counts as this predicate's verdict. Anything else
// — a malformed filter array, an unmaterializable column, an unknown option
// — is a DIFFERENT door and must not be laundered into a passing case.
if (message === engineFindOnePredicateRefusalMessage(OBJECT)) return 'reject';
throw e;
}
if (calls.length !== 1) {
throw new Error(`expected exactly one driver read, saw ${JSON.stringify(calls)}`);
}
return 'selective';
}

describe('engine findOne predicate — the shared predicate IS the engine (#11957)', () => {
it('has cases on both sides of the guard (an empty or one-sided set proves nothing)', () => {
const kinds = new Set(ENGINE_FINDONE_PREDICATE_CASES.map((c) => c.expect));
expect(kinds).toEqual(new Set(['selective', 'reject']));
expect(ENGINE_FINDONE_PREDICATE_CASES.filter((c) => c.expect === 'reject').length)
.toBeGreaterThan(3);
expect(ENGINE_FINDONE_PREDICATE_CASES.filter((c) => c.expect === 'selective').length)
.toBeGreaterThan(3);
});

for (const c of ENGINE_FINDONE_PREDICATE_CASES) {
it(`real engine agrees with the predicate: ${c.what} → ${c.expect}`, async () => {
expect(resolveEngineFindOnePredicate(OBJECT, c.query).kind, 'predicate').toBe(c.expect);
expect(await observeEngine(c.query), 'real ObjectQL.findOne').toBe(c.expect);
});
}

it('refuses with the exact message a fake must reproduce, object name included', () => {
expect(() => assertEngineFindOnePredicate(OBJECT, { where: [] }))
.toThrow(engineFindOnePredicateRefusalMessage(OBJECT));
// The message quotes the object twice — a fake reproducing only the prefix
// would let a test assert on wording the producer never emits.
const message = engineFindOnePredicateRefusalMessage('sys_user');
expect(message).toContain("findOne('sys_user')");
expect(message).toContain("find('sys_user', { limit: 1 })");
});

it('returns the verdict (never `reject`) when the call selects a record', () => {
expect(assertEngineFindOnePredicate(OBJECT, { where: { id: 'a' } }))
.toEqual({ kind: 'selective', by: 'where' });
expect(assertEngineFindOnePredicate(OBJECT, { filter: { status: 'open' } }))
.toEqual({ kind: 'selective', by: 'where' });
expect(assertEngineFindOnePredicate(OBJECT, { orderBy: [{ field: 'title', order: 'desc' }] }))
.toEqual({ kind: 'selective', by: 'orderBy' });
expect(assertEngineFindOnePredicate(OBJECT, { search: 'widget' }))
.toEqual({ kind: 'selective', by: 'search' });
});

// The three shapes a hand-mirrored `if (!query?.where && !query?.orderBy)`
// gets wrong, spelled out because they are the whole argument for importing
// the producer's decision instead of copying it.
it('reads `where: []` as NO predicate — the #11767 shape a truthiness copy accepts', () => {
expect(resolveEngineFindOnePredicate(OBJECT, { where: [] }).kind).toBe('reject');
// …while a non-empty filter array lowers to a real condition.
expect(resolveEngineFindOnePredicate(OBJECT, { where: ['status', '=', 'open'] }).kind)
.toBe('selective');
});

it('reads `where: {}` as NO predicate and the `filter` alias as one', () => {
expect(resolveEngineFindOnePredicate(OBJECT, { where: {} }).kind).toBe('reject');
expect(resolveEngineFindOnePredicate(OBJECT, { filter: { status: 'open' } }).kind)
.toBe('selective');
});

it('requires orderBy to be a NON-EMPTY ARRAY, as `Array.isArray` does in the engine', () => {
expect(resolveEngineFindOnePredicate(OBJECT, { orderBy: [] }).kind).toBe('reject');
// `EngineFindOneQueryInput.orderBy` is `unknown`, so the record form needs no
// assertion at all — the predicate's own input type admits it and answers.
expect(resolveEngineFindOnePredicate(OBJECT, { orderBy: { title: 'desc' } }).kind)
.toBe('reject');
});
});
40 changes: 40 additions & 0 deletions packages/objectql/src/engine-findone-predicate.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The `findOne` predicate's objectql-side path — a **re-export of its home** in
* `@objectstack/metadata-core`, the same shape the two write-side twins use
* (`engine-delete-dispatch.ts`, `engine-update-dispatch.ts`).
*
* ## Why the predicate lives one package down, and this file exists at all
*
* `@objectstack/objectql` **depends on** `@objectstack/metadata-protocol`, so
* the fake engines there cannot import from objectql without closing a cycle
* turbo refuses outright. Sinking the predicate into `@objectstack/metadata-core`
* — a package both sides already depend on, and which depends on neither — is
* the only route that pins those doubles without inventing a dependency edge.
* The full reasoning, with the measured cycle, is in the module header at the
* implementation (objectstack#5619 established it for the delete twin).
*
* This file exists so the predicate has objectql's public spelling too: the
* engine's own pinned test doubles and the real-engine conformance test import
* `./engine-findone-predicate.js`, and `index.ts` re-exports the public API
* from here.
*
* @see @objectstack/metadata-core `src/engine-findone-predicate.ts` — the implementation.
* @see engine-findone-predicate.test.ts — the case-set driven against the REAL engine,
* which stays in this package because it needs `ObjectQL`.
* @see ObjectQL.findOne → `requireFindOnePredicate` in `engine.ts` — the producer (#4419).
*/

export {
engineFindOnePredicateRefusalMessage,
resolveEngineFindOnePredicate,
assertEngineFindOnePredicate,
ENGINE_FINDONE_PREDICATE_CASES,
} from '@objectstack/metadata-core';

export type {
EngineFindOnePredicate,
EngineFindOneQueryInput,
EngineFindOnePredicateCase,
} from '@objectstack/metadata-core';
18 changes: 18 additions & 0 deletions packages/objectql/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,24 @@ export type {
EngineUpdateDispatchCase,
} from './engine-update-dispatch.js';

// [#11957] The READ-side sibling of the two dispatches above, on exactly the
// same terms. `ObjectQL.findOne` applies `limit: 1`, so a query with no `where`
// and no `orderBy` would return an ARBITRARY row — `requireFindOnePredicate`
// REFUSES it (#4419) and every in-memory double answered it happily, which is
// how #11767 shipped a first-run bypass that was permanently inert on real
// deployments while a 641-line unit matrix stayed green.
export {
resolveEngineFindOnePredicate,
assertEngineFindOnePredicate,
engineFindOnePredicateRefusalMessage,
ENGINE_FINDONE_PREDICATE_CASES,
} from './engine-findone-predicate.js';
export type {
EngineFindOnePredicate,
EngineFindOneQueryInput,
EngineFindOnePredicateCase,
} from './engine-findone-predicate.js';

// Export in-memory aggregation fallback (used by engine.aggregate when the
// driver lacks native groupBy/aggregations support; also useful for tests).
export { applyInMemoryAggregation, bucketDateValue } from './in-memory-aggregation.js';
Expand Down
7 changes: 7 additions & 0 deletions packages/objectql/src/layered-overlay-integration.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import type { MetaRef } from '@objectstack/metadata-core';
import { SysMetadataRepository } from '@objectstack/metadata-protocol';
import { assertEngineDeleteDispatch } from './engine-delete-dispatch.js';
import { assertEngineUpdateDispatch } from './engine-update-dispatch.js';
import { assertEngineFindOnePredicate } from './engine-findone-predicate.js';

interface Row {
id: string;
Expand DownExpand Up@@ -69,6 +70,12 @@ function makeFakeEngine() {
);
},
async findOne(table: string, opts: { where: Record<string, unknown> }) {
// [#11957] Pinned to ObjectQL.findOne's OWN #4419 predicate: `findOne`
// applies limit: 1, so a query naming no record returns an ARBITRARY row
// and the engine REFUSES it. A double that answers it anyway is how
// #11767 shipped a bootstrap bypass that was inert on every real
// deployment while a 641-line unit matrix stayed green.
assertEngineFindOnePredicate(table, opts);
if (table === 'sys_metadata_history') {
return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,7 @@ import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objects
// against what the platform would derive rather than against a transcription.
import { provisionPrimary } from '@objectstack/spec/data';
import { SchemaRegistry } from './registry.js';
import { assertEngineFindOnePredicate } from './engine-findone-predicate.js';

interface Row {
id: string;
Expand DownExpand Up@@ -120,6 +121,12 @@ function makeHost() {
const engine: any = {
registry,
async findOne(_t: string, o: { where: Record<string, unknown> }) {
// [#11957] Pinned to ObjectQL.findOne's OWN #4419 predicate: `findOne`
// applies limit: 1, so a query naming no record returns an ARBITRARY row
// and the engine REFUSES it. A double that answers it anyway is how
// #11767 shipped a bootstrap bypass that was inert on every real
// deployment while a 641-line unit matrix stayed green.
assertEngineFindOnePredicate(_t, o);
return findRow(o.where)?.row ?? null;
},
async find(_t: string, o: { where: Record<string, unknown> }) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,6 +40,7 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { SchemaRegistry } from './registry.js';
import { SEARCH_COMPANION_FIELD } from './search-companion.js';
import { assertEngineFindOnePredicate } from './engine-findone-predicate.js';

interface Row {
id: string;
Expand DownExpand Up@@ -88,6 +89,12 @@ function makeHost() {
const engine: any = {
registry,
async findOne(_t: string, o: { where: Record<string, unknown> }) {
// [#11957] Pinned to ObjectQL.findOne's OWN #4419 predicate: `findOne`
// applies limit: 1, so a query naming no record returns an ARBITRARY row
// and the engine REFUSES it. A double that answers it anyway is how
// #11767 shipped a bootstrap bypass that was inert on every real
// deployment while a 641-line unit matrix stayed green.
assertEngineFindOnePredicate(_t, o);
return findRow(o.where)?.row ?? null;
},
async find(_t: string, o: { where: Record<string, unknown> }) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco
// below cannot accept a call ObjectQL refuses.
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { SchemaRegistry } from './registry.js';
import { assertEngineFindOnePredicate } from './engine-findone-predicate.js';

interface Row {
id: string;
Expand DownExpand Up@@ -111,6 +112,12 @@ function makeHost(multiTenant: boolean) {
const engine: any = {
registry,
async findOne(_t: string, o: { where: Record<string, unknown> }) {
// [#11957] Pinned to ObjectQL.findOne's OWN #4419 predicate: `findOne`
// applies limit: 1, so a query naming no record returns an ARBITRARY row
// and the engine REFUSES it. A double that answers it anyway is how
// #11767 shipped a bootstrap bypass that was inert on every real
// deployment while a 641-line unit matrix stayed green.
assertEngineFindOnePredicate(_t, o);
return findRow(o.where)?.row ?? null;
},
async find(_t: string, o: { where: Record<string, unknown> }) {
Expand Down
Loading
Loading