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
44 changes: 44 additions & 0 deletions .changeset/driver-query-redundant-object-callers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@objectstack/metadata": patch
"@objectstack/objectql": patch
---

fix(metadata,objectql): stop restating the object name inside driver queries — and stop casting away the query's type to do it (#6231)

`DriverQuery` (`Omit<QueryAST, 'object'>`) landed in #6076 and five drivers
followed in #6075, but five **call sites** stayed as they were, because they
were hidden behind a cast where the compiler could not see them. This removes
the redundant key at all five and, with it, the casts that existed only to
carry it.

The redundant key was never the expensive half. `git grep 'query\.object' --
'packages/drivers/*/src'` is zero: no driver reads it, so the key itself was
inert. **The cast was the cost.** `as any` on a query argument does not
suppress one key — it switches off checking for `where`, `orderBy` and
`fields` as well, which is precisely the account #5181's changeset opened
(cloud#1053 measured 20 such sites; cloud#1030's `$like` — an operator the
filter dialect does not have — survived compilation and reached the runtime
through exactly this hole). `packages/metadata`'s `DatabaseLoader` is the
main metadata read path, so it was the worst place to be running unchecked.

The five sites:

- `metadata` `DatabaseLoader._find` / `._findOne` / `._count` — each was
`driver.find(table, { object: table, ...query } as any)`. The helpers now
declare `query: DriverQuery` and hand it to the driver unchanged and uncast,
so all nine of their call sites' `where` / `orderBy` / `fields` are checked
again.
- `objectql` `ObjectQL.resolveSecret` — the `sys_secret` read was
`{ object: 'sys_secret', where: { id } } as QueryAST`, where the cast existed
only to satisfy the AST's then-required `object`. Both are gone.
- `objectql` `LifecycleService` governance counter — `count(obj.name,
{ object: obj.name })` carried no cast; it was admitted by a hand-written
driver shape whose `query` was `Record<string, unknown>`, which would equally
have admitted a `where` the dialect does not have. That shape is now the named
`CountCapableDriver` typed with `DriverQuery`, and the call passes argument
one only.

No behaviour changes: the key was inert on every path, and the object name has
always travelled as the driver methods' first argument. What changes is that
these call sites are type-checked again, and that re-adding the key is now a
compile error (`TS2353`) rather than something a cast quietly absorbs.
43 changes: 43 additions & 0 deletions packages/metadata/src/loaders/database-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,49 @@ describe('DatabaseLoader', () => {
});
});

// [#6231] The driver takes the object name as argument ONE, and `DriverQuery`
// is `Omit<QueryAST, 'object'>` — so the AST must never restate it. These
// three read helpers used to spell `{ object: table, ...query } as any`, and
// that cast did more than tolerate the redundant key: it switched off
// checking for `where` / `orderBy` / `fields` as well, which is the account
// #5181's changeset opened (cloud#1030's `$like` reached runtime through
// exactly this hole). The redundant key is inert — no driver reads it — so
// the pin is on the SHAPE the driver is handed, which is what a future
// re-add would change.
describe('driver query shape (#6231)', () => {
it('never restates the object name inside the query AST', async () => {
const seen: Array<{ method: string; table: unknown; query: unknown }> = [];
for (const method of ['find', 'findOne', 'count'] as const) {
const real = (mockDriver[method] as (...a: unknown[]) => unknown).bind(mockDriver);
(mockDriver as unknown as Record<string, unknown>)[method] = (
table: unknown,
query: unknown,
...rest: unknown[]
) => {
seen.push({ method, table, query });
return real(table, query, ...rest);
};
}

// Every read path the loader owns: findOne (load/stat), find (loadMany/
// list) and count (exists).
await loader.save('object', 'account', { name: 'account' });
await loader.load('object', 'account');
await loader.loadMany('object');
await loader.exists('object', 'account');
await loader.list('object');
await loader.stat('object', 'account');

expect(seen.length).toBeGreaterThan(0);
for (const call of seen) {
// The object name travels as argument one…
expect(typeof call.table).toBe('string');
// …and only there.
expect(call.query ?? {}).not.toHaveProperty('object');
}
});
});

