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
19 changes: 19 additions & 0 deletions .changeset/20094-turso-remote-between-arity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/driver-turso': patch
---

fix(driver-turso): in remote mode, a `$between` that is not two bounds is refused with `INVALID_FILTER` / 400 and its message names no field or value (#20094)

Clause-②: no

In remote mode, `TursoDriver` lowers `$between` to `$gte` / `$lte` before the filter reaches its transport. When the range was not two bounds (`[x]`, `[x, y, z]`, `[]`, a number, a string, `null` or an object), the lowering threw a plain `Error` with no `code` and no `status`. Its message named the object and field and repeated the value, and every caller got it, including when the range came from a read scope such as `plugin-security`'s RLS or sharing predicate. Local mode refuses the same filter with `INVALID_FILTER` / 400 and withholds the field.

The lowering now passes such a range on as written, and the transport refuses it the way it refuses every other filter it cannot compile:

- `INVALID_FILTER` / 400, the code and status local mode answers;
- the message states the refusal's class, `Operator "$between" in this filter requires a [min, max] value array.`, behind the transport's `[RemoteTransport]` prefix, and says the field is withheld;
- the field and the value go to the driver's logger, at `warn`.

Remote mode rebuilds every filter node before the transport sees it, so no provenance mark reaches the transport. As with the transport's other refusals, a filter marked as the caller's own therefore also gets the withheld message in remote mode. Local mode gives that caller the full text.

Not changed: which filters are refused, local mode's answers, and how a two-bound `$between` is lowered, including the whole-day upper bound for a bare `YYYY-MM-DD` on a `datetime` field.
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
* mode `TursoDriver.toRemoteFilter` rebuilds every node, so no mark reaches
* this transport and even an author-marked `where` is withheld there: the
* contract's declared fail-closed direction, pinned so it is a decision and
* not an accident.
* not an accident. [#20094] A `$between` that is not two bounds joins that
* half in a table of its own: it reached neither seam before, because the
* driver refused it while lowering it, ahead of the transport.
*/

import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
Expand Down Expand Up @@ -165,13 +167,24 @@ const ARMS: readonly Door[] = [
klass: 'a key that is not an operator',
},
{
// Unreachable through `TursoDriver` (it lowers `$between` first) — withheld
// all the same, so the class has no exception.
// A WELL-FORMED range is unreachable through `TursoDriver` (it lowers every
// two-bound `$between` first) — withheld all the same, so the class has no
// exception.
builder: 'unsupportedOperator',
where: () => ({ [POLICY_COL]: { $between: [1, 2] } }),
secrets: [POLICY_COL],
klass: 'must be lowered to $gte/$lte',
},
{
// [#20094] A range that is not two bounds: `TursoDriver` hands it over
// un-lowered, so this arm IS reachable through the driver for it — with
// `driver-sql`'s class statement for the same mistake, and the comparand
// in the sink beside the field.
builder: 'unsupportedOperator',
where: () => ({ [POLICY_COL]: { $between: [SECRET_NUM] } }),
secrets: [POLICY_COL, String(SECRET_NUM)],
klass: 'requires a [min, max] value array',
},
{
// The misplaced-field-operator tail: which tail applied says whether the
// key is a field operator, so both tails share ONE withheld sentence.
Expand Down Expand Up @@ -461,3 +474,150 @@ describe('[#20039] TursoDriver LOCAL and REMOTE withhold these classes alike', (
});
}
});

// ── Half 3b: a `$between` that is not two bounds, one table over both faces ───

