Skip to content

Commit 55daf89

Browse files
fix(driver-turso): a remote $between that is not two bounds is refused INVALID_FILTER / 400 through the transport's withheld seam, naming no field or value (#20094) (#20114)
Fixes #20094 Clause-②: no ## What changed In remote mode, `TursoDriver.toRemoteFieldSpec` lowers `$between` to `$gte` / `$lte` before the filter reaches `RemoteTransport`, so that a two-bound range inherits the whole-day upper-bound rule (framework#4081). A range that was not two bounds was refused right there, with a bare `Error`: no `code`, no `status`, and a message naming `'deal.field'` and echoing the comparand. That method runs before the transport and so outside its refusal seam, which is why PR #20093's class closure never reached it. - **`turso-driver.ts`, the `$between` arm of `toRemoteFieldSpec`.** A range that is not two bounds is no longer refused here. The arm hands it to the transport as written, un-lowered. A two-bound range lowers exactly as before. - **`remote-transport.ts`, the `$between` arm of `unsupportedOperator`.** The arm now tells apart the two ways a `$between` can arrive: - A range that is not two bounds gets `driver-sql`'s class statement for the same mistake (`betweenArityError`), behind this file's `[RemoteTransport]` prefix. It goes through `withheldRefusal`: `INVALID_FILTER` / 400, with the field and the comparand in the diagnostic sink only. - A well-formed range keeps its "must be lowered" sentence, unchanged. - The range is read off the operator map that the one call site already passes as `subtree`. The refusal carries the range itself when it is an object, and otherwise the map. That is `driver-sql`'s `refusalSubtree` rule. After this change `toRemoteFilter`, `toRemoteFieldSpec` and `toRemoteUpperBound` contain no `throw`. The remote face's filter refusals are raised in `buildWhereSQL`, behind the seam and its enumeration pin. The remote withheld message, as it stands at this head: ```text [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. ``` It is the local message behind the prefix, minus one thing: the local sentence carries the tracker id `(#8197)`. Runtime text carries no tracker id, and `check:doc-authoring`'s sibling-package prose ratchet reds a new one, so the copy leaves it out. The class statement itself is byte-equal to the local one. The pin compares the two faces after removing the local tracker id, so the day the local sentence drops it, the comparison still holds. ## Measured The probe was a scratch vitest file, never committed. It drove a real remote `TursoDriver` (the in-repo libSQL stub over better-sqlite3) and a local `TursoDriver(':memory:')` control, both seeded with the same four rows. It covered 7 shapes (`[x]`, `[x, y, z]`, a number, a string, `null`, `{}` and `[]`), 3 caller classes (unmarked, `'policy'`-marked `where`, `'author'`-marked `where`), and both `find` and `count`: 42 cells per face. The field is `secret_policy_col` and the literals are distinctive, so a leak is a substring hit. The base column was measured on the base tree (`8d76c2d38c`), and again with both files put back at their base blobs in this worktree. The two base runs are byte-identical. ### H1: confirmed | face | base | this head | |:--|:--|:--| | remote | 42 of 42 cells: bare `Error`, `code` and `status` undefined, no own keys. The message names `'deal.secret_policy_col'` in all 42, and echoes the comparand in all 24 cells whose shape has a literal. Nothing is logged. | 42 of 42 cells: `INVALID_FILTER` / 400, own keys exactly `code,status`. The message names neither the field nor the comparand. One `warn` line holds both. | | local (control) | `INVALID_FILTER` / 400 and one class for all seven shapes. Unmarked and `'policy'`: withheld, with one `warn` line naming the field. `'author'`: the field restored, nothing logged. The comparand is never echoed. | 42 of 42 cells byte-identical to base | ### H2: falsified as stated, so the arm needed one branch I drove the transport directly with the un-lowered `$between`, 7 shapes × 3 marks. At base it refused all 21 cells with `INVALID_FILTER` / 400 through the seam and executed no statement. The wording, though, was the "must be lowered to $gte/$lte before it reaches the transport" sentence. That is the class of a skipped lowering step, not the local face's arity class. Dropping the pre-check alone would therefore have answered the right envelope with a false cause. At this head all 21 cells answer the arity class. `'author'`-marked cells get the full diagnostic, including the comparand. **Lowering controls, base against head, byte-identical in all six.** Each control compared the lowered filter (`toRemoteFilter`), the statements the stub executed, and the rows and `count` on both faces. Local and remote agree in every control. | control | lowered (identical at base and head) | rows | |:--|:--|:--| | numeric `[10, 20]` | `{"$gte":10,"$lte":20}` | r2, r3 | | bare days on a `date` field | `{"$gte":"2026-01-01","$lte":"2026-01-31"}` | r1, r2 | | bare days on a `datetime` field | `{"$gte":"2026-01-01T00:00:00.000Z","$lt":"2026-02-01T00:00:00.000Z"}` | r1, r2 (r2 is 13:45 on the last day; only the whole-day rule admits it) | | instants on a `datetime` field | `$gte` / `$lte`, unwidened | r1 | | the bare-day control under `$not` | `$not` of the widened pair | r3, r4 | | numeric inside `$or` | the lowered pair inside `$or` | r2, r3, r4 | ### H3: which text each caller class gets | caller | local (base = head) | remote base | remote head | |:--|:--|:--|:--| | unmarked | withheld class statement | bare `Error`, field and comparand, to everyone | withheld class statement | | `'policy'` | withheld class statement | the same bare `Error` | withheld class statement | | `'author'` | the full text, field restored | the same bare `Error` | withheld class statement | `toRemoteFilter` rebuilds every node, so no mark reaches the transport. On the remote face an author therefore reads the unmarked answer. PR #20093 declared that same cost for the transport's other classes; it errs in the withholding direction. I did not extend `turso-local-remote-text-parity.test.ts`. Its population is `FILTER_TEXT_CASES`, which has no `$between` row, so nothing there reaches this door. The local-against-remote table for withheld classes is Half 3 of `remote-transport-compile-refusal-seam.test.ts`. This PR extends that half with a table of its own, and adds no new file. ### H4: the refused set did not move Every one of the 42 remote cells was refused at base and is refused at head. I also measured 15 nested and multi-operator cells at both base and head: a malformed range under `$not`, inside `$or`, inside `$and` with `null`, beside a `$gt` in one map, and a two-bound range with an object member. Each of them was refused at base and is refused at head. The six well-formed controls answer the same rows. Declared `patch`, `Clause-②: no`. ## Tests `remote-transport-compile-refusal-seam.test.ts` grows from 96 to 130 cases: - **An `ARMS` row for the arity branch** (`{ secret_policy_col: { $between: [7770123] } }`), driven through all four legs of Half 2: `'policy'`, `'author'`, unmarked and merged `$and` in both orders. The stack binds it to `unsupportedOperator`. The existing well-formed `$between` row stays, so both branches of the arm are pinned. - **Half 3b, one table over both faces.** It covers 7 shapes × `find` / `count` (28 cases): - Unmarked and `'policy'`: `INVALID_FILTER` / 400 and own keys exactly `code,status` on both faces. The class statement is on the wire, with no field and no literal. The field is in each face's log, and the comparand is in the remote sink. The remote message equals the local one behind the prefix, minus the local tracker id. No statement is executed. - `'author'`: local restores the field, and remote equals its own unmarked answer. - **Two well-formed controls.** Numeric `[10, 20]`, and bare days on a `datetime` field. The date control's expected rows include a row only the whole-day upper bound admits. Both faces must return the same rows, and `count` must agree. **Ablations, on committed state.** Each ablation put one file back at its base blob with `git restore --source`, and the blob hash was checked equal to base before the run. `trap` restored the file from `HEAD`, the blob hash was checked equal to `HEAD`, and `git diff HEAD` was empty afterwards. The pin imports `src`, so no build leg was needed. I predicted the counts before running either one. 1. **`turso-driver.ts` at base** (marker 1 → 0): 28 failed, 102 passed, as predicted. All 28 Half 3b shape cases failed. The two controls and the transport arity row stayed green. 2. **`remote-transport.ts` at base** (marker 2 → 0): 18 failed, 112 passed, as predicted. The four legs of the arity row failed, along with the 14 unmarked and `'policy'` Half 3b cases, whose wording at base is "must be lowered". The 14 `'author'` cases stayed green (remote stays withheld either way), and so did the controls. This is H2 as a test result. **Suites run**, all at `d148119880` unless noted: - `driver-turso`, the whole suite: 68 files, 1640 tests passed. `typecheck` exits 0, and `tsc --noEmit --listFiles` counts the pin file once. - `driver-sql` seam and provenance pins, which import the shared wording: `sql-driver-compile-refusal-seam`, `sql-driver-target-field-provenance`, `sql-driver-refusal-door-provenance` and `sql-driver-cross-field-provenance`. 4 files, 169 tests passed. - The scratch probe, at base and at head (see above). - `driver-sql` is untouched, and no export moves. The only wording removed (`[TursoDriver] $between on … needs exactly two bounds`) has no reader: a repo-wide `git grep` finds it nowhere. The control, the spec's own "A range needs exactly two bounds" message, was found in its 3 files. ## Gates - `dispatch-gates --commands --repo objectstack-ai/objectstack` at `d148119880` (merge base `aa04ea2964`, 4 paths) derived 62 families, and all 62 exited 0. - `check:dual-build-cjs-loads` and `check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET). They exited 0 after `turbo run build --filter=./packages/* --filter=./packages/*/*` (72 of 72 tasks). `check:dts-closure` was re-run on that build: 72 packages, 166 of 166 declaration files present. - `--ran` over the last record per command: "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN". - The three roster families under `packages/` that the PM's derivation flagged exited 0: `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. - `check-issue-citations --base aa04ea2`: exit 0, 5 citations resolve. - `check:driver-conformance`: "50 covered cell(s), 0 in the DEBT ledger, 0 exempt" both at base (`8d76c2d38c`, read from an archived tree) and at this head. The ledger did not move. - `check:doc-authoring`: the sibling-package prose ids hold the baseline, with no growth. - ESLint (`--no-inline-config --format json`) on the 3 changed `.ts` files reports 3 files, 0 errors and 0 warnings, so no file was ignored. The printed config for `remote-transport.ts` has `parserOptions` `{"ecmaVersion":"latest","sourceType":"module"}` and no `project`. Linting is therefore not type-aware, and this diff cannot move a verdict on an untouched file. The repo-wide run is CI's. - A control-byte scan of the 4 changed files found no hits. ## Acceptance notes - **A remote author loses the text it got at base.** The base text was a bare `Error` that also went to every other caller. At this head the author gets the withheld class statement, and the field and comparand are in the server log. Local mode still restores the field for an author. This is the fail-closed direction the contract prescribes, and the same one the transport's other classes already take on the remote face. - **Which refusal answers can change when one filter carries more than one defect on the remote face.** The transport walks the map in order. Measured at head: `{ f: { $gt: SOME_OBJECT, $between: [x] } }` answers the comparand refusal, and the same map with `$between` first answers the arity refusal. Both are `INVALID_FILTER` / 400. At base the lowering threw its bare `$between` error for both orders; that is read from the code, not measured: the lowering walks the whole map before the transport sees it. The refused set does not move. - **An object member inside a two-bound range** (`[{…}, 1]`) answers a different class on each face. Local answers the list-member refusal and remote answers the comparand refusal. Both are `INVALID_FILTER` / 400 and withheld. The answer is identical at base and head, because this diff does not touch the two-bound path, and PR #20093's changeset already says so. - **`turso-remote-temporal-conformance.test.ts`** still asserts its malformed `$between` with a bare `toThrow(/\$between/)`. It stays green. The new table pins the envelope, and that file is outside this claim's one-table surface. - **The REST face was not measured.** The measurements are at the driver. - **I merged `origin/main` once** (`aa04ea2964`, `objectql` only). It is outside `driver-turso`'s dependency closure. - **#20055 and #20041 are not addressed here.** They are in flight in other regions of these files, and this diff touches only the two `$between` arms, one doc paragraph, the seam pin and the changeset. --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c1039b commit 55daf89

