diff --git a/.changeset/20424-turso-remote-missing-table-column-refused.md b/.changeset/20424-turso-remote-missing-table-column-refused.md new file mode 100644 index 0000000000..ce3d1465ae --- /dev/null +++ b/.changeset/20424-turso-remote-missing-table-column-refused.md @@ -0,0 +1,33 @@ +--- +"@objectstack/driver-turso": minor +--- + +`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). + +Clause-②: no (narrowing) + + + +**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. + +**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 `[]`. + +**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: + +| read | local | remote before | +|:--|:--|:--| +| `aggregate` on a table that is really absent | `DATABASE_ERROR` / 500 | `[]` | +| `aggregate` grouped by, or aggregating, a declared field whose column is absent | `INVALID_FIELD` / 400 | `[]` | +| `aggregate` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` | +| `find` / `findOne` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` / `null` | +| `count` whose `where` names that field | `INVALID_FILTER` / 400 | `DATABASE_ERROR` / 500 | +| `find` ordered by that field | the rows, unordered | `[]` | + +**What changes, on the remote face only:** + +- `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. +- `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 `[]`. +- 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. +- `RemoteTransport.find` and `RemoteTransport.aggregate`, used on their own, now raise the backend's error where they answered `[]`. + +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. diff --git a/packages/drivers/driver-turso/src/remote-transport-unknown-select.test.ts b/packages/drivers/driver-turso/src/remote-transport-unknown-select.test.ts index 27b95e87a8..31bcc322fc 100644 --- a/packages/drivers/driver-turso/src/remote-transport-unknown-select.test.ts +++ b/packages/drivers/driver-turso/src/remote-transport-unknown-select.test.ts @@ -51,12 +51,23 @@ describe('RemoteTransport unknown-$select column', () => { expect(calls[1].sql).toMatch(/SELECT \* FROM "product"/); }); - it('still returns empty when even SELECT * fails (e.g. unknown table)', async () => { - const { t } = transportWithClient(async () => { + // [#20424] This case REPLACES the pin 'still returns empty when even SELECT * + // fails (e.g. unknown table)', which asserted `[]` for exactly this input. + // `[]` was the defect: a column the WHERE still names once the projection is + // gone is a predicate that never ran, and "no rows" is a false answer to it. + // The transport now raises the backend's error from the last rung, and + // `TursoDriver` classifies it (`INVALID_FILTER` / 400, the local face's + // answer). The same input, the opposite assertion. + it('raises the last rung\'s error when even SELECT * fails, instead of answering []', async () => { + const { t, calls } = transportWithClient(async () => { throw new Error('SQLITE_ERROR: no such column: status'); }); - const result = await t.find('ghost', { fields: ['id', 'status'], limit: 10 }); - expect(result).toEqual([]); + await expect(t.find('ghost', { fields: ['id', 'status'], limit: 10 })).rejects.toThrow( + /no such column: status/, + ); + // The projection attempt, then the one rung this query has. + expect(calls).toHaveLength(2); + expect(calls[1].sql).toMatch(/SELECT \* FROM "ghost"/); }); it('propagates non-column errors instead of hiding them as empty', async () => { diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index 8d24ca857f..3d005ad41e 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -1412,16 +1412,39 @@ export class RemoteTransport { // real rows still come back; the unknown field is simply absent from // each row (it never existed). Mirrors the SqlDriver backstop — the // remote Turso path overrides find(), so it needs its own copy. + // + // [#20424] The copy now has the local ladder's two rungs and its + // terminal. The rungs, in `SqlDriver.findRows`' order: the projection + // first, then the ORDER BY (#3821: rows matter more than their order), + // each rebuilt with the caller's WHERE, which neither rung may drop. + // The terminal was `return []` here, on both the no-rung path and a + // failed rung, so an unresolvable column in the WHERE, or an ORDER BY + // on a column the table lacks, read as "there are no rows". The local + // face answers the first with `INVALID_FILTER` / 400 (#8790) and the + // second with its rows, unordered. Now the last error leaves this + // method as the backend raised it, and `TursoDriver` classifies it + // with the local face's own read-exit seam. The refusal and its code + // are the driver's, not this transport's. + const rungs: any[] = []; if (query?.fields && Array.isArray(query.fields) && query.fields.length > 0) { + rungs.push({ ...query, fields: undefined }); + } + if (Array.isArray(query?.orderBy) && query.orderBy.some((item: any) => item?.field)) { + rungs.push({ ...query, fields: undefined, orderBy: undefined }); + } + let lastError: unknown = error; + for (const rung of rungs) { try { - const fallback = this.buildSelectSQL(object, { ...query, fields: undefined }, table); + const fallback = this.buildSelectSQL(object, rung, table); const result = await this.client!.execute({ sql: fallback.sql, args: fallback.args }); return this.mapRows(result); - } catch { - return []; + } catch (rungError) { + // The next, broader rung. The last one to fail names the column + // the WHERE still holds, once the projection and the sort are gone. + lastError = rungError; } } - return []; + throw lastError; } throw error; } @@ -1626,19 +1649,18 @@ export class RemoteTransport { sql += ` GROUP BY ${groupBy.map((g) => `"${g.field}"`).join(', ')}`; } - try { - const result = await this.client!.execute({ sql, args }); - return this.foldEmptyAggregateAnswers(this.mapRows(result), foldedOutput); - } catch (error: any) { - if ( - error.message && - (error.message.includes('no such table') || - error.message.includes('no such column')) - ) { - return []; - } - throw error; - } + // [#20424] The backend's error leaves this method as it was raised. A + // catch here used to answer `no such table` and `no such column` with `[]`, + // so a table that is really absent, or a groupBy, aggregation or WHERE + // naming a declared field whose column is absent, read as "no data". The + // local face of the same driver refuses all of them. `TursoDriver` + // classifies the error with the local face's own aggregate seam + // (`SqlDriver.aggregateBackendFault`): `INVALID_FIELD` / 400 for a groupBy + // or aggregation column, `INVALID_FILTER` / 400 for a WHERE column, and + // `DATABASE_ERROR` / 500 for the rest. The catch arrived with this method's + // first version, with no comment, no pin and no case it was written for. + const result = await this.client!.execute({ sql, args }); + return this.foldEmptyAggregateAnswers(this.mapRows(result), foldedOutput); } /** @@ -2883,11 +2905,12 @@ export class RemoteTransport { // // The first two are the silent empty set: SQLite's backwards-compatible // rule degrades a double-quoted name that resolves to no column into a - // STRING LITERAL, so the statement compiles, runs and matches nothing — - // and where a build disables that rule (`SQLITE_DQS=0`) `find()`'s own - // `no such column` backstop swallows the error into `[]` anyway. Two - // roads, one answer, and neither is distinguishable from "no rows - // matched". + // STRING LITERAL, so the statement compiles, runs and matches nothing, + // which is not distinguishable from "no rows matched". Where a build + // disables that rule (`SQLITE_DQS=0`), the statement fails with + // `no such column` instead, and `find()`'s backstop now refuses it, as + // the local face does (`INVALID_FILTER` / 400, see #20424). It used to + // swallow that error into `[]` too: a second road to the same answer. // // The third is the expensive direction, and it needs no dialect quirk at // all: a `{}` disjunct absorbs its `$or` to TRUE, the compiled clauses are diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index afe3ad3106..3db777a5eb 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -673,8 +673,10 @@ function refuseRemoteInheritedMember( * this face could reach the right table and still name columns it does not * have. Measured on the remote face with the table resolved and the map * ignored, a filter on a renamed field answered an empty list: the transport - * reads the backend's `no such column` as "no rows". A write failed with the - * backend's own error. + * then read the backend's `no such column` as "no rows". A write failed with the + * backend's own error. (Since #20424 that read is refused `INVALID_FILTER` / + * 400 instead, which is loud but still wrong for a field the author declared: + * the refusal below stays the answer.) * * Translating the map here would be a second copy of the local compiler's * column rule inside the transport's compiler. That is the second @@ -1918,7 +1920,10 @@ export class TursoDriver extends SqlDriver { if (this.isRemote) { const table = this.remoteTableFor(object, 'find'); const remoteQuery = this.toRemoteReadQuery(object, query); - return this.formatRemoteRows(object, await this.remoteReadExit(object, () => this.remoteTransport!.find(object, remoteQuery, table))); + return this.formatRemoteRows( + object, + await this.remoteReadExit(object, { where: query?.where }, () => this.remoteTransport!.find(object, remoteQuery, table)), + ); } return super.find(object, query, options); } @@ -1934,7 +1939,10 @@ export class TursoDriver extends SqlDriver { if (this.isRemote) { const table = this.remoteTableFor(object, 'findOne'); const remoteQuery = this.toRemoteReadQuery(object, query, { singleRowLookup: true }); - return this.formatRemoteRow(object, await this.remoteReadExit(object, () => this.remoteTransport!.findOne(object, remoteQuery, table))); + return this.formatRemoteRow( + object, + await this.remoteReadExit(object, { where: query?.where }, () => this.remoteTransport!.findOne(object, remoteQuery, table)), + ); } return super.findOne(object, query, options); } @@ -2061,22 +2069,84 @@ export class TursoDriver extends SqlDriver { * table is the one this face's statement named. It returns anything that * already declares a `status` unchanged: the transport's filter refusals and * the remote timeout envelope keep their own answers. `distinct` already - * reaches the same terminal through `distinctBackendFault`. `aggregate` is - * not wrapped, because nothing would reach a wrapper: the transport's own - * catch answers a missing table or column with `[]`, where the local face - * answers this envelope. That divergence predates this change and is not - * widened by it. The write doors are left alone exactly as the local face - * leaves them, because a write fault is classified at the REST boundary from - * its message. + * reaches the same terminal through `distinctBackendFault`. The write doors + * are left alone exactly as the local face leaves them, because a write + * fault is classified at the REST boundary from its message. + * + * [#20424] `aggregate` now ends here too, and every exit reaches the + * terminal through {@link remoteReadFault}, which adds the local face's + * unresolvable-column arms in front of it. */ - private async remoteReadExit(object: string, read: () => Promise): Promise { + private async remoteReadExit(object: string, query: DriverQuery, read: () => Promise): Promise { try { return await read(); } catch (error) { - throw this.backendStatementFault(object, error); + throw this.remoteReadFault(object, query, error); } } + /** + * [#20424] Which envelope a backend error leaving a remote read exit + * deserves: the local face's answer, from the local face's own seam. + * + * # The defect this closes + * + * `RemoteTransport` answered a missing table or column with `[]` in two + * catches: `aggregate` for `no such table` and `no such column`, and the + * terminal of `find`'s projection backstop for `no such column`. Measured at + * base `6e3e5462c` over one libSQL `file:` database, with a local driver over + * the same file as the control, for a federated object and for a managed one + * alike: + * + * ``` + * aggregate, table really absent local DATABASE_ERROR 500 remote [] + * aggregate, groupBy a declared field, column absent local INVALID_FIELD 400 remote [] + * find, where names a declared field, column absent local INVALID_FILTER 400 remote [] + * findOne, the same where local INVALID_FILTER 400 remote null + * find, orderBy on that field local rows, unordered remote [] + * count, the same where local INVALID_FILTER 400 remote DATABASE_ERROR 500 + * ``` + * + * The transport now lets the backend's error out (its `find` keeps the local + * ladder's projection and ORDER BY rungs first), and this classifies it. + * + * # The seam: `SqlDriver.aggregateBackendFault`, called, not copied + * + * The local face decides AFTER its statement runs, from the backend's error: + * `count` and `findRows` send an unresolvable column to + * `unresolvableFilterColumnRefusal` (#8790) and everything else to + * `backendStatementFault` (#8931); `aggregate` attributes the column to the + * clause the caller's own query names it in first (#11541). All three + * compositions are protected members this driver inherits. The class + * predicate they share, `isUnresolvableColumnError`, is not exported from + * `@objectstack/driver-sql`, so `aggregateBackendFault` is the one inherited + * member that asks it. For `aggregate` it is the local exit verbatim. For + * `find`, `findOne` and `count` it is handed the WHERE alone, and then it is + * the local exit too: with no groupBy and no aggregation its first arm cannot + * fire, its second is `unresolvableFilterColumnRefusal` with the caller's + * `where`, and its terminal is `backendStatementFault`. The one place the two + * could differ, a recognised wording whose column name does not parse (the + * local `count` still answers `INVALID_FILTER` there, this answers + * `DATABASE_ERROR`), cannot arise on libSQL, whose only wording is + * `no such column: `. + * + * # Anything that already declares a `status` passes unchanged + * + * The local face guards only the statement's EXECUTION, so its classifier + * never sees a refusal raised while the statement is built. A remote door + * compiles and executes inside one transport call, so the transport's own + * refusals (the filter compiler's `INVALID_FILTER`, the aggregate + * vocabulary's refusals, the timeout envelope) arrive here beside the + * backend's errors. They all declare a `status`; a libSQL error declares + * none. So the gate `backendStatementFault` applies first on its own terms + * ("is it already ours") is applied here before the classifier reads a + * message, and the classifier sees exactly what the local one sees. + */ + private remoteReadFault(object: string, query: DriverQuery, error: unknown): Error { + if (typeof (error as { status?: unknown } | null | undefined)?.status === 'number') return error as Error; + return this.aggregateBackendFault(object, query, error); + } + /** * [#6944] Refuse a remote write that would need a record number this face * cannot issue — see {@link refuseRemoteAutonumber} for the ruling and the @@ -2274,7 +2344,7 @@ export class TursoDriver extends SqlDriver { if (this.isRemote) { const table = this.remoteTableFor(object, 'count'); const remoteQuery = this.toRemoteQuery(object, query); - return this.remoteReadExit(object, () => this.remoteTransport!.count(object, remoteQuery, table)); + return this.remoteReadExit(object, { where: query?.where }, () => this.remoteTransport!.count(object, remoteQuery, table)); } return super.count(object, query, options); } @@ -2305,7 +2375,12 @@ export class TursoDriver extends SqlDriver { this.assertRemoteTransactionUnsupported(options, 'aggregate'); if (this.isRemote) { const table = this.remoteTableFor(object, 'aggregate'); - return this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query), table); + // [#20424] The caller's own query is what the fault is attributed + // against, as `SqlDriver.aggregate` does locally: its groupBy and + // aggregation fields, and the `where` before `toRemoteFilter` rewrote it. + return this.remoteReadExit(object, query, () => + this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query), table), + ); } return super.aggregate(object, query, options); } diff --git a/packages/drivers/driver-turso/src/turso-local-remote-external-object-parity.test.ts b/packages/drivers/driver-turso/src/turso-local-remote-external-object-parity.test.ts index 94c909c328..a3d11433ae 100644 --- a/packages/drivers/driver-turso/src/turso-local-remote-external-object-parity.test.ts +++ b/packages/drivers/driver-turso/src/turso-local-remote-external-object-parity.test.ts @@ -45,8 +45,9 @@ * ## Reverse verification, directions predicted BEFORE running * * - `remoteTableFor` returning the object name: sections 1 and 2 go RED, with - * `no such table: ext_t` on every door but `aggregate`, and `[]` in place of - * the sum there. In section 3 the declared table stays `absent_t` but the + * `no such table: ext_t` on every door, `aggregate` included, which refuses + * it as `DATABASE_ERROR` / 500 (before #20424 it answered `[]` in place of + * the sum). In section 3 the declared table stays `absent_t` but the * backend's phrase names `ext_gone`, so the phrase assertion goes RED. * - `remoteReadExit` rethrowing the error unchanged: section 3 goes RED on * `code` (`SQLITE_ERROR`) and `status` (`undefined`). diff --git a/packages/drivers/driver-turso/src/turso-local-remote-missing-table-column-parity.test.ts b/packages/drivers/driver-turso/src/turso-local-remote-missing-table-column-parity.test.ts new file mode 100644 index 0000000000..96629a6144 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-local-remote-missing-table-column-parity.test.ts @@ -0,0 +1,345 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20424] `TursoDriver` LOCAL and REMOTE — a missing table or a missing + * column is refused on both faces, with the same code, and never read as + * "there are no rows". + * + * ## What was measured before this change + * + * Base `6e3e5462c`. The remote face is a `TursoDriver` over a real + * `@libsql/client` on a `file:` database. The local control is a `TursoDriver` + * over the SAME file. A declared field whose column is absent is a field the + * object declares and the table lacks: schema drift. + * + * ``` + * row read local remote + * 1 aggregate, the mapped table really absent DATABASE_ERROR / 500 [] + * 2 aggregate grouped by that field INVALID_FIELD / 400 [] + * 3 find whose where names that field INVALID_FILTER / 400 [] + * findOne, the same where INVALID_FILTER / 400 null + * count, the same where INVALID_FILTER / 400 DATABASE_ERROR / 500 + * aggregate over that field (sum) INVALID_FIELD / 400 [] + * aggregate whose where names that field INVALID_FILTER / 400 [] + * find ordered by that field rows, unordered [] + * ``` + * + * Rows 1 to 3 held for a managed object too, with its table or its column + * dropped under it. The `[]` answers came from two catches in + * `RemoteTransport`: `aggregate`'s, which mapped `no such table` and `no such + * column` to `[]`, and the terminal of `find`'s projection backstop, which + * mapped `no such column` to `[]` once the projection retry was spent or when + * there was no projection to drop. + * + * ## What is pinned + * + * 1. The card's three rows, each as a local-and-remote pair, for a federated + * object and for a managed one: both faces refuse with the same `code` and + * `status`, which are the local face's. + * 2. The neighbours the same catches (and the shared find/count exit) reached: + * findOne, count, an aggregation over the field, an aggregate whose where + * names it. And the ORDER BY: the local ladder drops a sort on a column the + * table lacks and answers the rows, and so does the remote face now. + * 3. Controls: an existing table and column answer the literal rows on both + * faces, and a refusal the transport raises while compiling keeps its own + * envelope. + * + * ## Reverse verification, directions predicted BEFORE running + * + * - `RemoteTransport.aggregate`'s catch restored (`[]` for no such table / + * column): every aggregate pair in sections 1 and 2 goes RED, the remote + * side resolving `[]`. find, findOne, count and the ORDER BY case stay green. + * - The find ladder's terminal back to `return []`: the find and findOne pairs + * go RED (`[]` / `null`). count, aggregate and the ORDER BY case stay green. + * - `remoteReadFault` answering `backendStatementFault` alone: every 400 pair + * goes RED with `DATABASE_ERROR` / 500 on the remote side. Both missing-table + * pairs stay green (they are 500 on both faces anyway). + * - The ORDER BY rung removed: the ORDER BY case goes RED, the remote face + * refusing with `INVALID_FILTER` where the local face answers the rows. + * - The declared-status gate in `remoteReadFault` removed: the synthetic + * enveloped-refusal case goes RED, re-worded as `INVALID_FIELD`. The real + * compile-refusal control stays green, because no real refusal text parses + * as a backend column fault today; the synthetic case is what holds the gate. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createClient, type Client } from '@libsql/client'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { TursoDriver } from './turso-driver.js'; + +interface Envelope extends Error { + code?: string; + status?: number; +} + +const QUIET = { warn() {}, error() {}, info() {}, debug() {} }; + +/** Federated: its table exists, and it declares `region`, which the table lacks. */ +const EXT = { + name: 'ext_t', + fields: { id: { type: 'text' }, name: { type: 'text' }, amount: { type: 'number' }, region: { type: 'text' } }, + external: { remoteName: 'probe_t' }, +}; + +/** Federated: its mapped table is really absent. */ +const EXT_GONE = { + name: 'ext_gone', + fields: { id: { type: 'text' }, name: { type: 'text' } }, + external: { remoteName: 'absent_t' }, +}; + +/** Managed: synced by the driver; its `region` column is dropped under it. */ +const MANAGED = { + name: 'managed_t', + fields: { name: { type: 'text' }, amount: { type: 'number' }, region: { type: 'text' } }, +}; + +/** Managed: synced by the driver; its table is dropped under it. */ +const MANAGED_GONE = { name: 'managed_gone', fields: { name: { type: 'text' } } }; + +const SEED = [ + { id: 'a', name: 'alpha', amount: 1 }, + { id: 'b', name: 'beta', amount: 2 }, + { id: 'c', name: 'beta', amount: 4 }, +]; + +const scratch = mkdtempSync(join(tmpdir(), 'turso-20424-')); + +type Face = { driver: TursoDriver; client: Client | null }; + +async function localFace(file: string): Promise { + const driver = new TursoDriver({ url: `file:${file}` }); + (driver as unknown as { logger: typeof QUIET }).logger = QUIET; + await driver.connect(); + expect(driver.transportMode).toBe('local'); + return { driver, client: null }; +} + +async function remoteFace(file: string): Promise { + const client = createClient({ url: `file:${file}` }); + const driver = new TursoDriver({ url: 'libsql://issue-20424.turso.io', client }); + (driver as unknown as { logger: typeof QUIET }).logger = QUIET; + await driver.connect(); + expect(driver.transportMode).toBe('remote'); + return { driver, client }; +} + +async function close(face: Face): Promise { + await face.driver.disconnect(); + face.client?.close(); +} + +async function refusalOf(run: () => Promise): Promise { + let answer: unknown; + try { + answer = await run(); + } catch (e) { + return e as Envelope; + } + throw new Error(`expected the driver to refuse, but the call resolved ${JSON.stringify(answer)}`); +} + +const byId = (rows: unknown): Array> => + [...(rows as Array>)].sort((x, y) => String(x.id).localeCompare(String(y.id))); + +let file: string; +let local: Face; +let remote: Face; + +beforeAll(async () => { + file = join(scratch, 'db.sqlite'); + const seed = createClient({ url: `file:${file}` }); + await seed.execute('CREATE TABLE probe_t (id TEXT PRIMARY KEY, name TEXT, amount REAL)'); + for (const row of SEED) { + await seed.execute({ sql: 'INSERT INTO probe_t (id, name, amount) VALUES (?, ?, ?)', args: [row.id, row.name, row.amount] }); + } + seed.close(); + + local = await localFace(file); + remote = await remoteFace(file); + for (const face of [local, remote]) { + face.driver.registerExternalObject(EXT); + face.driver.registerExternalObject(EXT_GONE); + } + // The remote face creates the managed tables; the local face then syncs the + // same objects so it holds the same field metadata over the same tables. + for (const face of [remote, local]) { + await face.driver.syncSchema(MANAGED.name, MANAGED); + await face.driver.syncSchema(MANAGED_GONE.name, MANAGED_GONE); + } + await remote.driver.create(MANAGED.name, { id: 'm1', name: 'kept', amount: 3, region: 'EU' }); + + // The drift: a declared column and a synced table disappear under the driver. + const raw = createClient({ url: `file:${file}` }); + await raw.execute(`ALTER TABLE ${MANAGED.name} DROP COLUMN region`); + await raw.execute(`DROP TABLE ${MANAGED_GONE.name}`); + raw.close(); +}); + +afterAll(async () => { + await close(local); + await close(remote); + rmSync(scratch, { recursive: true, force: true }); +}); + +type Read = (d: TursoDriver) => Promise; + +/** A refused read: the local face's code and status, and the remote face's, which must be the same. */ +function refusedOnBothFaces(label: string, read: Read, expected: { code: string; status: number }): void { + it(`${label}: local ${expected.code} / ${expected.status}, and remote the same`, async () => { + const onLocal = await refusalOf(() => read(local.driver)); + expect({ code: onLocal.code, status: onLocal.status }).toEqual(expected); + const onRemote = await refusalOf(() => read(remote.driver)); + expect({ code: onRemote.code, status: onRemote.status }).toEqual(expected); + }); +} + +const agg = (q: Record) => q as DriverQuery; + +// ── 1. The card's three rows ───────────────────────────────────────────────── + +describe('[#20424] the three rows, local and remote, federated and managed', () => { + refusedOnBothFaces( + 'row 1, federated: aggregate on a mapped table that is really absent', + (d) => d.aggregate(EXT_GONE.name, agg({ aggregations: [{ function: 'count', alias: 'n' }] })), + { code: 'DATABASE_ERROR', status: 500 }, + ); + refusedOnBothFaces( + 'row 1, managed: aggregate on a synced table dropped under the driver', + (d) => d.aggregate(MANAGED_GONE.name, agg({ aggregations: [{ function: 'count', alias: 'n' }] })), + { code: 'DATABASE_ERROR', status: 500 }, + ); + refusedOnBothFaces( + 'row 2, federated: aggregate grouped by a declared field whose column is absent', + (d) => d.aggregate(EXT.name, agg({ groupBy: ['region'], aggregations: [{ function: 'count', alias: 'n' }] })), + { code: 'INVALID_FIELD', status: 400 }, + ); + refusedOnBothFaces( + 'row 2, managed: aggregate grouped by a declared field whose column was dropped', + (d) => d.aggregate(MANAGED.name, agg({ groupBy: ['region'], aggregations: [{ function: 'count', alias: 'n' }] })), + { code: 'INVALID_FIELD', status: 400 }, + ); + refusedOnBothFaces( + 'row 3, federated: find whose where names a declared field whose column is absent', + (d) => d.find(EXT.name, { where: { region: 'EU' } }), + { code: 'INVALID_FILTER', status: 400 }, + ); + refusedOnBothFaces( + 'row 3, managed: find whose where names a declared field whose column was dropped', + (d) => d.find(MANAGED.name, { where: { region: 'EU' } }), + { code: 'INVALID_FILTER', status: 400 }, + ); +}); + +// ── 2. What the same catches and the same exit also reached ────────────────── + +describe('[#20424] the neighbours, local and remote', () => { + refusedOnBothFaces( + 'findOne whose where names the absent column', + (d) => d.findOne(EXT.name, { where: { region: 'EU' } }), + { code: 'INVALID_FILTER', status: 400 }, + ); + refusedOnBothFaces( + 'find with a projection AND a where naming the absent column (the projection rung cannot save it)', + (d) => d.find(EXT.name, { where: { region: 'EU' }, fields: ['id', 'region'] } as DriverQuery), + { code: 'INVALID_FILTER', status: 400 }, + ); + refusedOnBothFaces( + 'count whose where names the absent column', + (d) => d.count(EXT.name, { where: { region: 'EU' } }), + { code: 'INVALID_FILTER', status: 400 }, + ); + refusedOnBothFaces( + 'aggregate over the absent column (sum)', + (d) => d.aggregate(EXT.name, agg({ aggregations: [{ function: 'sum', field: 'region', alias: 's' }] })), + { code: 'INVALID_FIELD', status: 400 }, + ); + refusedOnBothFaces( + 'aggregate whose where names the absent column', + (d) => d.aggregate(EXT.name, agg({ where: { region: 'EU' }, aggregations: [{ function: 'count', alias: 'n' }] })), + { code: 'INVALID_FILTER', status: 400 }, + ); + refusedOnBothFaces( + 'count on a managed object whose column was dropped', + (d) => d.count(MANAGED.name, { where: { region: 'EU' } }), + { code: 'INVALID_FILTER', status: 400 }, + ); + + it('find ordered by the absent column: the sort is dropped and the rows answer, on both faces', async () => { + const read: Read = (d) => d.find(EXT.name, { orderBy: [{ field: 'region', order: 'asc' }] } as DriverQuery); + expect(byId(await read(local.driver))).toEqual(SEED); + expect(byId(await read(remote.driver))).toEqual(SEED); + }); + + it('find projecting the absent column: the projection is dropped and the rows answer, on both faces', async () => { + const read: Read = (d) => d.find(EXT.name, { fields: ['id', 'region'] } as DriverQuery); + expect(byId(await read(local.driver))).toEqual(SEED); + expect(byId(await read(remote.driver))).toEqual(SEED); + }); +}); + +// ── 3. Controls ────────────────────────────────────────────────────────────── + +describe('[#20424] controls: what exists still answers, and a compile refusal keeps its envelope', () => { + const ANSWERS: ReadonlyArray = [ + ['find with a filter on a real column', async (d) => byId(await d.find(EXT.name, { where: { name: 'beta' } })), SEED.slice(1)], + ['findOne', (d) => d.findOne(EXT.name, { where: { id: 'b' } }), SEED[1]], + ['count with a filter', (d) => d.count(EXT.name, { where: { amount: { $gt: 1 } } }), 2], + [ + 'aggregate grouped by a real column', + async (d) => + [...(await d.aggregate(EXT.name, agg({ groupBy: ['name'], aggregations: [{ function: 'sum', field: 'amount', alias: 's' }] })))].sort( + (x, y) => String(x.name).localeCompare(String(y.name)), + ), + [ + { name: 'alpha', s: 1 }, + { name: 'beta', s: 6 }, + ], + ], + ['managed find on its surviving columns', (d) => d.find(MANAGED.name, { where: { name: 'kept' }, fields: ['id', 'name', 'amount'] } as DriverQuery), [{ id: 'm1', name: 'kept', amount: 3 }]], + ['managed aggregate on a surviving column', (d) => d.aggregate(MANAGED.name, agg({ aggregations: [{ function: 'sum', field: 'amount', alias: 's' }] })), [{ s: 3 }]], + ]; + + for (const [label, read, expected] of ANSWERS) { + it(`${label}: the same literal answer on both faces`, async () => { + expect(await read(local.driver)).toEqual(expected); + expect(await read(remote.driver)).toEqual(expected); + }); + } + + it('an undeclared aggregate function is refused before any statement, with the same envelope on both faces', async () => { + const read: Read = (d) => d.aggregate(EXT.name, agg({ aggregations: [{ function: 'COUNT', alias: 'n' }] })); + const onLocal = await refusalOf(() => read(local.driver)); + const execute = vi.spyOn(remote.client!, 'execute'); + try { + const onRemote = await refusalOf(() => read(remote.driver)); + expect({ code: onRemote.code, status: onRemote.status }).toEqual({ code: onLocal.code, status: onLocal.status }); + expect(onRemote.status).toBe(400); + expect(execute).not.toHaveBeenCalled(); + } finally { + execute.mockRestore(); + } + }); + + it('a refusal that already declares a status leaves the remote exit unchanged, even when its words read as a column fault', async () => { + // Synthetic on purpose: no real transport refusal is worded like a + // backend column fault today, so this is what holds the gate that keeps + // the classifier to the errors the local one would see. + const transport = (remote.driver as unknown as { remoteTransport: { aggregate: (...args: unknown[]) => Promise } }) + .remoteTransport; + const enveloped = Object.assign(new Error('no such column: region'), { code: 'NOT_IMPLEMENTED', status: 501 }); + const stub = vi.spyOn(transport, 'aggregate').mockRejectedValue(enveloped); + try { + const onRemote = await refusalOf(() => + remote.driver.aggregate(EXT.name, agg({ groupBy: ['region'], aggregations: [{ function: 'count', alias: 'n' }] })), + ); + expect(onRemote).toBe(enveloped); + expect({ code: onRemote.code, status: onRemote.status }).toEqual({ code: 'NOT_IMPLEMENTED', status: 501 }); + } finally { + stub.mockRestore(); + } + }); +});