Skip to content

Commit 3e8b492

Browse files
fix(driver-turso): the remote face refuses a missing table or column with the local face's code instead of answering [] (#20461)
Fixes #20424 Clause-②: no (narrowing) Seat ruling: the amended claim 5872640432 on #20424 rules this change an accept-set narrowing: `Clause-②: no (narrowing)`, `@objectstack/driver-turso` `minor` with the BREAKING banner, and the ADR-0087 disposition `not-required (already-registered driver-sql-unresolvable-where-column-refused)`. ## Summary On the Turso **remote** face, `aggregate`, `find`, `findOne` and `count` now answer a missing table or a missing column the way the local face of the same driver answers it over the same file: `DATABASE_ERROR` / 500, `INVALID_FIELD` / 400 or `INVALID_FILTER` / 400, and never "no rows". Two catches in `RemoteTransport` read the backend's `no such table` / `no such column` as `[]`. They now let the error out, and `TursoDriver`'s remote read exits classify it with the local face's inherited seam, `SqlDriver.aggregateBackendFault`. No code is minted, nothing in `driver-sql` or `spec` is edited, and no classification is copied. Measured head: `daad09aa1` (this branch after merging `origin/main` at `3cf644938`). Patch round 1 changed only the changeset and this body, then merged `origin/main` at `2304b1608`: head `3b56eb769`, with `packages/drivers` byte-identical to `daad09aa1` (see "Patch round 1" below). ## H1: reproduced on `main` before any change Base `6e3e5462c`. The remote face is a `TursoDriver` over a real `@libsql/client` on a `file:` database, and the local control is a `TursoDriver` over the same file (a throwaway probe, not committed). "Declared field, column absent" is a field the object declares and the table lacks. | read | local | remote | |:--|:--|:--| | row 1: `aggregate`, mapped table really absent | `DATABASE_ERROR` / 500 | `[]` | | row 2: `aggregate` grouped by a declared field, column absent | `INVALID_FIELD` / 400 | `[]` | | row 3: `find` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` | | `findOne`, the same `where` | `INVALID_FILTER` / 400 | `null` | | `find` with a projection and that `where` | `INVALID_FILTER` / 400 | `[]` | | `count`, the same `where` | `INVALID_FILTER` / 400 | `DATABASE_ERROR` / 500 | | `aggregate` summing that field | `INVALID_FIELD` / 400 | `[]` | | `aggregate` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` | | `find` ordered by that field | the rows, unordered | `[]` | | `find` projecting that field | the rows | the rows | | `find`, mapped table absent | `DATABASE_ERROR` / 500 | `DATABASE_ERROR` / 500 | | controls: a real table and column | the rows | the rows | H1 holds for all three card rows. **H4 holds too**: rows 1 to 3 answered the same way for a managed object whose synced table or column was dropped under it (`aggregate` `[]`, `aggregate` grouped `[]`, `find` `[]`, where the local face refused with 500 / 400 / 400). ## H2: where the `[]` came from, and why each catch existed - **`RemoteTransport.aggregate`'s catch.** `git log -S` finds it in the method's first version, `101d5c345` ("Add i18n, analytics, turso aggregate, auth date normalization", 2026-05-16). It had no comment, no test and no card, and nothing in that commit names a first-boot or not-yet-created table. The local face refuses both conditions (a missing table since #11455, a missing column since #11541), so under the order's rule there is no case to keep. The catch is removed. - **`RemoteTransport.find`'s `$select` backstop.** It arrived with the migration from the cloud repository (`06ba03627`). Its comment names one case, a list view that projects fields the object lacks, and that case is kept: the projection is still dropped and the rows answer. Its terminal `return []` (no projection to drop, or the retry failed too) was the pre-#8790 behaviour it mirrored. The local face's terminal became a refusal in `716ac9bf8`, and this copy never followed. The unit pin `still returns empty when even SELECT * fails (e.g. unknown table)` asserted that `[]`, and its stub threw `no such column`, not a table error: a real `no such table` was already rethrown by this catch. It is replaced, with the same input and the opposite assertion. ## H3: the seam. The mechanism is falsified; the seam is reachable The hypothesis was a field check that decides before the statement runs. The local face has none for this condition: the ingress field checks pass because the field *is* declared. The local face decides **after** the statement runs, from the backend's error. `count` and `findRows` send an unresolvable column to `unresolvableFilterColumnRefusal` and everything else to `backendStatementFault`. `aggregate` goes through `aggregateBackendFault`, which attributes the column to the groupBy, the aggregation or the `where` from the caller's own query. All of those compositions are `protected` on `SqlDriver`, and `TursoDriver` inherits them. The class predicate they share, `isUnresolvableColumnError`, is a module function that `@objectstack/driver-sql` does not export. So: - The remote `aggregate` exit calls `aggregateBackendFault(object, query, error)` with the caller's own query. That is the local exit verbatim. - The remote `find` / `findOne` / `count` exits call the same method with the `where` alone. With no groupBy and no aggregation, arm 1 cannot fire. Arm 2 is `unresolvableFilterColumnRefusal(object, error, where)`, and the terminal is `backendStatementFault`, which is the local exit. The two can differ only on a recognised wording whose column name does not parse, and that cannot arise on libSQL, whose only wording is `no such column: NAME`. - A remote door compiles and executes inside one transport call, while the local face guards only the execution. So the new `remoteReadFault` first returns anything that already declares a numeric `status` unchanged. That is the same "is it already ours" gate `backendStatementFault` applies, so the classifier sees what the local one sees. The transport's own compile refusals and the timeout envelope keep their answers. The `where` handed over is the caller's own, from before `toRemoteFilter` rewrote it, so the read-scope provenance marks decide whether the column is named, exactly as they do locally. ## What changes - `packages/drivers/driver-turso/src/remote-transport.ts`: - `aggregate`: the catch is removed, and the backend's error leaves the method. - `find`: the ladder has the local face's two rungs, the projection and then the ORDER BY (the new one), each rebuilt with the caller's `where`, which neither drops. The terminal throws the last rung's error instead of answering `[]`. - `packages/drivers/driver-turso/src/turso-driver.ts`: - `remoteReadExit(object, query, read)` ends in a new private `remoteReadFault`: the status gate, then `aggregateBackendFault`. - The remote `aggregate` arm now goes through that exit. `find`, `findOne` and `count` pass the caller's `where`. - One sentence in `refuseRemoteColumnMap`'s docblock moves to the past tense: the `[]` it described is now a refusal, and the 501 refusal stays. A parenthetical sentence is also added after it, saying so: that read is now refused `INVALID_FILTER` / 400, and the 501 refusal stays the answer. - No change to `RemoteTransport`'s or `TursoDriver`'s published signatures. The constructor (PR #20447) and the write doors are untouched. ## Bounded in-place fixes, declared 1. **`count`'s remote exit.** For the same `where`, local answers `INVALID_FILTER` / 400 and remote answers `DATABASE_ERROR` / 500. This is not one of the card's three rows. It shares `remoteReadExit` with `find`, and leaving it would reopen the split #8790 ruled out: a list view calls both halves. ① Same class: a remote read exit answers an unresolvable `where` column differently from the local face. ② Mechanical, with a pinned shape: the local seam, called. ③ Inside the claimed surface (the remote read arms of `turso-driver.ts`), and no other claim holds those lines. ④ Same package tests. 2. **The ORDER BY rung.** Without it, removing the terminal `[]` turns "ordered by a column the table lacks" from `[]` into an `INVALID_FILTER` refusal that tells the caller their filter was wrong (measured, ablation leg D below). The local face answers the rows, unordered (#3821). The registered migration entry `driver-sql-unresolvable-where-column-refused` already says the ladder drops an ORDER BY and keeps the rows. ① The same `return []` line. ② Mechanical: the local ladder's second rung. ③ The claimed catch, in the claimed file. ④ Same package tests. ## Tests - New `turso-local-remote-missing-table-column-parity.test.ts`: 22 cases over a real `@libsql/client` `file:` database, with a local driver over the same file. - The card's three rows as local-and-remote pairs, for a federated and a managed object (6 cases). Each asserts `code` + `status` on both faces. - The neighbours: `findOne`, find with projection and `where`, `count` (federated and managed), `sum` over the field, `aggregate` with that `where`, and ORDER BY and projection recoveries answering the literal rows on both faces (8 cases). - Controls: literal answers for real tables and columns on both faces (6 cases). An undeclared aggregate function is refused with the same envelope on both faces, with no statement sent. A synthetic already-enveloped refusal passes the remote exit unchanged. - `remote-transport-unknown-select.test.ts`: the `[]` pin is replaced as described under H2. - At the measured head `daad09aa1`: `pnpm --filter @objectstack/driver-turso exec vitest run --maxWorkers=2` gave `Test Files 75 passed (75)` and `Tests 2003 passed | 16 skipped (2019)`. `pnpm --filter @objectstack/driver-turso typecheck` exited 0, and `tsc --listFiles` includes both test files. Run after rebuilding the dependency closure on the merged tree. ### Reverse verification The fix was committed first (`a10647239`). Every leg went through `node scripts/ablation-replace.mjs`: the anchor hit 1 → 0, the blob changed, and the restore was proven by blob == HEAD and an empty `git diff HEAD`. An outer trap re-verified the restore against the HEAD blob. The subject resolves from `src/` through a relative import (vitest, no alias), so no `dist/` is in the path. Suites: the new parity file, the unit file and the #20107 external-object parity file (79 cases). Directions were predicted in the test header before running: - **A**, `aggregate`'s `[]` catch restored: **6 failed / 73 passed**, exactly the six aggregate pairs. - **B**, the find terminal back to `return []`: **5 failed / 74 passed**. That is the find, findOne and find-with-projection pairs, plus the replaced unit pin. `count`, `aggregate` and ORDER BY stayed green. - **C**, `remoteReadFault` reduced to `backendStatementFault`: **10 failed / 69 passed**, every 400 pair. Both missing-table pairs stayed green, since they answer 500 on both faces anyway. - **D**, the ORDER BY rung deleted: **1 failed / 78 passed**, the ORDER BY case. The remote face refused with the unnamed `INVALID_FILTER` wording ("A filter on object 'ext_t' names a column the database could not resolve …") where the local face answered the rows. - **E**, the status gate deleted: **1 failed / 78 passed**, the synthetic case, re-worded as `INVALID_FIELD` / 400. The real compile-refusal control stayed green, as predicted: no real refusal text parses as a backend column fault today. A first harness dry run was refused by the tool itself (its replacement contained the anchor, so the anchor count did not drop), and it restored. Nothing was measured there. ### Gates at `daad09aa1` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands (5 paths vs merge base `3cf644938`). All 63 were run, and each exit code was written to a file before any pipe. Three gates first answered PREREQUISITE NOT MET (exit 3): `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. I built every package with `pnpm turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2`: 71 of 71 tasks succeeded. All three then exited 0. The dist-sweeping `check:dts-closure` (71 packages), `check:sourcemap-no-sources-content` (68) and `check:published-files` were re-run on the full build and exited 0. `--ran` answered: `63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3)`. **Driver conformance ledger:** - Before, at `6e3e5462c`: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.` - After, at `daad09aa1`: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.` - The `driver-turso` row is `ok` in every column at both readings. **Lint, narrowed:** `eslint --no-inline-config --format json` over the 4 changed `.ts` files gave 4 files, 0 errors and 0 warnings. - The population comes from `eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus its ignores. - The file count is from the JSON. - The effective `parserOptions` are `{ecmaVersion: latest, sourceType: module}`, with no `project`. All 6 active rules are per-file AST rules, so type-aware linting is off and this diff cannot move a verdict on any untouched file. The full `pnpm lint` is CI's. ## Clause-② and the changeset `.changeset/20424-turso-remote-missing-table-column-refused.md` ships `@objectstack/driver-turso` `minor`, with `Clause-②: no (narrowing)`, the **BREAKING** banner in the launch-window form (`check-changeset-no-major` refuses `major`), a FROM → TO line with the fix, and the ADR-0087 disposition `not-required (already-registered driver-sql-unresolvable-where-column-refused)`. This follows the seat's ruling in the amended claim 5872640432. The precedent the ruling reads: - #8790 (`716ac9bf8`, `find` `[]` → `INVALID_FILTER` / 400, the same shape as row 3) declared **BREAKING accept-set narrowing**: `minor`, with an ADR-0087 `registered` disposition and the migration entry `driver-sql-unresolvable-where-column-refused`. - That registered entry's `surface` already names "`driver-sql` (and its `TursoDriver` / `SqliteWasmDriver` subclasses)" and prescribes this change's remedy (name a real column, or run schema sync). So no new entry is owed; the `aggregate` legs are the same drifted-schema family with the same remedy. Remote-face users meet the refusal for the first time here, so the banner is owed. - #11541 (`ef52884a8`, `aggregate` 500 → `INVALID_FIELD` / 400) shipped as `patch` with no narrowing: a refusal that changed its code, not an accept set. - `RemoteTransport.find` and `RemoteTransport.aggregate`, exported from the package root, now raise the backend's error where they answered `[]`. The changeset says so. ## Files - `.changeset/20424-turso-remote-missing-table-column-refused.md` (+33 / -0) - `packages/drivers/driver-turso/src/remote-transport.ts` (+45 / -22) - `packages/drivers/driver-turso/src/turso-driver.ts` (+90 / -15) - `packages/drivers/driver-turso/src/remote-transport-unknown-select.test.ts` (+15 / -4) - `packages/drivers/driver-turso/src/turso-local-remote-missing-table-column-parity.test.ts` (+345 / -0) - `packages/drivers/driver-turso/src/turso-local-remote-external-object-parity.test.ts` (+3 / -2, a header prediction line only) In total, +531 / -43 over 6 files, under the 5,000-line threshold. No governed surface. ## Patch round 1 The seat's amended claim 5872640432 answered the round-0 open question with B, and accepted the two bounded in-place fixes and the H3 route. This round changes only the changeset and this body, with no code or test change: - The changeset: `patch` → `minor`; `Clause-②: no` → `Clause-②: no (narrowing)`; the **BREAKING** banner; the ADR-0087 marker `not-required (already-registered driver-sql-unresolvable-where-column-refused)` with its reason; a FROM → TO line naming the refusals, the fix, and `RemoteTransport.find` / `aggregate` now raising where they answered `[]`. - This body: the `Clause-②` line, the seat-ruling line under it, the "Clause-② and the changeset" section, the Files line and one Acceptance note. - `origin/main` merged at `2304b1608` (a true merge commit, five incoming commits, none on a `driver-*` path). Head `3b56eb769`. `git diff daad09a 3b56eb7 -- packages/drivers` is empty. After rebuilding the dependency closure on the merged tree: `pnpm --filter @objectstack/driver-turso exec vitest run --maxWorkers=2` gave `Test Files 75 passed (75)` and `Tests 2003 passed | 16 skipped (2019)`, and `typecheck` exited 0. - Changeset gates at `3b56eb769`, each exit code recorded before any pipe: `pnpm check:adr-0087-registration` exit 0; `node scripts/check-changeset-no-major.mjs --base origin/main --event` (this body) exit 0; `node scripts/check-empty-changeset.mjs --base origin/main` exit 0. ## Patch round 2 After the at-tier review 5873239611 (PASS at `3b56eb769`) and the seat's amended claim 5873267997, this round is text only plus a merge, with no logic or test-assertion change: - `remote-transport.ts`, the `$`-prefixed-key comment: its sentence saying `find()`'s `no such column` backstop "swallows the error into `[]` anyway" under `SQLITE_DQS=0` was made false by this PR. It now says the backstop refuses, as the local face does (`INVALID_FILTER` / 400), and that it used to answer `[]`. - `turso-local-remote-external-object-parity.test.ts`, the #20107 suite's header prediction for its first ablation leg: `aggregate` now refuses the missing table as `DATABASE_ERROR` / 500, instead of answering `[]` in place of the sum. - `origin/main` merged at `acd009521` (a true merge commit), carrying PR #20447 (`bea6d2ea3`, the constructor region of `turso-driver.ts`). The merge was clean. Head `0c47b7bbe`. - On the combined tree, after rebuilding the dependency closure: `pnpm --filter @objectstack/driver-turso test` gave `Test Files 76 passed (76)` and `Tests 2036 passed | 18 skipped (2054)` (the added file and cases are #20447's), and `typecheck` exited 0. - `dispatch-gates --commands` at `0c47b7bbe` derived the same 63 commands. After a full package build (71 of 71), all 63 exited 0 on the first pass. `--ran`: `63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3)`. Driver conformance: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.` Narrowed eslint over the 5 changed `.ts` files: 0 errors, 0 warnings. - Not changed, outside the claimed surface: the same #20107 header's third prediction ("A filter on the renamed field answers `[]`") is also out of date after this PR. With the column-map refusal deleted, that filter would now be refused `INVALID_FILTER` / 400. It is prediction prose, and no assertion depends on it. ## Acceptance notes - **The shared predicate is not exported.** `isUnresolvableColumnError` is not exported from `@objectstack/driver-sql`, so the remote find/count exit reaches it through `aggregateBackendFault` with a `where`-only query. An exported predicate, or a protected `where`-exit on `SqlDriver` shared by `count` and `findRows`, would be the plainer seam. This card may not edit `driver-sql`. Carrier: none. - **The transport keeps an inline recognizer.** `RemoteTransport.find` still recognises the unresolvable-column class inline (`no such column`, or `column` + `does not exist`) to gate its ladder. That pre-existing copy of the predicate lacks the MySQL arm, which libSQL never speaks. - **An unmeasured edge.** If a ladder rung fails with an error that is not a column error, the local face refuses `INVALID_FILTER` (unnamed wording) and this face answers `DATABASE_ERROR` / 500. No producer is known. - **Standalone transport use.** `RemoteTransport.find` and `RemoteTransport.aggregate`, used on their own without `TursoDriver`, now raise the backend's error where they answered `[]`. The changeset says so. - **`main` moved after the measured merge.** Round 0 did not re-merge `b28550818` (additive in `packages/spec` only). Patch round 1 merged `origin/main` at `2304b1608` (five commits, none on a `driver-*` path). It then moved once more, to `fbeb56e4b` (one `packages/spec` test pin, `turbo.json` and `scripts/cross-package-test-inputs.mjs`, no `driver-*` path). Patch round 2 merged `origin/main` at `acd009521`, which carries both. PR #20447 (#20200) landed as `bea6d2ea3`, is merged here, and driver-turso was re-tested on the combined tree (see "Patch round 2"). --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 75b2169 commit 3e8b492