4 files changed

Lines changed: 232 additions & 13 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/driver-turso': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
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:
12+
13+
- `INVALID_FILTER` / 400, the code and status local mode answers;
14+
- 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;
15+
- the field and the value go to the driver's logger, at `warn`.
16+
17+
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.
18+
19+
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.

‎packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts‎

Lines changed: 163 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
* mode `TursoDriver.toRemoteFilter` rebuilds every node, so no mark reaches
2929
* this transport and even an author-marked `where` is withheld there: the
3030
* contract's declared fail-closed direction, pinned so it is a decision and
31-
* not an accident.
31+
* not an accident. [#20094] A `$between` that is not two bounds joins that
32+
* half in a table of its own: it reached neither seam before, because the
33+
* driver refused it while lowering it, ahead of the transport.
3234
*/
3335

3436
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
@@ -165,13 +167,24 @@ const ARMS: readonly Door[] = [
165167
klass: 'a key that is not an operator',
166168
},
167169
{
168-
// Unreachable through `TursoDriver` (it lowers `$between` first) — withheld
169-
// all the same, so the class has no exception.
170+
// A WELL-FORMED range is unreachable through `TursoDriver` (it lowers every
171+
// two-bound `$between` first) — withheld all the same, so the class has no
172+
// exception.
170173
builder: 'unsupportedOperator',
171174
where: () => ({ [POLICY_COL]: { $between: [1, 2] } }),
172175
secrets: [POLICY_COL],
173176
klass: 'must be lowered to $gte/$lte',
174177
},
178+
{
179+
// [#20094] A range that is not two bounds: `TursoDriver` hands it over
180+
// un-lowered, so this arm IS reachable through the driver for it — with
181+
// `driver-sql`'s class statement for the same mistake, and the comparand
182+
// in the sink beside the field.
183+
builder: 'unsupportedOperator',
184+
where: () => ({ [POLICY_COL]: { $between: [SECRET_NUM] } }),
185+
secrets: [POLICY_COL, String(SECRET_NUM)],
186+
klass: 'requires a [min, max] value array',
187+
},
175188
{
176189
// The misplaced-field-operator tail: which tail applied says whether the
177190
// key is a field operator, so both tails share ONE withheld sentence.
@@ -461,3 +474,150 @@ describe('[#20039] TursoDriver LOCAL and REMOTE withhold these classes alike', (
461474
});
462475
}
463476
});
477+
478+
// ── Half 3b: a `$between` that is not two bounds, one table over both faces ───
479+
480+
/**
481+
* [#20094] REMOTE mode refused a malformed range in
482+
* `TursoDriver.toRemoteFieldSpec`, which runs before the transport and so
483+
* outside its seam: a bare `Error` with no `code` and no `status` (an
484+
* unclassified 500 over REST) that named `'deal.<field>'` and echoed the
485+
* comparand to every caller, a `'policy'`-marked subtree included. LOCAL mode
486+
* answers `INVALID_FILTER` / 400 with the field withheld. The lowering now
487+
* hands such a range over as written and the transport's `$between` arm
488+
* refuses it, so this is one table over both faces: every shape, every caller
489+
* class, `find` and `count`. The two well-formed controls hold the lowering
490+
* itself still — the date one turns on a row only the whole-day upper bound
491+
* admits.
492+
*/
493+
describe('[#20094] TursoDriver LOCAL and REMOTE refuse a $between that is not two bounds alike', () => {
494+
const RANGE_COL = 'secret_policy_amount';
495+
const OBJECT = {
496+
name: 'deal',
497+
fields: { [RANGE_COL]: { type: 'number' }, closed_at: { type: 'datetime' } },
498+
};
499+
const ROWS = [
500+
{ id: 'r1', [RANGE_COL]: 5, closed_at: '2026-01-01T00:00:00.000Z' },
501+
// Inside the window ONLY because a bare-day max covers its whole day.
502+
{ id: 'r2', [RANGE_COL]: 15, closed_at: '2026-01-31T13:45:00.000Z' },
503+
{ id: 'r3', [RANGE_COL]: 20, closed_at: '2026-02-01T00:00:00.000Z' },
504+
{ id: 'r4', [RANGE_COL]: 25, closed_at: '2025-12-31T23:59:59.000Z' },
505+
];
506+
let local: TursoDriver;
507+
let remote: TursoDriver;
508+
let stub: LibsqlSqliteStub;
509+
510+
beforeAll(async () => {
511+
local = new TursoDriver({ url: ':memory:' });
512+
expect(local.transportMode).toBe('local');
513+
await local.initObjects([OBJECT]);
514+
stub = makeLibsqlSqliteStub();
515+
remote = new TursoDriver({ url: 'libsql://between-arity.turso.io', client: asLibsqlClient(stub) });
516+
await remote.connect();
517+
expect(remote.transportMode).toBe('remote');
518+
await remote.syncSchema(OBJECT.name, OBJECT);
519+
for (const row of ROWS) {
520+
await local.create(OBJECT.name, { ...row }, { bypassTenantAudit: true });
521+
await remote.create(OBJECT.name, { ...row });
522+
}
523+
});
524+
525+
afterAll(async () => {
526+
await local.disconnect();
527+
await remote.disconnect();
528+
stub.close();
529+
});
530+
531+
type Mark = 'unmarked' | 'policy' | 'author';
532+
type Op = 'find' | 'count';
533+
534+
/** `echo` is the literal the comparand put on the wire at base, where it has one. */
535+
const RANGES: ReadonlyArray<[label: string, range: () => unknown, echo: string | null]> = [
536+
['one bound', () => [SECRET_NUM], String(SECRET_NUM)],
537+
['three bounds', () => [SECRET_NUM, SECRET_NUM + 1, SECRET_NUM + 2], String(SECRET_NUM)],
538+
['a number', () => SECRET_NUM, String(SECRET_NUM)],
539+
['a string', () => SECRET, SECRET],
540+
['null', () => null, null],
541+
['an object', () => ({}), null],
542+
['an empty list', () => [], null],
543+
];
544+
545+
const whereOf = (range: () => unknown, mark: Mark): Record<string, unknown> => {
546+
const where = { [RANGE_COL]: { $between: range() } };
547+
return mark === 'unmarked' ? where : markFilterSubtreeProvenance(where, mark);
548+
};
549+
550+
const refusalOf = async (driver: TursoDriver, op: Op, where: unknown) => {
551+
const logger = (driver as unknown as { logger: { warn: (...args: unknown[]) => void } }).logger;
552+
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
553+
const execute = vi.spyOn(stub, 'execute');
554+
try {
555+
const query = { where } as DriverQuery;
556+
const err = await (op === 'find' ? driver.find(OBJECT.name, query) : driver.count(OBJECT.name, query)).then(
557+
() => null,
558+
(e: unknown) => e as WireBearingError,
559+
);
560+
expect(err, `${driver.transportMode} ${op} answered a malformed range`).not.toBeNull();
561+
expect(err!.code, driver.transportMode).toBe('INVALID_FILTER');
562+
expect(err!.status, driver.transportMode).toBe(400);
563+
expect(Object.keys(err!).sort(), driver.transportMode).toEqual(['code', 'status']);
564+
if (driver === remote) expect(execute, 'a refused filter must not execute a statement').not.toHaveBeenCalled();
565+
return { message: err!.message, log: warn.mock.calls.map((call) => String(call[0])).join('\n') };
566+
} finally {
567+
warn.mockRestore();
568+
execute.mockRestore();
569+
}
570+
};
571+
572+
for (const [label, range, echo] of RANGES) {
573+
for (const op of ['find', 'count'] as const) {
574+
it(`${label}, ${op}: unmarked and policy-marked ⇒ one class statement on both faces, operands in the log`, async () => {
575+
for (const mark of ['unmarked', 'policy'] as const) {
576+
const faces = {
577+
local: await refusalOf(local, op, whereOf(range, mark)),
578+
remote: await refusalOf(remote, op, whereOf(range, mark)),
579+
};
580+
for (const [face, { message, log }] of Object.entries(faces)) {
581+
expect(message, `${face} ${mark}`).toContain('requires a [min, max] value array');
582+
expect(message, `${face} ${mark}`).not.toContain(RANGE_COL);
583+
if (echo) expect(message, `${face} ${mark}`).not.toContain(echo);
584+
expect(log, `${face} ${mark}: the log lost the field`).toContain(RANGE_COL);
585+
}
586+
// The remote sentence is the local one behind the transport's prefix,
587+
// less the tracker id the local sentence carries: runtime text carries
588+
// none (`pnpm check:doc-authoring`), so the copy could not.
589+
expect(faces.remote.message, mark).toBe(
590+
`[RemoteTransport] ${faces.local.message.replace(/ \(#\d+\)/g, '')}`,
591+
);
592+
// What the wire stopped echoing is in the remote sink.
593+
if (echo) expect(faces.remote.log, `${mark}: the sink lost the comparand`).toContain(echo);
594+
}
595+
});
596+
597+
it(`${label}, ${op}: author-marked ⇒ LOCAL restores the field, REMOTE stays withheld`, async () => {
598+
expect((await refusalOf(local, op, whereOf(range, 'author'))).message).toContain(RANGE_COL);
599+
// `TursoDriver.toRemoteFilter` rebuilds every node, so no mark reaches
600+
// the transport and an author reads the unmarked answer — the declared
601+
// fail-closed direction, the same as every other remote refusal.
602+
expect((await refusalOf(remote, op, whereOf(range, 'author'))).message).toBe(
603+
(await refusalOf(remote, op, whereOf(range, 'unmarked'))).message,
604+
);
605+
});
606+
}
607+
}
608+
609+
const CONTROLS: ReadonlyArray<[label: string, where: Record<string, unknown>, expected: string[]]> = [
610+
['numeric', { [RANGE_COL]: { $between: [10, 20] } }, ['r2', 'r3']],
611+
['bare days on a datetime column', { closed_at: { $between: ['2026-01-01', '2026-01-31'] } }, ['r1', 'r2']],
612+
];
613+
614+
for (const [label, where, expected] of CONTROLS) {
615+
it(`well-formed control, ${label}: both faces return the same rows, and count agrees`, async () => {
616+
for (const driver of [local, remote]) {
617+
const rows = await driver.find(OBJECT.name, { where } as DriverQuery);
618+
expect(rows.map((row) => String(row.id)).sort(), driver.transportMode).toEqual(expected);
619+
expect(await driver.count(OBJECT.name, { where } as DriverQuery), driver.transportMode).toBe(expected.length);
620+
}
621+
});
622+
}
623+
});