describe('schema bootstrapping', () => {
it('should call syncSchema with SysMetadataObject on first operation', async () => {
await loader.list('object');
Expand Down
25 changes: 18 additions & 7 deletions packages/metadata/src/loaders/database-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type {
import { SysMetadataObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
import { applyConversionsToStoredItem } from '@objectstack/spec';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import type { IDataDriver, IDataEngine } from '@objectstack/spec/contracts';
import type { IDataDriver, IDataEngine, DriverQuery } from '@objectstack/spec/contracts';
import type { MetadataLoader } from './loader-interface.js';
import { calculateChecksum } from '../utils/metadata-history-utils.js';
import { LRUCache } from '../utils/lru-cache.js';
Expand Down Expand Up @@ -225,25 +225,36 @@ export class DatabaseLoader implements MetadataLoader {
// Internal CRUD helpers (driver vs engine)
// ==========================================

private async _find(table: string, query: Record<string, unknown>): Promise<Record<string, unknown>[]> {
// NOTE (#6231): the DRIVER branch below takes `query` unchanged and uncast —
// `DriverQuery` is `Omit<QueryAST, 'object'>`, so the object name travels as
// argument one only. The ENGINE branch still carries `as any`, and that cast
// is NOT vestigial: `EngineQueryOptionsSchema.search` admits only the
// structured `FullTextSearchSchema`, while `QueryAST.search` (hence
// `DriverQuery`) also admits the bare query string that ADR-0061 D1 calls the
// canonical Tier-1 spelling and that the engine actually serves. Until those
// two schemas agree, `DriverQuery` is not assignable to
// `EngineQueryOptionsParsed`. Tracked as #7178; do not "fix" it here by
// narrowing the cast.

private async _find(table: string, query: DriverQuery): Promise<Record<string, unknown>[]> {
if (this.engine) {
return this.engine.find(table, query as any);
}
return this.driver!.find(table, { object: table, ...query } as any);
return this.driver!.find(table, query);
}

private async _findOne(table: string, query: Record<string, unknown>): Promise<Record<string, unknown> | null> {
private async _findOne(table: string, query: DriverQuery): Promise<Record<string, unknown> | null> {
if (this.engine) {
return this.engine.findOne(table, query as any);
}
return this.driver!.findOne(table, { object: table, ...query } as any);
return this.driver!.findOne(table, query);
}

private async _count(table: string, query: Record<string, unknown>): Promise<number> {
private async _count(table: string, query: DriverQuery): Promise<number> {
if (this.engine) {
return this.engine.count(table, query as any);
}
return this.driver!.count(table, { object: table, ...query } as any);
return this.driver!.count(table, query);
}

private async _create(table: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4132,7 +4132,7 @@ export class ObjectQL implements IObjectQLEngine {
throw new Error('Cannot resolve secret: no CryptoProvider is registered (fail-closed).');
}
const secretDriver = this.getDriver('sys_secret');
const found = await secretDriver.find('sys_secret', { object: 'sys_secret', where: { id } } as QueryAST);
const found = await secretDriver.find('sys_secret', { where: { id } });
const secret: any = Array.isArray(found) ? found[0] : found;
if (!secret) {
throw new Error(`Cannot resolve secret: sys_secret row "${id}" not found (fail-closed).`);
Expand Down
21 changes: 21 additions & 0 deletions packages/objectql/src/lifecycle/lifecycle-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,27 @@ describe('LifecycleService.sweep — governance (P4)', () => {
expect(deletes).toHaveLength(2);
});

// [#6231] `count()` takes the object name as argument ONE; the query AST is
// `DriverQuery` (`Omit<QueryAST, 'object'>`) and must not restate it. The
// governance counter used to pass `{ object: obj.name }` — carried not by a
// cast but by a hand-written driver shape whose `query` was
// `Record<string, unknown>`, which would equally have accepted a `where` the
// filter dialect does not have.
it('counts by argument one only — the query never restates the object name', async () => {
const count = vi.fn(async (_object: string, _query?: unknown) => 5);
const driver = { name: 'default', count };
const { engine } = captureEngine([TELEMETRY_OBJ], { driver });
const settings = fakeSettings({ quotas: { sys_job_run: 1 } });

await service(engine, { getSettings: () => settings }).sweep();

expect(count).toHaveBeenCalled();
for (const [object, query] of count.mock.calls) {
expect(object).toBe('sys_job_run');
expect(query ?? {}).not.toHaveProperty('object');
}
});

it('quota defaults by class apply when no per-object quota is set', async () => {
const driver = { name: 'default', count: async () => 50 };
const { engine } = captureEngine([TELEMETRY_OBJ], { driver });
Expand Down
21 changes: 17 additions & 4 deletions packages/objectql/src/lifecycle/lifecycle-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Lifecycle } from '@objectstack/spec/data';
import type { DriverQuery } from '@objectstack/spec/contracts';
import { parseLifecycleDuration } from './duration.js';
import type {
DanglingReferenceAuditOptions,
Expand Down Expand Up @@ -298,6 +299,20 @@ interface RotationCapableDriver extends ReclaimCapableDriver {
): Promise<{ object: string; current: string; shards: string[]; dropped: string[] }>;
}

/**
* Driver surface the governance counter (P4) uses.
*
* `query` is the driver contract's {@link DriverQuery}: the object name
* travels as argument ONE and is deliberately absent from the AST, so a
* caller cannot state it twice (objectstack#5181, #6231). Typing it as the
* contract rather than as a loose bag is the point — the previous
* `Record<string, unknown>` accepted the redundant `object` key, and would
* equally have accepted a `where` the filter dialect does not have.
*/
interface CountCapableDriver {
count?(object: string, query?: DriverQuery, options?: unknown): Promise<number>;
}

/** Driver surface the Archiver uses on both the hot and the cold store. */
interface ArchiveCapableDriver {
name?: string;
Expand Down Expand Up @@ -782,13 +797,11 @@ export class LifecycleService {
const gov = this.governance;
const nextCounts = new Map<string, number>();
for (const obj of declared) {
const driver = engine.getDriverForObject(obj.name) as
| { count?(object: string, query?: Record<string, unknown>): Promise<number> }
| undefined;
const driver = engine.getDriverForObject(obj.name) as CountCapableDriver | undefined;
if (!driver || typeof driver.count !== 'function') continue;
let rowCount: number;
try {
rowCount = await driver.count(obj.name, { object: obj.name });
rowCount = await driver.count(obj.name);
} catch {
continue;
}
Expand Down
26 changes: 25 additions & 1 deletion packages/objectql/src/secret-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ async function buildEngine(withCrypto: boolean) {
engine.registry.registerObject(dsObject);
const crypto = makeFakeCrypto();
if (withCrypto) engine.setCryptoProvider(crypto.provider);
return { engine, stores, crypto };
return { engine, stores, crypto, driver };
}

describe('objectql secret-field channel', () => {
Expand Down Expand Up @@ -180,6 +180,30 @@ describe('objectql secret-field channel', () => {
expect(ctx.crypto.calls.decrypt).toBe(1);
});

// [#6231] `resolveSecret` reads `sys_secret` straight off the driver. That
// call used to spell `{ object: 'sys_secret', where: { id } } as QueryAST`,
// where the cast existed only to satisfy the AST's then-required `object`.
// With `DriverQuery` (`Omit<QueryAST, 'object'>`) the key is gone and so is
// the cast — so `where` is type-checked at this call site again.
it('resolveSecret reads sys_secret by argument one — the AST never restates the object name', async () => {
const created = await ctx.engine.insert('ext_datasource', { name: 'pg', db_password: 's3cr3t' });
const stored = ctx.stores.get('ext_datasource')!.get(created.id) as any;

const seen: Array<{ object: string; ast: any }> = [];
const realFind = ctx.driver.find.bind(ctx.driver);
ctx.driver.find = async (object: string, ast: any) => {
seen.push({ object, ast });
return realFind(object, ast);
};

expect(await ctx.engine.resolveSecret(stored.db_password)).toBe('s3cr3t');

const secretReads = seen.filter((c) => c.object === 'sys_secret');
expect(secretReads).toHaveLength(1);
expect(secretReads[0].ast).not.toHaveProperty('object');
expect(secretReads[0].ast.where).toEqual({ id: expect.any(String) });
});

it('fail-closed: writing a secret field with no CryptoProvider throws', async () => {
const bare = await buildEngine(false);
await expect(
Expand Down
2 changes: 1 addition & 1 deletion scripts/query-options-erasure-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"packages/core/src/security/resolve-authz-context.ts": 1,
"packages/metadata-protocol/src/protocol.ts": 6,
"packages/metadata-protocol/src/seed-loader.ts": 3,
"packages/metadata/src/loaders/database-loader.ts": 6,
"packages/metadata/src/loaders/database-loader.ts": 3,
"packages/objectql/src/engine.ts": 9,
"packages/plugins/plugin-approvals/src/approval-service.ts": 10,
"packages/plugins/plugin-approvals/src/approver-org-scope.ts": 2,
Expand Down
Loading