6 files changed

Lines changed: 531 additions & 43 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
`TursoDriver` in **remote** mode refuses a read over a missing table or a missing column with the same code the local mode answers, instead of answering "no rows" (#20424).
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (already-registered driver-sql-unresolvable-where-column-refused) That registered entry names `TursoDriver` among the `SqlDriver` subclasses whose reads now refuse an unresolvable column, and prescribes this change's remedy: name a column the table has, or run schema sync so a declared field exists as a column. The `aggregate` legs (a table that is really absent, a `groupBy` or aggregation column that is absent) are the same drifted-schema family with the same remedy, so no new migration entry is owed. -->
10+
11+
**BREAKING** — an accept-set narrowing on the remote face of `TursoDriver`'s read doors, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level). Remote-face users meet this refusal for the first time here.
12+
13+
**FROM → TO.** A remote-face read (`aggregate`, `find`, `findOne`, `count`) that answered `[]` / `null` for a missing table or a missing column now refuses, as the local face does: `DATABASE_ERROR` / 500 for a table that is absent, `INVALID_FIELD` / 400 for a `groupBy` or aggregation column that is absent, `INVALID_FILTER` / 400 for a `where` column that is absent. **The fix:** run schema sync so the declared field has its column (or the object its table), or name a column the table has. `RemoteTransport.find` and `RemoteTransport.aggregate`, exported from the package root, now raise the backend's error where they answered `[]`.
14+
15+
**What was wrong.** Two catches in `RemoteTransport` read a backend "no such table" or "no such column" as an empty result. `aggregate` answered `[]` for both. `find` (and `findOne` through it) answered `[]` (`null`) for a missing column once its projection retry was spent, or when there was no projection to drop. So on a remote Turso database a schema drift or a missing table read as "there is no data", while the local mode of the same driver, over the same file, refused it. Measured with a local driver over the same libSQL file as the control, for a federated and for a managed object alike:
16+
17+
| read | local | remote before |
18+
|:--|:--|:--|
19+
| `aggregate` on a table that is really absent | `DATABASE_ERROR` / 500 | `[]` |
20+
| `aggregate` grouped by, or aggregating, a declared field whose column is absent | `INVALID_FIELD` / 400 | `[]` |
21+
| `aggregate` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` |
22+
| `find` / `findOne` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` / `null` |
23+
| `count` whose `where` names that field | `INVALID_FILTER` / 400 | `DATABASE_ERROR` / 500 |
24+
| `find` ordered by that field | the rows, unordered | `[]` |
25+
26+
**What changes, on the remote face only:**
27+
28+
- `aggregate`, `find`, `findOne` and `count` answer each row above the way the local face does. The backend's error is classified by the local face's own inherited seam, `SqlDriver.aggregateBackendFault`, and not by a second copy: an unresolvable column named by a `groupBy` or an aggregation is `INVALID_FIELD` / 400, one named by the `where` is `INVALID_FILTER` / 400, and anything else is `DATABASE_ERROR` / 500. The dialect text goes to the server log, never to the caller.
29+
- `find` keeps the local face's recovery ladder: a projection naming a column the table lacks is dropped first, then an ORDER BY on one, and the rows answer. A `where` is never dropped. Before, the ORDER BY rung was missing and the sort answered `[]`.
30+
- A refusal the transport raises while it compiles the statement (a filter or aggregate-vocabulary refusal, the timeout envelope) keeps its own code and status.
31+
- `RemoteTransport.find` and `RemoteTransport.aggregate`, used on their own, now raise the backend's error where they answered `[]`.
32+
33+
This is the refusal the registered migration entry `driver-sql-unresolvable-where-column-refused` already names for `driver-sql` "and its `TursoDriver` / `SqliteWasmDriver` subclasses": the remote face of `TursoDriver` now delivers it. **If a read now refuses for you:** the table or column it names is missing from the remote database. Run schema sync so the declared field has its column (or the object its table), or correct the name the query uses.

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,23 @@ describe('RemoteTransport unknown-$select column', () => {
5151
expect(calls[1].sql).toMatch(/SELECT \* FROM "product"/);
5252
});
5353

54-
it('still returns empty when even SELECT * fails (e.g. unknown table)', async () => {
55-
const { t } = transportWithClient(async () => {
54+
// [#20424] This case REPLACES the pin 'still returns empty when even SELECT *
55+
// fails (e.g. unknown table)', which asserted `[]` for exactly this input.
56+
// `[]` was the defect: a column the WHERE still names once the projection is
57+
// gone is a predicate that never ran, and "no rows" is a false answer to it.
58+
// The transport now raises the backend's error from the last rung, and
59+
// `TursoDriver` classifies it (`INVALID_FILTER` / 400, the local face's
60+
// answer). The same input, the opposite assertion.
61+
it('raises the last rung\'s error when even SELECT * fails, instead of answering []', async () => {
62+
const { t, calls } = transportWithClient(async () => {
5663
throw new Error('SQLITE_ERROR: no such column: status');
5764
});
58-
const result = await t.find('ghost', { fields: ['id', 'status'], limit: 10 });
59-
expect(result).toEqual([]);
65+
await expect(t.find('ghost', { fields: ['id', 'status'], limit: 10 })).rejects.toThrow(
66+
/no such column: status/,
67+
);
68+
// The projection attempt, then the one rung this query has.
69+
expect(calls).toHaveLength(2);
70+
expect(calls[1].sql).toMatch(/SELECT \* FROM "ghost"/);
6071
});
6172

6273
it('propagates non-column errors instead of hiding them as empty', async () => {

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

Lines changed: 45 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,16 +1412,39 @@ export class RemoteTransport {
14121412
// real rows still come back; the unknown field is simply absent from
14131413
// each row (it never existed). Mirrors the SqlDriver backstop — the
14141414
// remote Turso path overrides find(), so it needs its own copy.
1415+
//
1416+
// [#20424] The copy now has the local ladder's two rungs and its
1417+
// terminal. The rungs, in `SqlDriver.findRows`' order: the projection
1418+
// first, then the ORDER BY (#3821: rows matter more than their order),
1419+
// each rebuilt with the caller's WHERE, which neither rung may drop.
1420+
// The terminal was `return []` here, on both the no-rung path and a
1421+
// failed rung, so an unresolvable column in the WHERE, or an ORDER BY
1422+
// on a column the table lacks, read as "there are no rows". The local
1423+
// face answers the first with `INVALID_FILTER` / 400 (#8790) and the
1424+
// second with its rows, unordered. Now the last error leaves this
1425+
// method as the backend raised it, and `TursoDriver` classifies it
1426+
// with the local face's own read-exit seam. The refusal and its code
1427+
// are the driver's, not this transport's.
1428+
const rungs: any[] = [];
14151429
if (query?.fields && Array.isArray(query.fields) && query.fields.length > 0) {
1430+
rungs.push({ ...query, fields: undefined });
1431+
}
1432+
if (Array.isArray(query?.orderBy) && query.orderBy.some((item: any) => item?.field)) {
1433+
rungs.push({ ...query, fields: undefined, orderBy: undefined });
1434+
}
1435+
let lastError: unknown = error;
1436+
for (const rung of rungs) {
14161437
try {
1417-
const fallback = this.buildSelectSQL(object, { ...query, fields: undefined }, table);
1438+
const fallback = this.buildSelectSQL(object, rung, table);
14181439
const result = await this.client!.execute({ sql: fallback.sql, args: fallback.args });
14191440
return this.mapRows(result);
1420-
} catch {
1421-
return [];
1441+
} catch (rungError) {
1442+
// The next, broader rung. The last one to fail names the column
1443+
// the WHERE still holds, once the projection and the sort are gone.
1444+
lastError = rungError;
14221445
}
14231446
}
1424-
return [];
1447+
throw lastError;
14251448
}
14261449
throw error;
14271450
}
@@ -1626,19 +1649,18 @@ export class RemoteTransport {
16261649
sql += ` GROUP BY ${groupBy.map((g) => `"${g.field}"`).join(', ')}`;
16271650
}
16281651

1629-
try {
1630-
const result = await this.client!.execute({ sql, args });
1631-
return this.foldEmptyAggregateAnswers(this.mapRows(result), foldedOutput);
1632-
} catch (error: any) {
1633-
if (
1634-
error.message &&
1635-
(error.message.includes('no such table') ||
1636-
error.message.includes('no such column'))
1637-
) {
1638-
return [];
1639-
}
1640-
throw error;
1641-
}
1652+
// [#20424] The backend's error leaves this method as it was raised. A
1653+
// catch here used to answer `no such table` and `no such column` with `[]`,
1654+
// so a table that is really absent, or a groupBy, aggregation or WHERE
1655+
// naming a declared field whose column is absent, read as "no data". The
1656+
// local face of the same driver refuses all of them. `TursoDriver`
1657+
// classifies the error with the local face's own aggregate seam
1658+
// (`SqlDriver.aggregateBackendFault`): `INVALID_FIELD` / 400 for a groupBy
1659+
// or aggregation column, `INVALID_FILTER` / 400 for a WHERE column, and
1660+
// `DATABASE_ERROR` / 500 for the rest. The catch arrived with this method's
1661+
// first version, with no comment, no pin and no case it was written for.
1662+
const result = await this.client!.execute({ sql, args });
1663+
return this.foldEmptyAggregateAnswers(this.mapRows(result), foldedOutput);
16421664
}
16431665

16441666
/**
@@ -2883,11 +2905,12 @@ export class RemoteTransport {
28832905
//
28842906
// The first two are the silent empty set: SQLite's backwards-compatible
28852907
// rule degrades a double-quoted name that resolves to no column into a
2886-
// STRING LITERAL, so the statement compiles, runs and matches nothing —
2887-
// and where a build disables that rule (`SQLITE_DQS=0`) `find()`'s own
2888-
// `no such column` backstop swallows the error into `[]` anyway. Two
2889-
// roads, one answer, and neither is distinguishable from "no rows
2890-
// matched".
2908+
// STRING LITERAL, so the statement compiles, runs and matches nothing,
2909+
// which is not distinguishable from "no rows matched". Where a build
2910+
// disables that rule (`SQLITE_DQS=0`), the statement fails with
2911+
// `no such column` instead, and `find()`'s backstop now refuses it, as
2912+
// the local face does (`INVALID_FILTER` / 400, see #20424). It used to
2913+
// swallow that error into `[]` too: a second road to the same answer.
28912914
//
28922915
// The third is the expensive direction, and it needs no dialect quirk at
28932916
// all: a `{}` disjunct absorbs its `$or` to TRUE, the compiled clauses are

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

Lines changed: 90 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -673,8 +673,10 @@ function refuseRemoteInheritedMember(
673673
* this face could reach the right table and still name columns it does not
674674
* have. Measured on the remote face with the table resolved and the map
675675
* ignored, a filter on a renamed field answered an empty list: the transport
676-
* reads the backend's `no such column` as "no rows". A write failed with the
677-
* backend's own error.
676+
* then read the backend's `no such column` as "no rows". A write failed with the
677+
* backend's own error. (Since #20424 that read is refused `INVALID_FILTER` /
678+
* 400 instead, which is loud but still wrong for a field the author declared:
679+
* the refusal below stays the answer.)
678680
*
679681
* Translating the map here would be a second copy of the local compiler's
680682
* column rule inside the transport's compiler. That is the second
@@ -1918,7 +1920,10 @@ export class TursoDriver extends SqlDriver {
19181920
if (this.isRemote) {
19191921
const table = this.remoteTableFor(object, 'find');
19201922
const remoteQuery = this.toRemoteReadQuery(object, query);
1921-
return this.formatRemoteRows(object, await this.remoteReadExit(object, () => this.remoteTransport!.find(object, remoteQuery, table)));
1923+
return this.formatRemoteRows(
1924+
object,
1925+
await this.remoteReadExit(object, { where: query?.where }, () => this.remoteTransport!.find(object, remoteQuery, table)),
1926+
);
19221927
}
19231928
return super.find(object, query, options);
19241929
}
@@ -1934,7 +1939,10 @@ export class TursoDriver extends SqlDriver {
19341939
if (this.isRemote) {
19351940
const table = this.remoteTableFor(object, 'findOne');
19361941
const remoteQuery = this.toRemoteReadQuery(object, query, { singleRowLookup: true });
1937-
return this.formatRemoteRow(object, await this.remoteReadExit(object, () => this.remoteTransport!.findOne(object, remoteQuery, table)));
1942+
return this.formatRemoteRow(
1943+
object,
1944+
await this.remoteReadExit(object, { where: query?.where }, () => this.remoteTransport!.findOne(object, remoteQuery, table)),
1945+
);
19381946
}
19391947
return super.findOne(object, query, options);
19401948
}
@@ -2061,22 +2069,84 @@ export class TursoDriver extends SqlDriver {
20612069
* table is the one this face's statement named. It returns anything that
20622070
* already declares a `status` unchanged: the transport's filter refusals and
20632071
* the remote timeout envelope keep their own answers. `distinct` already
2064-
* reaches the same terminal through `distinctBackendFault`. `aggregate` is
2065-
* not wrapped, because nothing would reach a wrapper: the transport's own
2066-
* catch answers a missing table or column with `[]`, where the local face
2067-
* answers this envelope. That divergence predates this change and is not
2068-
* widened by it. The write doors are left alone exactly as the local face
2069-
* leaves them, because a write fault is classified at the REST boundary from
2070-
* its message.
2072+
* reaches the same terminal through `distinctBackendFault`. The write doors
2073+
* are left alone exactly as the local face leaves them, because a write
2074+
* fault is classified at the REST boundary from its message.
2075+
*
2076+
* [#20424] `aggregate` now ends here too, and every exit reaches the
2077+
* terminal through {@link remoteReadFault}, which adds the local face's
2078+
* unresolvable-column arms in front of it.
20712079
*/
2072-
private async remoteReadExit<T>(object: string, read: () => Promise<T>): Promise<T> {
2080+
private async remoteReadExit<T>(object: string, query: DriverQuery, read: () => Promise<T>): Promise<T> {
20732081
try {
20742082
return await read();
20752083
} catch (error) {
2076-
throw this.backendStatementFault(object, error);
2084+
throw this.remoteReadFault(object, query, error);
20772085
}
20782086
}
20792087

2088+
/**
2089+
* [#20424] Which envelope a backend error leaving a remote read exit
2090+
* deserves: the local face's answer, from the local face's own seam.
2091+
*
2092+
* # The defect this closes
2093+
*
2094+
* `RemoteTransport` answered a missing table or column with `[]` in two
2095+
* catches: `aggregate` for `no such table` and `no such column`, and the
2096+
* terminal of `find`'s projection backstop for `no such column`. Measured at
2097+
* base `6e3e5462c` over one libSQL `file:` database, with a local driver over
2098+
* the same file as the control, for a federated object and for a managed one
2099+
* alike:
2100+
*
2101+
* ```
2102+
* aggregate, table really absent local DATABASE_ERROR 500 remote []
2103+
* aggregate, groupBy a declared field, column absent local INVALID_FIELD 400 remote []
2104+
* find, where names a declared field, column absent local INVALID_FILTER 400 remote []
2105+
* findOne, the same where local INVALID_FILTER 400 remote null
2106+
* find, orderBy on that field local rows, unordered remote []
2107+
* count, the same where local INVALID_FILTER 400 remote DATABASE_ERROR 500
2108+
* ```
2109+
*
2110+
* The transport now lets the backend's error out (its `find` keeps the local
2111+
* ladder's projection and ORDER BY rungs first), and this classifies it.
2112+
*
2113+
* # The seam: `SqlDriver.aggregateBackendFault`, called, not copied
2114+
*
2115+
* The local face decides AFTER its statement runs, from the backend's error:
2116+
* `count` and `findRows` send an unresolvable column to
2117+
* `unresolvableFilterColumnRefusal` (#8790) and everything else to
2118+
* `backendStatementFault` (#8931); `aggregate` attributes the column to the
2119+
* clause the caller's own query names it in first (#11541). All three
2120+
* compositions are protected members this driver inherits. The class
2121+
* predicate they share, `isUnresolvableColumnError`, is not exported from
2122+
* `@objectstack/driver-sql`, so `aggregateBackendFault` is the one inherited
2123+
* member that asks it. For `aggregate` it is the local exit verbatim. For
2124+
* `find`, `findOne` and `count` it is handed the WHERE alone, and then it is
2125+
* the local exit too: with no groupBy and no aggregation its first arm cannot
2126+
* fire, its second is `unresolvableFilterColumnRefusal` with the caller's
2127+
* `where`, and its terminal is `backendStatementFault`. The one place the two
2128+
* could differ, a recognised wording whose column name does not parse (the
2129+
* local `count` still answers `INVALID_FILTER` there, this answers
2130+
* `DATABASE_ERROR`), cannot arise on libSQL, whose only wording is
2131+
* `no such column: <name>`.
2132+
*
2133+
* # Anything that already declares a `status` passes unchanged
2134+
*
2135+
* The local face guards only the statement's EXECUTION, so its classifier
2136+
* never sees a refusal raised while the statement is built. A remote door
2137+
* compiles and executes inside one transport call, so the transport's own
2138+
* refusals (the filter compiler's `INVALID_FILTER`, the aggregate
2139+
* vocabulary's refusals, the timeout envelope) arrive here beside the
2140+
* backend's errors. They all declare a `status`; a libSQL error declares
2141+
* none. So the gate `backendStatementFault` applies first on its own terms
2142+
* ("is it already ours") is applied here before the classifier reads a
2143+
* message, and the classifier sees exactly what the local one sees.
2144+
*/
2145+
private remoteReadFault(object: string, query: DriverQuery, error: unknown): Error {
2146+
if (typeof (error as { status?: unknown } | null | undefined)?.status === 'number') return error as Error;
2147+
return this.aggregateBackendFault(object, query, error);
2148+
}
2149+
20802150
/**
20812151
* [#6944] Refuse a remote write that would need a record number this face
20822152
* cannot issue — see {@link refuseRemoteAutonumber} for the ruling and the
@@ -2274,7 +2344,7 @@ export class TursoDriver extends SqlDriver {
22742344
if (this.isRemote) {
22752345
const table = this.remoteTableFor(object, 'count');
22762346
const remoteQuery = this.toRemoteQuery(object, query);
2277-
return this.remoteReadExit(object, () => this.remoteTransport!.count(object, remoteQuery, table));
2347+
return this.remoteReadExit(object, { where: query?.where }, () => this.remoteTransport!.count(object, remoteQuery, table));
22782348
}
22792349
return super.count(object, query, options);
22802350
}
@@ -2305,7 +2375,12 @@ export class TursoDriver extends SqlDriver {
23052375
this.assertRemoteTransactionUnsupported(options, 'aggregate');
23062376
if (this.isRemote) {
23072377
const table = this.remoteTableFor(object, 'aggregate');
2308-
return this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query), table);
2378+
// [#20424] The caller's own query is what the fault is attributed
2379+
// against, as `SqlDriver.aggregate` does locally: its groupBy and
2380+
// aggregation fields, and the `where` before `toRemoteFilter` rewrote it.
2381+
return this.remoteReadExit(object, query, () =>
2382+
this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query), table),
2383+
);
23092384
}
23102385
return super.aggregate(object, query, options);
23112386
}

0 commit comments

Comments
 (0)