‎packages/drivers/driver-turso/src/remote-transport.ts‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3667,8 +3667,11 @@ export class RemoteTransport {
36673667
* `TursoDriver.toRemoteFieldSpec` lowers it to `$gte`/`$lte` (#1003) so the
36683668
* calendar-day upper-bound rule is applied in exactly one place. Growing a
36693669
* `$between` arm here would be that second implementation, silently without
3670-
* the rule — so reaching this point means the lowering step was bypassed, and
3671-
* saying which step it was is the whole value of the message.
3670+
* the rule — so a well-formed range reaching this point means the lowering
3671+
* step was bypassed, and saying which step it was is the whole value of the
3672+
* message. [#20094] A range that is NOT two bounds is the other way to arrive:
3673+
* the lowering hands it over as written, and it gets the sentence `driver-sql`
3674+
* gives the same mistake instead — a malformed range, not a skipped step.
36723675
*/
36733676
private unsupportedOperator(
36743677
object: string,
@@ -3706,10 +3709,37 @@ export class RemoteTransport {
37063709
);
37073710
}
37083711
if (op === '$between') {
3709-
// [#20039, the #8220 contract] Unreachable through `TursoDriver`, which
3710-
// lowers every `$between` before this transport sees it — and withheld
3711-
// all the same, so that EVERY refusal `buildWhereSQL` can raise goes
3712-
// through the seam and the enumeration pin has no exception to carry.
3712+
// [#20094] A range that is not two bounds — `[x]`, `[x, y, z]`, `[]`, a
3713+
// scalar, `null`, an object. `TursoDriver.toRemoteFieldSpec` lowers only a
3714+
// two-bound range and hands every other one over as written, so THIS is
3715+
// where the remote face refuses it: through the seam, `INVALID_FILTER` /
3716+
// 400 like every refusal here, with `driver-sql`'s class statement for
3717+
// the same mistake (`betweenArityError`) behind this file's prefix, less
3718+
// the tracker id that sentence carries (runtime text carries none). The
3719+
// field and the comparand go to the sink only. `subtree` is this field's
3720+
// operator map (the one call site hands over `value`), so the range is
3721+
// read off it; the refusal resolves against the range itself when it is
3722+
// an object, else against the map — `driver-sql`'s `refusalSubtree` rule.
3723+
const operatorMap =
3724+
subtree !== null && typeof subtree === 'object' && !Array.isArray(subtree)
3725+
? (subtree as Record<string, unknown>)
3726+
: null;
3727+
const range = operatorMap?.[op];
3728+
if (operatorMap !== null && !(Array.isArray(range) && range.length === 2)) {
3729+
return this.withheldRefusal(
3730+
'[RemoteTransport] Operator "$between" in this filter requires a [min, max] value array. ' +
3731+
'The field it was aimed at is withheld from the message; the full diagnostic is in the ' +
3732+
'server log.',
3733+
refusalNode(range, operatorMap),
3734+
`[RemoteTransport] Operator "$between" on field ${target} requires a [min, max] value ` +
3735+
`array, got ${preview(range)}. Refusing rather than widening the query silently.`,
3736+
);
3737+
}
3738+
// [#20039, the #8220 contract] A WELL-FORMED range is unreachable through
3739+
// `TursoDriver`, which lowers every two-bound `$between` before this
3740+
// transport sees it — and withheld all the same, so that EVERY refusal
3741+
// `buildWhereSQL` can raise goes through the seam and the enumeration pin
3742+
// has no exception to carry.
37133743
return this.withheldRefusal(
37143744
'[RemoteTransport] A $between in this filter must be lowered to $gte/$lte before it reaches ' +
37153745
'the transport — TursoDriver.toRemoteFieldSpec does that so the calendar-day upper-bound ' +

‎packages/drivers/driver-turso/src/turso-driver.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2005,11 +2005,21 @@ export class TursoDriver extends SqlDriver {
20052005
// (framework#4081): the upper-bound arm below already carries the
20062006
// whole-day calendar rule, so a range's max inherits it by
20072007
// construction instead of via a second implementation.
2008+
//
2009+
// [#20094] A range that is not two bounds is NOT refused here: it is
2010+
// handed to the transport as written, un-lowered, and the transport's
2011+
// `$between` arm refuses it (`RemoteTransport.unsupportedOperator`).
2012+
// This method runs outside the transport's refusal seam, so a throw
2013+
// here was a bare `Error` — no `code`, no `status`, a 500 over REST —
2014+
// naming the field and echoing the comparand to every caller, where
2015+
// local mode answers `INVALID_FILTER` / 400 with both withheld. The
2016+
// transport's arm is where every other remote filter refusal already
2017+
// lives, behind the withheld seam and its enumeration pin, so the
2018+
// lowering keeps no refusal of its own. The refused set does not
2019+
// move: every such range was refused before and is refused after.
20082020
if (!Array.isArray(raw) || raw.length !== 2) {
2009-
throw new Error(
2010-
`[TursoDriver] $between on '${object}.${field}' needs exactly two bounds, got ` +
2011-
`${JSON.stringify(raw)}. Refusing rather than widening the query silently.`,
2012-
);
2021+
out[op] = raw;
2022+
break;
20132023
}
20142024
out.$gte = this.temporalFilterValue(object, field, raw[0]);
20152025
Object.assign(out, this.toRemoteUpperBound(object, field, '$lte', raw[1]));

0 commit comments

Comments
 (0)