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
45 changes: 45 additions & 0 deletions .changeset/metadata-protocol-driver-exec-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@objectstack/metadata-protocol": patch
---

fix(metadata-protocol): resolve the raw-SQL driver seam through one `execute`-first helper (#14083)

Three sites in this package resolved a raw-SQL entry point off a driver, in two
different orders: `migrations/partial-index-probe.ts` and `protocol.ts`'s
`ensureOverlayIndex` tried `raw` first, while `migrations/seed-tenancy-backfill.ts`
tried `execute` first. They now share one helper, `migrations/driver-exec.ts`,
which tries `execute` first and keeps `raw` as the fallback.

`execute` goes first because `IDataDriver` (`@objectstack/spec/contracts`,
`data-driver.ts`) declares it **non-optionally** and has never declared `raw`.
It is therefore the only raw-execution surface the contract guarantees, and any
driver satisfying the interface has it. This is the same reasoning, and the same
order, that `@objectstack/metadata`'s `migrations/driver-exec.ts` adopted; the
two modules are twins and their headers cross-reference each other.

**No behaviour change on any driver this repo ships.** No data driver here
defines `raw` — `InMemoryDriver`, `MongoDBDriver` and `SqlDriver` each declare
`execute` and none declares `raw`, and `SqliteWasmDriver` and `TursoDriver`
extend `SqlDriver` — so the `raw` limb was unreachable and `execute` was already
what ran at all three sites. The flip matters for a host or third-party driver
that defines BOTH surfaces: such a driver used to be driven through `raw` at two
sites and `execute` at the third, the same operation taking two paths in one
process. It is now driven through `execute` everywhere.

`raw` is deliberately **kept**: nothing that worked before stops working.

Two smaller consequences of routing all three through one helper:

- Bindings are now passed positionally to whichever surface is selected. The
`raw` fallback in `seed-tenancy-backfill.ts` previously dropped its `params`
argument entirely, which was invisible only because that limb is unreachable
on every shipped driver.
- The capability predicate each site spelled for itself (`canRunSql` / `canRun`
/ an inline check) is now defined as the resolution succeeding, so the
predicate and the selection cannot drift apart.

⚠️ Unchanged and explicitly not addressed here: `typeof driver.execute === 'function'`
cannot distinguish "declares the surface" from "can actually run SQL", and two
shipped drivers satisfy the declaration while executing nothing. That is a
capability-declaration question tracked separately; this change aligns the ORDER
only and does not endorse the probe.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ that silently does not happen.
| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10787` |
| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:10949` |
| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9680` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1736` |
| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` |
| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9717`, `readonly-strict-errors.ts:66` |
| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5705` |
| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3574`, `:3584`, `:3611` |
Expand Down
118 changes: 118 additions & 0 deletions packages/metadata-protocol/src/migrations/driver-exec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The order pin for this package's raw-SQL seam resolution.
*
* Three sites used to resolve a raw-SQL entry point off a driver in TWO
* different orders (`partial-index-probe.ts` and `protocol.ts`'s
* `ensureOverlayIndex` tried `raw` first, `seed-tenancy-backfill.ts` tried
* `execute` first). They now share `./driver-exec.ts`, which tries the surface
* `IDataDriver` DECLARES — `execute`, non-optional — and keeps `raw` as the
* fallback for a host or third-party driver that defines it.
*
* What this file pins is the SELECTION, across all four driver shapes that can
* reach the resolver:
*
* | shape | selected |
* |:-------------|:----------|
* | execute only | `execute` |
* | raw only | `raw` |
* | BOTH | `execute` |
* | neither | undefined |
*
* The `both` row is the one with teeth. On every driver this repo ships the
* `raw` limb is unreachable — no data driver defines `raw` — so a test using a
* realistic double would pass under EITHER order and pin nothing. Only a double
* offering both surfaces can tell the two orders apart, which is why the row
* exists and why it is asserted on the call arguments rather than on a return
* value.
*
* ⚠️ This pin is about the ORDER only. `typeof driver.execute === 'function'`
* cannot tell "declares the surface" from "can actually run SQL" — two shipped
* drivers satisfy the declaration and execute nothing — and nothing here should
* be read as endorsing that probe. See `./driver-exec.ts`'s header.
*/

import { describe, it, expect, vi } from 'vitest';

import { driverCanRunSql, resolveDriverExec } from './driver-exec.js';

describe('resolveDriverExec — surface selection', () => {
it('selects execute() on a driver that offers only execute', async () => {
const execute = vi.fn(async () => 'ran');
const exec = resolveDriverExec({ execute } as any);

expect(exec).toBeTypeOf('function');
await exec!('SELECT 1');
expect(execute).toHaveBeenCalledWith('SELECT 1', []);
});

it('selects raw() on a driver that offers only raw', async () => {
const raw = vi.fn(async () => 'ran');
const exec = resolveDriverExec({ raw } as any);

expect(exec).toBeTypeOf('function');
await exec!('SELECT 1');
expect(raw).toHaveBeenCalledWith('SELECT 1', []);
});

it('selects execute() — NOT raw() — on a driver that offers BOTH', async () => {
// The row that separates the two orders. Under the pre-alignment
// raw-first order this expectation is exactly inverted, so a regression
// to `raw` first fails here and nowhere else.
const raw = vi.fn(async () => 'raw');
const execute = vi.fn(async () => 'execute');

await resolveDriverExec({ raw, execute } as any)!('SELECT 1');

expect(execute).toHaveBeenCalledWith('SELECT 1', []);
expect(raw).not.toHaveBeenCalled();
});

it('returns undefined on a driver that offers neither', () => {
expect(resolveDriverExec({} as any)).toBeUndefined();
expect(resolveDriverExec(null)).toBeUndefined();
expect(resolveDriverExec(undefined)).toBeUndefined();
// A non-callable member of the right NAME must not satisfy the probe.
expect(resolveDriverExec({ execute: 'yes', raw: 42 } as any)).toBeUndefined();
});

it('passes bindings positionally to whichever surface is selected', async () => {
// `IDataDriver.execute(command, parameters?)` takes bindings as the
// second POSITIONAL argument. The `raw` limb is held to the same call
// shape: before the alignment this package's `raw` fallback in
// `seed-tenancy-backfill.ts` dropped its `params` argument on the floor,
// which was invisible only because that limb is unreachable today.
const execute = vi.fn(async () => undefined);
await resolveDriverExec({ execute } as any)!('SELECT ?', ['a']);
expect(execute).toHaveBeenCalledWith('SELECT ?', ['a']);

const raw = vi.fn(async () => undefined);
await resolveDriverExec({ raw } as any)!('SELECT ?', ['b']);
expect(raw).toHaveBeenCalledWith('SELECT ?', ['b']);
});
});

describe('driverCanRunSql', () => {
it('agrees with resolveDriverExec on all four shapes', () => {
const shapes: Array<[string, unknown]> = [
['execute only', { execute: async () => undefined }],
['raw only', { raw: async () => undefined }],
['both', { execute: async () => undefined, raw: async () => undefined }],
['neither', {}],
['null', null],
];

// The predicate is DEFINED as the resolution succeeding; this pins that
// the two cannot drift into disagreeing about which drivers count.
for (const [label, driver] of shapes) {
expect(
driverCanRunSql(driver),
`${label}: predicate must match resolution`,
).toBe(resolveDriverExec(driver as any) !== undefined);
}

expect(driverCanRunSql({ execute: async () => undefined })).toBe(true);
expect(driverCanRunSql({})).toBe(false);
});
});
153 changes: 153 additions & 0 deletions packages/metadata-protocol/src/migrations/driver-exec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* How this package obtains a raw-SQL entry point from a driver — one
* definition, because it used to be three and they did not agree.
*
* ## The divergence this module closes
*
* Three sites in `@objectstack/metadata-protocol` resolved a raw-SQL seam off a
* driver, in TWO different orders:
*
* | site | order |
* |:--------------------------------------|:-------------------|
* | `migrations/partial-index-probe.ts` | `raw`, then `execute` |
* | `migrations/seed-tenancy-backfill.ts` | `execute`, then `raw` |
* | `protocol.ts` (`ensureOverlayIndex`) | `raw`, then `execute` |
*
* On the drivers this repo ships the two orders pick the same surface, so the
* split produced no measurable difference (see "Why this was not urgent"
* below). It still had to be closed: a host or third-party driver defining BOTH
* surfaces would have been driven through `raw` at two of those sites and
* `execute` at the third — the same operation taking two different paths in one
* process — and the dead limb read as the preferred one to anybody maintaining
* the raw-first sites.
*
* ## Why `execute` is tried FIRST
*
* `IDataDriver` (`@objectstack/spec/contracts`, `data-driver.ts`) declares
*
* ```ts
* execute(command: unknown, parameters?: unknown[], options?: DriverOptions): Promise<unknown>;
* ```
*
* — **non-optional**. `raw` has never appeared on that interface at all. So
* `execute` is not merely the surface the shipped drivers happen to have; it is
* the only raw-execution surface the contract guarantees, and any driver
* satisfying `IDataDriver` has it. Trying it first is the order that matches the
* declaration.
*
* This is the 2026-08-07 meta-criterion — one operation, several
* implementations, inconsistent behaviour, decide by the declaration-bound side
* — applied a second time. The first application is the precedent this module
* follows: `packages/metadata/src/migrations/driver-exec.ts`, which converted
* `@objectstack/metadata`'s migrations to `execute`-first for exactly this
* reason. ⚠️ That module and this one are TWINS and must stay in step; its
* header carries the longer argument, including the `raw(sql, bindings?)` call
* shape that made `execute`'s positional `parameters` the matching surface.
*
* `execute`-first also happens to be the order `seed-tenancy-backfill.ts`
* already argued for on independent grounds: `execute(sql, params)` carries
* bound parameters and `raw(sql)`, as this package was calling it, did not.
*
* ⚠️ `IDataEngine.execute?(command, options?)` (`data-engine.ts`) is a DIFFERENT
* member on a different interface — its second parameter is an options bag, not
* bindings. These helpers resolve an `IDataDriver`, so `data-driver.ts` governs.
* Do not reason about this call from the engine declaration.
*
* ## Why `raw` is KEPT
*
* Nothing in this repo defines `raw` on a data driver, but a host or a
* third-party driver may, and removing a surface that currently works is not
* what this alignment is for. The fallback stays; only the ORDER changed.
* Callers that must refuse should treat `undefined` as "neither surface".
*
* ## Why this is a twin rather than an import
*
* `@objectstack/metadata` is already a declared dependency of this package, and
* `resolveDriverExec` could have been imported instead of restated. It is not,
* for two reasons:
*
* - `driver-exec.ts` is INTERNAL to `metadata`'s migrations directory — it is
* not re-exported from `@objectstack/metadata/migrations`. Importing it would
* mean widening that package's published surface to serve three call sites in
* a sibling package.
* - The only subpath that could carry it is the `./migrations` barrel, and
* `ensureOverlayIndex` — one of the three callers — runs on EVERY boot.
* Pulling a migrations barrel onto the boot path to save ten lines is the
* wrong trade.
*
* ⚠️ The comment in `protocol.ts` that used to justify keeping this logic local
* cited a circular dependency ("metadata already depends on objectql"). That
* reason is STALE and was not the one acted on here: `@objectstack/metadata`
* does not depend on `@objectstack/objectql`; `objectql` depends on both. The
* two bullets above are the live reasons.
*
* ## Known limitation, deliberately not papered over here
*
* `typeof driver.execute === 'function'` separates "declares the surface" from
* "does not declare it" — it does NOT separate either from "can actually run
* SQL". Two shipped drivers satisfy the non-optional `execute` declaration and
* then execute nothing: `InMemoryDriver.execute` logs and returns `null` for
* every command, and `MongoDBDriver.execute` hands the command back. Both are
* selected by the resolver below and then answer every probe with "absent", so a
* migration reports "not applicable" instead of refusing. `IDataDriver` exposes
* no capability flag that would tell the two apart (`DriverCapabilities` has no
* such member), so distinguishing them is a contract question rather than
* something to guess at with a driver-name sniff. Filed separately and tracked
* on the capability-declaration surface; this module inherits the limitation and
* does not add to it. Nothing below should be read as an endorsement of the
* probe — only as agreement about its ORDER.
*
* ## Why this was not urgent
*
* Measured on the tree this module landed on: no data driver in this repo
* defines `raw`. `InMemoryDriver`, `MongoDBDriver` and `SqlDriver` each declare
* `execute` and none declares `raw`; `SqliteWasmDriver` and `TursoDriver` extend
* `SqlDriver` and inherit the same. The only `raw(` members anywhere are two
* test doubles and `packages/verify/src/harness.ts`, an HTTP harness whose
* signature is `(path, init)` and which is not a data driver. So on every
* shipped driver the `raw` limb is unreachable and the flip changes no observed
* behaviour today — which is precisely why it was safe to do before a
* third-party driver made it a live defect.
*/

import type { IDataDriver } from '@objectstack/spec/contracts';

/**
* A raw-SQL entry point resolved off a driver. `bindings` are passed
* positionally, matching `IDataDriver.execute`'s declared `parameters`.
*/
export type DriverExec = (sql: string, bindings?: readonly unknown[]) => Promise<any>;

/**
* Whether `driver` offers either raw-SQL surface.
*
* Exactly `resolveDriverExec(driver) !== undefined`, and defined in terms of it
* so the predicate and the resolution can never disagree about which drivers
* count — the three call sites previously spelled this test three times, once
* per site, alongside three copies of the resolution.
*/
export function driverCanRunSql(driver: unknown): boolean {
return resolveDriverExec(driver as IDataDriver | null | undefined) !== undefined;
}

/**
* Resolve the raw-SQL entry point of `driver`, or `undefined` when it offers
* neither surface.
*
* Order: declared surface (`execute`) first, `raw` as the fallback — see the
* header for why, and do not flip it back without reading that argument.
*/
export function resolveDriverExec(driver: IDataDriver | null | undefined): DriverExec | undefined {
const candidate = driver as any;
if (!candidate) return undefined;
// Declared surface first — see the header.
if (typeof candidate.execute === 'function') {
return (sql, bindings) => candidate.execute(sql, bindings ? [...bindings] : []);
}
if (typeof candidate.raw === 'function') {
return (sql, bindings) => candidate.raw(sql, bindings ? [...bindings] : []);
}
return undefined;
}
16 changes: 11 additions & 5 deletions packages/metadata-protocol/src/migrations/partial-index-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@

import { isUniqueViolationError } from '@objectstack/types';

/** Raw-SQL seam. Mirrors `ensureOverlayIndex`: `raw()` first, `execute()` second. */
import { driverCanRunSql, resolveDriverExec } from './driver-exec.js';

/**
* Raw-SQL seam. The surface is resolved by `./driver-exec.ts`: `execute()`
* first — the member `IDataDriver` declares non-optionally — then `raw()` as
* the fallback for a host or third-party driver that defines it. That module's
* header carries the argument; this order is shared with `ensureOverlayIndex`
* and `seed-tenancy-backfill.ts`, which used to disagree with it.
*/
export type IndexExec = (sql: string) => Promise<unknown>;

/**
Expand Down Expand Up @@ -75,8 +83,7 @@ export function resolveIndexExecForTable(engine: unknown, table: string): IndexE
return undefined;
}
};
const canRunSql = (d: any): boolean =>
!!d && (typeof d.raw === 'function' || typeof d.execute === 'function');
const canRunSql = (d: any): boolean => driverCanRunSql(d);

let driver: any = attempt(() => engineAny?.getDriverForObject?.(table));
if (!canRunSql(driver)) driver = attempt(() => engineAny?.driver);
Expand All @@ -91,8 +98,7 @@ export function resolveIndexExecForTable(engine: unknown, table: string): IndexE
}
}
if (!canRunSql(driver)) return undefined;
if (typeof driver.raw === 'function') return (sql: string) => driver.raw(sql);
return (sql: string) => driver.execute(sql);
return resolveDriverExec(driver);
}

/**
Expand Down
Loading
Loading