/**
* [#20094] REMOTE mode refused a malformed range in
* `TursoDriver.toRemoteFieldSpec`, which runs before the transport and so
* outside its seam: a bare `Error` with no `code` and no `status` (an
* unclassified 500 over REST) that named `'deal.<field>'` and echoed the
* comparand to every caller, a `'policy'`-marked subtree included. LOCAL mode
* answers `INVALID_FILTER` / 400 with the field withheld. The lowering now
* hands such a range over as written and the transport's `$between` arm
* refuses it, so this is one table over both faces: every shape, every caller
* class, `find` and `count`. The two well-formed controls hold the lowering
* itself still — the date one turns on a row only the whole-day upper bound
* admits.
*/
describe('[#20094] TursoDriver LOCAL and REMOTE refuse a $between that is not two bounds alike', () => {
const RANGE_COL = 'secret_policy_amount';
const OBJECT = {
name: 'deal',
fields: { [RANGE_COL]: { type: 'number' }, closed_at: { type: 'datetime' } },
};
const ROWS = [
{ id: 'r1', [RANGE_COL]: 5, closed_at: '2026-01-01T00:00:00.000Z' },
// Inside the window ONLY because a bare-day max covers its whole day.
{ id: 'r2', [RANGE_COL]: 15, closed_at: '2026-01-31T13:45:00.000Z' },
{ id: 'r3', [RANGE_COL]: 20, closed_at: '2026-02-01T00:00:00.000Z' },
{ id: 'r4', [RANGE_COL]: 25, closed_at: '2025-12-31T23:59:59.000Z' },
];
let local: TursoDriver;
let remote: TursoDriver;
let stub: LibsqlSqliteStub;

beforeAll(async () => {
local = new TursoDriver({ url: ':memory:' });
expect(local.transportMode).toBe('local');
await local.initObjects([OBJECT]);
stub = makeLibsqlSqliteStub();
remote = new TursoDriver({ url: 'libsql://between-arity.turso.io', client: asLibsqlClient(stub) });
await remote.connect();
expect(remote.transportMode).toBe('remote');
await remote.syncSchema(OBJECT.name, OBJECT);
for (const row of ROWS) {
await local.create(OBJECT.name, { ...row }, { bypassTenantAudit: true });
await remote.create(OBJECT.name, { ...row });
}
});

afterAll(async () => {
await local.disconnect();
await remote.disconnect();
stub.close();
});

type Mark = 'unmarked' | 'policy' | 'author';
type Op = 'find' | 'count';

/** `echo` is the literal the comparand put on the wire at base, where it has one. */
const RANGES: ReadonlyArray<[label: string, range: () => unknown, echo: string | null]> = [
['one bound', () => [SECRET_NUM], String(SECRET_NUM)],
['three bounds', () => [SECRET_NUM, SECRET_NUM + 1, SECRET_NUM + 2], String(SECRET_NUM)],
['a number', () => SECRET_NUM, String(SECRET_NUM)],
['a string', () => SECRET, SECRET],
['null', () => null, null],
['an object', () => ({}), null],
['an empty list', () => [], null],
];

const whereOf = (range: () => unknown, mark: Mark): Record<string, unknown> => {
const where = { [RANGE_COL]: { $between: range() } };
return mark === 'unmarked' ? where : markFilterSubtreeProvenance(where, mark);
};

const refusalOf = async (driver: TursoDriver, op: Op, where: unknown) => {
const logger = (driver as unknown as { logger: { warn: (...args: unknown[]) => void } }).logger;
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const execute = vi.spyOn(stub, 'execute');
try {
const query = { where } as DriverQuery;
const err = await (op === 'find' ? driver.find(OBJECT.name, query) : driver.count(OBJECT.name, query)).then(
() => null,
(e: unknown) => e as WireBearingError,
);
expect(err, `${driver.transportMode} ${op} answered a malformed range`).not.toBeNull();
expect(err!.code, driver.transportMode).toBe('INVALID_FILTER');
expect(err!.status, driver.transportMode).toBe(400);
expect(Object.keys(err!).sort(), driver.transportMode).toEqual(['code', 'status']);
if (driver === remote) expect(execute, 'a refused filter must not execute a statement').not.toHaveBeenCalled();
return { message: err!.message, log: warn.mock.calls.map((call) => String(call[0])).join('\n') };
} finally {
warn.mockRestore();
execute.mockRestore();
}
};

for (const [label, range, echo] of RANGES) {
for (const op of ['find', 'count'] as const) {
it(`${label}, ${op}: unmarked and policy-marked ⇒ one class statement on both faces, operands in the log`, async () => {
for (const mark of ['unmarked', 'policy'] as const) {
const faces = {
local: await refusalOf(local, op, whereOf(range, mark)),
remote: await refusalOf(remote, op, whereOf(range, mark)),
};
for (const [face, { message, log }] of Object.entries(faces)) {
expect(message, `${face} ${mark}`).toContain('requires a [min, max] value array');
expect(message, `${face} ${mark}`).not.toContain(RANGE_COL);
if (echo) expect(message, `${face} ${mark}`).not.toContain(echo);
expect(log, `${face} ${mark}: the log lost the field`).toContain(RANGE_COL);
}
// The remote sentence is the local one behind the transport's prefix,
// less the tracker id the local sentence carries: runtime text carries
// none (`pnpm check:doc-authoring`), so the copy could not.
expect(faces.remote.message, mark).toBe(
`[RemoteTransport] ${faces.local.message.replace(/ \(#\d+\)/g, '')}`,
);
// What the wire stopped echoing is in the remote sink.
if (echo) expect(faces.remote.log, `${mark}: the sink lost the comparand`).toContain(echo);
}
});

it(`${label}, ${op}: author-marked ⇒ LOCAL restores the field, REMOTE stays withheld`, async () => {
expect((await refusalOf(local, op, whereOf(range, 'author'))).message).toContain(RANGE_COL);
// `TursoDriver.toRemoteFilter` rebuilds every node, so no mark reaches
// the transport and an author reads the unmarked answer — the declared
// fail-closed direction, the same as every other remote refusal.
expect((await refusalOf(remote, op, whereOf(range, 'author'))).message).toBe(
(await refusalOf(remote, op, whereOf(range, 'unmarked'))).message,
);
});
}
}

const CONTROLS: ReadonlyArray<[label: string, where: Record<string, unknown>, expected: string[]]> = [
['numeric', { [RANGE_COL]: { $between: [10, 20] } }, ['r2', 'r3']],
['bare days on a datetime column', { closed_at: { $between: ['2026-01-01', '2026-01-31'] } }, ['r1', 'r2']],
];

for (const [label, where, expected] of CONTROLS) {
it(`well-formed control, ${label}: both faces return the same rows, and count agrees`, async () => {
for (const driver of [local, remote]) {
const rows = await driver.find(OBJECT.name, { where } as DriverQuery);
expect(rows.map((row) => String(row.id)).sort(), driver.transportMode).toEqual(expected);
expect(await driver.count(OBJECT.name, { where } as DriverQuery), driver.transportMode).toBe(expected.length);
}
});
}
});
42 changes: 36 additions & 6 deletions packages/drivers/driver-turso/src/remote-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3667,8 +3667,11 @@ export class RemoteTransport {
* `TursoDriver.toRemoteFieldSpec` lowers it to `$gte`/`$lte` (#1003) so the
* calendar-day upper-bound rule is applied in exactly one place. Growing a
* `$between` arm here would be that second implementation, silently without
* the rule — so reaching this point means the lowering step was bypassed, and
* saying which step it was is the whole value of the message.
* the rule — so a well-formed range reaching this point means the lowering
* step was bypassed, and saying which step it was is the whole value of the
* message. [#20094] A range that is NOT two bounds is the other way to arrive:
* the lowering hands it over as written, and it gets the sentence `driver-sql`
* gives the same mistake instead — a malformed range, not a skipped step.
*/
private unsupportedOperator(
object: string,
Expand Down Expand Up @@ -3706,10 +3709,37 @@ export class RemoteTransport {
);
}
if (op === '$between') {
// [#20039, the #8220 contract] Unreachable through `TursoDriver`, which
// lowers every `$between` before this transport sees it — and withheld
// all the same, so that EVERY refusal `buildWhereSQL` can raise goes
// through the seam and the enumeration pin has no exception to carry.
// [#20094] A range that is not two bounds — `[x]`, `[x, y, z]`, `[]`, a
// scalar, `null`, an object. `TursoDriver.toRemoteFieldSpec` lowers only a
// two-bound range and hands every other one over as written, so THIS is
// where the remote face refuses it: through the seam, `INVALID_FILTER` /
// 400 like every refusal here, with `driver-sql`'s class statement for
// the same mistake (`betweenArityError`) behind this file's prefix, less
// the tracker id that sentence carries (runtime text carries none). The
// field and the comparand go to the sink only. `subtree` is this field's
// operator map (the one call site hands over `value`), so the range is
// read off it; the refusal resolves against the range itself when it is
// an object, else against the map — `driver-sql`'s `refusalSubtree` rule.
const operatorMap =
subtree !== null && typeof subtree === 'object' && !Array.isArray(subtree)
? (subtree as Record<string, unknown>)
: null;
const range = operatorMap?.[op];
if (operatorMap !== null && !(Array.isArray(range) && range.length === 2)) {
return this.withheldRefusal(
'[RemoteTransport] Operator "$between" in this filter requires a [min, max] value array. ' +
'The field it was aimed at is withheld from the message; the full diagnostic is in the ' +
'server log.',
refusalNode(range, operatorMap),
`[RemoteTransport] Operator "$between" on field ${target} requires a [min, max] value ` +
`array, got ${preview(range)}. Refusing rather than widening the query silently.`,
);
}
// [#20039, the #8220 contract] A WELL-FORMED range is unreachable through
// `TursoDriver`, which lowers every two-bound `$between` before this
// transport sees it — and withheld all the same, so that EVERY refusal
// `buildWhereSQL` can raise goes through the seam and the enumeration pin
// has no exception to carry.
return this.withheldRefusal(
'[RemoteTransport] A $between in this filter must be lowered to $gte/$lte before it reaches ' +
'the transport — TursoDriver.toRemoteFieldSpec does that so the calendar-day upper-bound ' +
Expand Down
18 changes: 14 additions & 4 deletions packages/drivers/driver-turso/src/turso-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2005,11 +2005,21 @@ export class TursoDriver extends SqlDriver {
// (framework#4081): the upper-bound arm below already carries the
// whole-day calendar rule, so a range's max inherits it by
// construction instead of via a second implementation.
//
// [#20094] A range that is not two bounds is NOT refused here: it is
// handed to the transport as written, un-lowered, and the transport's
// `$between` arm refuses it (`RemoteTransport.unsupportedOperator`).
// This method runs outside the transport's refusal seam, so a throw
// here was a bare `Error` — no `code`, no `status`, a 500 over REST —
// naming the field and echoing the comparand to every caller, where
// local mode answers `INVALID_FILTER` / 400 with both withheld. The
// transport's arm is where every other remote filter refusal already
// lives, behind the withheld seam and its enumeration pin, so the
// lowering keeps no refusal of its own. The refused set does not
// move: every such range was refused before and is refused after.
if (!Array.isArray(raw) || raw.length !== 2) {
throw new Error(
`[TursoDriver] $between on '${object}.${field}' needs exactly two bounds, got ` +
`${JSON.stringify(raw)}. Refusing rather than widening the query silently.`,
);
out[op] = raw;
break;
}
out.$gte = this.temporalFilterValue(object, field, raw[0]);
Object.assign(out, this.toRemoteUpperBound(object, field, '$lte', raw[1]));
Expand Down
Loading