Skip to content

Commit dbddf02

Browse files
fix(driver-turso): the remote face reads and writes a federated object's external.remoteName table (#20422)
Fixes #20107 Clause-②: yes (widening) ## Summary On the Turso **remote** face, a federated object (ADR-0015) is now read from and written to the table its `external.remoteName` declares, the way the local face always has. Every remote data door reads the table from `SqlDriver`'s own `physicalTableByObject` registry, the one `getBuilder` reads locally, and passes it to `RemoteTransport` per call. The registry is not moved or copied. The remote `find` / `findOne` / `count` exits now end in the local face's read-exit envelope (`backendStatementFault`, `DATABASE_ERROR` / 500), so a backend "no such table" no longer reaches a caller as a bare `LibsqlError`. A federated object whose `external.columnMap` renames a column is refused `NOT_IMPLEMENTED` / 501 on every remote data door, before any statement is sent (see "The column-map refusal" below). Measured head: `37414c5e8` (this branch after merging `origin/main` at `40b315b03`). ## Patch round 1 The at-tier contract review (record 5868256854 on this PR) passed ① and ③ and failed ② alone. `RemoteTransport` is exported from the package root, and its 14 data methods gained an optional `table` argument that the implementation honours. That is a signature widening of a published export, and the changeset had declared `patch` with `Clause-②: no`. The seat's amended claim (5868269122 on the card) reads `Clause-②: yes (widening)`. This round changes only the changeset and this body, with no code or test change: - The changeset ships `minor`, and its `Clause-②` line reads `yes (widening)`. - A new bullet names the widening. - The refusal bullet names the binding with no `remoteName`, or with `remoteName` equal to the object name, plus a renaming map. The seat rules that shape an accidental half-service rather than a published accept set, so there is no narrowing arm and no ADR-0087 marker. Code and tests are byte-identical to `37414c5e8`, where the tests, ablations and gates below were measured. The changeset gates were re-run at the new head `20375d3da`, whose only change is the changeset. ## What was measured on `main` (H1) Measured at base `789b2ae54`. 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. Both registered `{ name: 'ext_t', external: { remoteName: 'probe_t' } }`. | door | local | remote before | |---|---|---| | `find`, `findOne`, `count` | rows from `probe_t` | `LibsqlError` `SQLITE_ERROR: no such table: ext_t`, `status` undefined | | `aggregate` | `[{"s":3}]` | `[]` (the transport reads "no such table" as no rows) | | `distinct` | `["alpha","beta"]` | `DATABASE_ERROR` / 500 | | `create`, `update`, `upsert`, `delete`, `bulkCreate`, `bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany` | landed in `probe_t` | `LibsqlError` `SQLITE_ERROR: no such table: ext_t` | The seat's source count (one `remoteName` in `turso-driver.ts`, zero in `remote-transport.ts`) matched the behaviour. ## Where the mapping lives (H2) `SqlDriver.registerExternalObject` writes `physicalTableByObject` / `objectByPhysicalTable` / `fieldColumnByObject` / `columnFieldByObject`. All four are `protected`, so `TursoDriver` reads them where they are. `getBuilder` resolves `physicalTableByObject[object] ?? object` for every local statement. The remote arms handed `RemoteTransport` the object name, and the transport interpolated it as the table in every statement it built. The driver-sql mapping is untouched. ## Which answer the docs and callers expect (H3): resolve - `packages/spec/src/contracts/data-driver.ts`, `IDataDriver.registerExternalObject`: drivers that support federation implement it "to record the physical remote table (`external.remoteName` / `remoteSchema`) ... so queries resolve to the remote table". A driver that does not support federation omits it. `TursoDriver`'s remote face carries it: `REMOTE_FACE_ANSWERS.registerExternalObject` is `'inherited'`. - `docs/adr/0015-external-datasource-federation.md`, Scope: "`remoteName` is honoured on **all** dialects." - `content/docs/data-modeling/external-datasources.mdx` promises federation for a datasource with `schemaMode: 'external'` and does not fence any driver or transport out. `content/docs/data-modeling/drivers.mdx` lists `turso` / `libsql` as a datasource driver and says nothing about federation on either transport. That silence is itself a reading: nothing limits it to the local face. - Callers: `ObjectQLPlugin.syncRegisteredSchemas` and `reconcileFederatedBindings` (`packages/objectql/src/plugin.ts`) and `ObjectQL.syncObjectSchema` (`packages/objectql/src/engine.ts`) call `driver.registerExternalObject(obj)` on any driver that has it, and count the object as bound when the call returns. So the refusal route would have taken away a capability the contract and ADR-0015 promise on every driver that carries the member. Resolving delivers it. ## Door by door after the change (H4) - Every read door (`find`, `findOne`, `count`, `aggregate`, `distinct`) answers from the mapped table, and gives the same answer as the local face over the same file. - Every write door lands in the mapped table with the local face's answer and the same rows after. No table named after the object is ever created. - A mapped table that is really absent: `find` / `findOne` / `count` answer `DATABASE_ERROR` / 500 on both faces. The `LibsqlError` rides under a non-enumerable `cause`, the message withholds the physical table, and the declared targeted table (for `isMissingTableError`) is the one the backend's phrase names. - Where H4 does not hold as worded: the **write** doors on a mapped table that is really absent still raise the backend's own error, on both faces. Measured, the local control raises `SqliteError` `insert into \`absent_t\` ... - no such table: absent_t` with no `status`. That is `backendStatementFault`'s documented boundary: write faults are classified at the REST boundary from their message, and composing a 500 over them would turn precise 409 / 400 answers into 500s. The remote face now matches the local face there. The defect's own bare error, "no such table" caused by the ignored mapping, no longer arises on any door. - `aggregate` on a mapped table that is really absent still answers `[]` on the remote face, where the local face answers 500. That is a pre-existing transport catch that applies to managed objects equally. It is listed in the Acceptance notes and was not changed here. ## The column-map refusal: a bounded in-place fix, and why it is owed With the table resolved but `external.columnMap` still ignored, I measured a remote face with the refusal bypassed. A filter on a renamed field answered `[]`, because the transport's `find` reads the backend's "no such column" as no rows. A write failed with `LibsqlError: table probe_t has no column named region`. Before this change the same object failed loudly on every door. So resolving the table alone would have turned one door from a loud failure into a silent empty answer, and this change has to handle it. Translating the map means a second copy of the local compiler's column rule inside the transport's WHERE, projection, ORDER BY, aggregate and write builders. That is not mechanical, and ADR-0053 D-A1 rules out a second implementation. So the object is refused, and all four exemption conditions hold: - ① same defect class: the remote face ignores a key of the same `external` binding, written by the same registration; - ② mechanical, with a pinned shape: the file's own `NOT_IMPLEMENTED` / 501 refusal taxonomy, keyed on the local face's own `columnFieldByObject`; - ③ no other claim holds these lines; - ④ same gate family, and the test sits in the same package. A map whose entries rename nothing is a no-op on the local face too, and is served. One shape did partly answer before, and is refused now too: a binding with no `remoteName`, or with `remoteName` equal to the object name, plus a renaming map. It reached its table on this face. Its unfiltered reads, `count({})` and id-keyed writes answered, while every filter or write naming a renamed field answered a silent `[]` or the backend's error. The seat rules it an accidental half-service rather than a published accept set, so the refusal carries no narrowing arm. The changeset names that shape and says what to do if the refusal fires. ## Files - `packages/drivers/driver-turso/src/turso-driver.ts`: `remoteTableFor` (the table lookup plus the column-map refusal, called first in every remote data arm), `remoteReadExit` (the `find` / `findOne` / `count` terminal), `refuseRemoteColumnMap`, and doc updates. Not touched: the remote `reclaimSpace` route or the constructor. - `packages/drivers/driver-turso/src/remote-transport.ts`: the 14 data methods take the physical table as an optional last argument, which defaults to the object. `tableSql` quotes the table as one name with any embedded quote doubled, because `external.remoteName` is authored as any string. - `packages/drivers/driver-turso/src/turso-local-remote-external-object-parity.test.ts`: 54 cases. - `.changeset/20107-turso-remote-external-remote-name.md`: `minor`, `Clause-②: yes (widening)`. ## Tests - `pnpm --filter @objectstack/driver-turso test` at `37414c5e8`: 74 files passed, 1975 tests passed, 16 skipped (1991). - `pnpm --filter @objectstack/driver-turso typecheck` at `37414c5e8`: exit 0. `tsc --listFiles` includes the new test file. - New file: `Tests 54 passed (54)`. ### Reverse verification (committed first, all three legs at `48968c65c`, directions predicted in the test header) Each leg went through `scripts/ablation-replace.mjs`: the anchor hit 1 → 0, the blob changed, the restore was proven by blob == HEAD and an empty `git diff HEAD`, and an outer trap re-verified the restore. The subject resolves from `src/` through a relative import (vitest, no alias, no `dist/` in the path), so no rebuild sits between the mutation and the run. - **A**, `remoteTableFor` returns the object: **22 failed / 32 passed**. That is all 8 remote read-parity cases, all 10 write-parity cases, the 3 "declared table equals the phrase's table" cases (`expected 'SQLITE_ERROR: no such table: ext_gone' to match /no such table: absent_t\b/`), and the identity-map case. The failures show `LibsqlError: SQLITE_ERROR: no such table: ext_t`, and aggregate shows `[]` against `[{ total: 7 }]`. The local controls and the managed-object control stayed green. - **B**, `remoteReadExit` rethrows bare: **9 failed / 45 passed**, exactly section 3 (`expected 'SQLITE_ERROR' to be 'DATABASE_ERROR'`). - **C**, the column-map refusal is deleted: **15 failed / 39 passed**. All 14 doors failed, plus "the refused writes left probe_mapped as seeded". Find resolved `[]` ("expected the driver to refuse, but the call resolved"), writes raised `SQLITE_ERROR`, count raised `DATABASE_ERROR`, and distinct raised `INVALID_FIELD`. ### Gates at `37414c5e8` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands. All 63 were run, and each exit code was captured before any pipe. `--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)`. 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 (`turbo run build --filter='./packages/*' --filter='./packages/*/*'`) and re-ran them, and all three exited 0. The dist-sweeping gates `check:dts-closure` (71 packages), `check:sourcemap-no-sources-content` (68) and `check:published-files` were re-run on the full build and all exited 0. Driver conformance ledger: `50 covered cell(s), 0 in the DEBT ledger` before, at `789b2ae54`, and after, at `37414c5e8`. Lint, narrowed to the changed files: `eslint --no-inline-config --format json` over the three changed `.ts` files gave 3 files, 0 errors and 0 warnings. The population comes from `eslint.config.mjs`: `packages/**/*.{ts,tsx,mts,cts}` is linted. The effective config for these files has `parserOptions` `{"ecmaVersion":"latest","sourceType":"module"}` and no `project`, and all six active rules are per-file AST rules. So type-aware linting is off, and this diff cannot change a verdict on any untouched file. The full `pnpm lint` is CI's. ## Acceptance notes - **Pre-existing, not changed here: remote reads that answer an invented empty answer.** At `37414c5e8`, with the local face as control over the same file: - `aggregate` on a mapped table that is really absent: remote `[]`, local `DATABASE_ERROR` / 500. - `aggregate` grouped by a declared field whose column is absent: remote `[]`, local `INVALID_FIELD` / 400. - `find` whose WHERE names a declared field whose column is absent: remote `[]`, local `INVALID_FILTER` / 400. The first two come from `RemoteTransport.aggregate`'s catch. The third comes from `RemoteTransport.find`'s `$select` backstop, which returns `[]` when no projection is present. They apply to managed objects as much as to federated ones. They are reported for the seat to file, not changed here. - `external.remoteSchema` on this face: the base registration warns and ignores it on SQLite, on both faces alike, so there is nothing to read. - A `remoteName` containing a dot: knex splits it into a schema qualifier on the local face. This face quotes it as one name. Nobody is known to author that shape, and no measurement was taken. - The DDL doors (`syncSchema`, `syncSchemasBatch`, `dropTable`) still address the object name on both faces. The engine routes federated objects to `registerExternalObject` and never to DDL. - `RemoteTransport` is exported from the package root. Its 14 data methods gained an optional trailing `table` parameter, and nothing is removed or narrowed. That is the signature widening the `Clause-②: yes (widening)` line and the `minor` changeset declare. A standalone transport that omits it behaves as before. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 24b7085 commit dbddf02

4 files changed

Lines changed: 737 additions & 54 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
`TursoDriver` in **remote** mode reads and writes a federated object's remote table, `external.remoteName`, the way the local and embedded-replica modes always have (#20107).
6+
7+
Clause-②: yes (widening)
8+
9+
**What was wrong.** `registerExternalObject` records a federated object's remote table (ADR-0015), and the local face reads that record for every statement. The remote face inherited the registration but never read it. Every remote data door handed `RemoteTransport` the object name, and the transport used it as the table. So an object `ext_customer` bound to the remote table `customers` was queried as a table named `ext_customer`. Measured on a remote face over a libSQL `file:` client, with a local driver over the same file answering every door from the mapped table:
10+
11+
- `find`, `findOne`, `count` and every write door failed with a bare `LibsqlError` (`SQLITE_ERROR: no such table: ext_customer`). It carried no `status`, so REST served it as an unclassified 500.
12+
- `aggregate` answered `[]`, because the transport reads "no such table" as "no rows".
13+
- `distinct` answered `DATABASE_ERROR` / 500.
14+
15+
**What changes, on the remote face only:**
16+
17+
- Every data door (`find`, `findOne`, `count`, `aggregate`, `distinct`, `create`, `update`, `upsert`, `delete`, `bulkCreate`, `bulkUpdate`, `bulkDelete`, `updateMany`, `deleteMany`) compiles against the table the registration recorded. That is `external.remoteName` for a federated object, and the object's own name otherwise. It is read from the same `SqlDriver` registry the local face reads, and not copied. Managed objects send the same statements as before.
18+
- A remote table name is quoted as one SQL name, with any embedded quote doubled, so a name the local face can read (`order-lines`) can be read here too.
19+
- `find`, `findOne` and `count` now end in the local face's read-exit envelope. A statement the backend refuses, for example on a remote table that really is absent, answers `DATABASE_ERROR` / 500. The libSQL error rides under a non-enumerable `cause`, the dialect text goes to the server log, and the targeted table is declared for `isMissingTableError`. Before, the bare `LibsqlError` reached the caller. The write doors keep the local face's behaviour, where a write fault is classified at the REST boundary from its message.
20+
- A federated object whose `external.columnMap` **renames** a column is refused with `NOT_IMPLEMENTED` / 501 on every remote data door, before any statement is sent. The remote transport addresses columns by field name and does not translate the map. With the table resolved, a filter on a renamed field would read a column the table does not have, and the transport answers that with an empty list rather than the matching rows. Before this change, an object whose `remoteName` differs from its name failed with `no such table` on every door but `aggregate`, which answered `[]`. A binding with no `remoteName`, or with `remoteName` equal to the object name, plus a renaming map is refused too: before, its unfiltered reads and its id-keyed writes answered, while its filtered reads were already silently wrong. A map whose entries rename nothing is served. **If this refusal fires for you:** use the local or embedded-replica transport for that object, or name its fields after the remote columns and drop the renaming entries.
21+
- `RemoteTransport` (exported from the package root) gains an optional trailing `table` argument on its 14 data methods; omitted, it defaults to the object, which is what a transport used on its own always did.

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

Lines changed: 96 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1370,11 +1370,28 @@ export class RemoteTransport {
13701370
// ===================================
13711371
// CRUD Operations
13721372
// ===================================
1373+
//
1374+
// [#20107] Every data method below takes the OBJECT and the TABLE apart.
1375+
//
1376+
// `object` is the API name the caller asked for. The driver's rules are keyed
1377+
// by it (`filterColumnSql`, `nonTextColumn`), and every refusal names it,
1378+
// because it is the caller's own word. `table` is the physical table the
1379+
// statement is compiled against, which `TursoDriver` resolves the way
1380+
// `SqlDriver.getBuilder` resolves it for the local face: a federated
1381+
// object's `external.remoteName` (ADR-0015), otherwise the object's own name.
1382+
// Omitted, the object is its own table, which is true of every managed
1383+
// object and of every call a transport driven standalone receives.
1384+
//
1385+
// The two used to be one argument, so on this face a federated object was
1386+
// read from, and written to, a table named after the object. The remote
1387+
// table it declared was never reached. Every door but `aggregate` failed with
1388+
// the backend's own `no such table`, and `aggregate` answered an empty list,
1389+
// while the local face of the same driver answered from the mapped table.
13731390

1374-
async find(object: string, query: any): Promise<Record<string, unknown>[]> {
1391+
async find(object: string, query: any, table: string = object): Promise<Record<string, unknown>[]> {
13751392
await this.ensureConnected();
13761393

1377-
const { sql, args } = this.buildSelectSQL(object, query);
1394+
const { sql, args } = this.buildSelectSQL(object, query, table);
13781395

13791396
try {
13801397
const result = await this.client!.execute({ sql, args });
@@ -1397,7 +1414,7 @@ export class RemoteTransport {
13971414
// remote Turso path overrides find(), so it needs its own copy.
13981415
if (query?.fields && Array.isArray(query.fields) && query.fields.length > 0) {
13991416
try {
1400-
const fallback = this.buildSelectSQL(object, { ...query, fields: undefined });
1417+
const fallback = this.buildSelectSQL(object, { ...query, fields: undefined }, table);
14011418
const result = await this.client!.execute({ sql: fallback.sql, args: fallback.args });
14021419
return this.mapRows(result);
14031420
} catch {
@@ -1421,9 +1438,9 @@ export class RemoteTransport {
14211438
* missing. Neither the contract nor any caller outside these tests spelled it
14221439
* that way, so the branch is gone here too: one driver, one spelling.
14231440
*/
1424-
async findOne(object: string, query: any): Promise<Record<string, unknown> | null> {
1441+
async findOne(object: string, query: any, table: string = object): Promise<Record<string, unknown> | null> {
14251442
if (query && typeof query === 'object') {
1426-
const results = await this.find(object, { ...query, limit: 1 });
1443+
const results = await this.find(object, { ...query, limit: 1 }, table);
14271444
return results[0] || null;
14281445
}
14291446

@@ -1440,7 +1457,7 @@ export class RemoteTransport {
14401457
* `SqlDriver.aggregate` and `TursoDriver.aggregate` took, because all three are
14411458
* one door and a caller may not be told three different things about it.
14421459
*/
1443-
async aggregate(object: string, query: DriverQuery): Promise<Record<string, unknown>[]> {
1460+
async aggregate(object: string, query: DriverQuery, table: string = object): Promise<Record<string, unknown>[]> {
14441461
await this.ensureConnected();
14451462
this.assertSafeIdentifier(object);
14461463

@@ -1592,7 +1609,7 @@ export class RemoteTransport {
15921609

15931610
if (selectParts.length === 0) selectParts.push('*');
15941611

1595-
let sql = `SELECT ${selectParts.join(', ')} FROM "${object}"`;
1612+
let sql = `SELECT ${selectParts.join(', ')} FROM ${this.tableSql(table)}`;
15961613
const args: any[] = [];
15971614

15981615
const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query?.where);
@@ -1656,7 +1673,7 @@ export class RemoteTransport {
16561673
return rows;
16571674
}
16581675

1659-
async create(object: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
1676+
async create(object: string, data: Record<string, unknown>, table: string = object): Promise<Record<string, unknown>> {
16601677
await this.ensureConnected();
16611678

16621679
const { _id, ...rest } = data as any;
@@ -1672,12 +1689,12 @@ export class RemoteTransport {
16721689
const placeholders = columns.map(() => '?').join(', ');
16731690
const values = columns.map((col) => this.serializeValue(toInsert[col]));
16741691

1675-
const sql = `INSERT INTO "${object}" (${columns.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;
1692+
const sql = `INSERT INTO ${this.tableSql(table)} (${columns.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;
16761693
await this.client!.execute({ sql, args: values });
16771694

16781695
// Fetch the inserted row to return complete record
16791696
const result = await this.client!.execute({
1680-
sql: `SELECT * FROM "${object}" WHERE "id" = ?`,
1697+
sql: `SELECT * FROM ${this.tableSql(table)} WHERE "id" = ?`,
16811698
args: [toInsert.id],
16821699
});
16831700
const rows = this.mapRows(result);
@@ -1715,26 +1732,36 @@ export class RemoteTransport {
17151732
* ⚠️ `upsert()` is deliberately untouched: an upsert never answers
17161733
* "not found".
17171734
*/
1718-
async update(object: string, id: string | number, data: Record<string, unknown>): Promise<Record<string, unknown> | null> {
1735+
async update(
1736+
object: string,
1737+
id: string | number,
1738+
data: Record<string, unknown>,
1739+
table: string = object,
1740+
): Promise<Record<string, unknown> | null> {
17191741
await this.ensureConnected();
17201742

17211743
const columns = Object.keys(data);
17221744
const setClauses = columns.map((col) => `"${col}" = ?`).join(', ');
17231745
const values = columns.map((col) => this.serializeValue(data[col]));
17241746

1725-
const sql = `UPDATE "${object}" SET ${setClauses} WHERE "id" = ?`;
1747+
const sql = `UPDATE ${this.tableSql(table)} SET ${setClauses} WHERE "id" = ?`;
17261748
await this.client!.execute({ sql, args: [...values, id] });
17271749

17281750
// Fetch updated row
17291751
const result = await this.client!.execute({
1730-
sql: `SELECT * FROM "${object}" WHERE "id" = ?`,
1752+
sql: `SELECT * FROM ${this.tableSql(table)} WHERE "id" = ?`,
17311753
args: [id],
17321754
});
17331755
const rows = this.mapRows(result);
17341756
return rows[0] ?? null;
17351757
}
17361758

1737-
async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[]): Promise<Record<string, unknown>> {
1759+
async upsert(
1760+
object: string,
1761+
data: Record<string, unknown>,
1762+
conflictKeys?: string[],
1763+
table: string = object,
1764+
): Promise<Record<string, unknown>> {
17381765
await this.ensureConnected();
17391766

17401767
const { _id, ...rest } = data as any;
@@ -1755,7 +1782,7 @@ export class RemoteTransport {
17551782
const updateCols = columns.filter((c) => !mergeKeys.includes(c));
17561783
const updateClauses = updateCols.map((col) => `"${col}" = excluded."${col}"`).join(', ');
17571784

1758-
let sql = `INSERT INTO "${object}" (${columns.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;
1785+
let sql = `INSERT INTO ${this.tableSql(table)} (${columns.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;
17591786
sql += ` ON CONFLICT(${mergeKeys.map((k) => `"${k}"`).join(', ')})`;
17601787
if (updateClauses) {
17611788
sql += ` DO UPDATE SET ${updateClauses}`;
@@ -1777,27 +1804,27 @@ export class RemoteTransport {
17771804

17781805
// Fetch the result row
17791806
const result = await this.client!.execute({
1780-
sql: `SELECT * FROM "${object}" WHERE "id" = ?`,
1807+
sql: `SELECT * FROM ${this.tableSql(table)} WHERE "id" = ?`,
17811808
args: [toUpsert.id],
17821809
});
17831810
const rows = this.mapRows(result);
17841811
return rows[0] || toUpsert;
17851812
}
17861813

1787-
async delete(object: string, id: string | number): Promise<boolean> {
1814+
async delete(object: string, id: string | number, table: string = object): Promise<boolean> {
17881815
await this.ensureConnected();
17891816
const result = await this.client!.execute({
1790-
sql: `DELETE FROM "${object}" WHERE "id" = ?`,
1817+
sql: `DELETE FROM ${this.tableSql(table)} WHERE "id" = ?`,
17911818
args: [id],
17921819
});
17931820
return result.rowsAffected > 0;
17941821
}
17951822

1796-
async count(object: string, query?: any): Promise<number> {
1823+
async count(object: string, query?: any, table: string = object): Promise<number> {
17971824
await this.ensureConnected();
17981825

17991826
const { whereClauses, args } = this.buildWhereSQL(object, query?.where);
1800-
let sql = `SELECT COUNT(*) as count FROM "${object}"`;
1827+
let sql = `SELECT COUNT(*) as count FROM ${this.tableSql(table)}`;
18011828
if (whereClauses) sql += ` WHERE ${whereClauses}`;
18021829

18031830
const result = await this.client!.execute({ sql, args });
@@ -1828,11 +1855,16 @@ export class RemoteTransport {
18281855
* The column is a REFERENCE, so it keeps the identifier gate, as
18291856
* `aggregate()`'s group-by field does.
18301857
*/
1831-
compileDistinct(object: string, field: string, where: unknown): { sql: string; args: unknown[] } {
1858+
compileDistinct(
1859+
object: string,
1860+
field: string,
1861+
where: unknown,
1862+
table: string = object,
1863+
): { sql: string; args: unknown[] } {
18321864
this.assertSafeIdentifier(object);
18331865
this.assertSafeIdentifier(field);
18341866
const { whereClauses, args } = this.buildWhereSQL(object, where);
1835-
let sql = `SELECT DISTINCT "${field}" FROM "${object}"`;
1867+
let sql = `SELECT DISTINCT "${field}" FROM ${this.tableSql(table)}`;
18361868
if (whereClauses) sql += ` WHERE ${whereClauses}`;
18371869
return { sql, args };
18381870
}
@@ -1841,55 +1873,63 @@ export class RemoteTransport {
18411873
// Bulk Operations
18421874
// ===================================
18431875

1844-
async bulkCreate(object: string, dataArray: Record<string, unknown>[]): Promise<Record<string, unknown>[]> {
1876+
async bulkCreate(
1877+
object: string,
1878+
dataArray: Record<string, unknown>[],
1879+
table: string = object,
1880+
): Promise<Record<string, unknown>[]> {
18451881
const results: Record<string, unknown>[] = [];
18461882
for (const data of dataArray) {
1847-
const created = await this.create(object, data);
1883+
const created = await this.create(object, data, table);
18481884
results.push(created);
18491885
}
18501886
return results;
18511887
}
18521888

1853-
async bulkUpdate(object: string, updates: Array<{ id: string | number; data: Record<string, unknown> }>): Promise<Record<string, unknown>[]> {
1889+
async bulkUpdate(
1890+
object: string,
1891+
updates: Array<{ id: string | number; data: Record<string, unknown> }>,
1892+
table: string = object,
1893+
): Promise<Record<string, unknown>[]> {
18541894
const results: Record<string, unknown>[] = [];
18551895
for (const { id, data } of updates) {
1856-
const updated = await this.update(object, id, data);
1896+
const updated = await this.update(object, id, data, table);
18571897
if (updated) results.push(updated);
18581898
}
18591899
return results;
18601900
}
18611901

1862-
async bulkDelete(object: string, ids: Array<string | number>): Promise<void> {
1902+
async bulkDelete(object: string, ids: Array<string | number>, table: string = object): Promise<void> {
18631903
await this.ensureConnected();
18641904
if (ids.length === 0) return;
18651905

18661906
const placeholders = ids.map(() => '?').join(', ');
18671907
await this.client!.execute({
1868-
sql: `DELETE FROM "${object}" WHERE "id" IN (${placeholders})`,
1908+
sql: `DELETE FROM ${this.tableSql(table)} WHERE "id" IN (${placeholders})`,
18691909
args: ids as any[],
18701910
});
18711911
}
18721912

1873-
async updateMany(object: string, query: any, data: Record<string, unknown>): Promise<number> {
1913+
async updateMany(object: string, query: any, data: Record<string, unknown>, table: string = object): Promise<number> {
18741914
await this.ensureConnected();
18751915

18761916
const columns = Object.keys(data);
18771917
const setClauses = columns.map((col) => `"${col}" = ?`).join(', ');
18781918
const setValues = columns.map((col) => this.serializeValue(data[col]));
18791919

18801920
const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query?.where);
1881-
let sql = `UPDATE "${object}" SET ${setClauses}`;
1921+
let sql = `UPDATE ${this.tableSql(table)} SET ${setClauses}`;
18821922
if (whereClauses) sql += ` WHERE ${whereClauses}`;
18831923

18841924
const result = await this.client!.execute({ sql, args: [...setValues, ...whereArgs] });
18851925
return result.rowsAffected;
18861926
}
18871927

1888-
async deleteMany(object: string, query: any): Promise<number> {
1928+
async deleteMany(object: string, query: any, table: string = object): Promise<number> {
18891929
await this.ensureConnected();
18901930

18911931
const { whereClauses, args } = this.buildWhereSQL(object, query?.where);
1892-
let sql = `DELETE FROM "${object}"`;
1932+
let sql = `DELETE FROM ${this.tableSql(table)}`;
18931933
if (whereClauses) sql += ` WHERE ${whereClauses}`;
18941934

18951935
const result = await this.client!.execute({ sql, args });
@@ -2228,6 +2268,28 @@ export class RemoteTransport {
22282268
return `"${String(alias).replace(/"/g, '""')}"`;
22292269
}
22302270

2271+
/**
2272+
* [#20107] Spell the table a data statement targets — see the note above
2273+
* {@link find} for where `table` comes from.
2274+
*
2275+
* A remote table name is ONE name, so it takes the treatment
2276+
* {@link aliasIdentifierSql} gives an output name: quoted, with any embedded
2277+
* quote doubled. Its qualifier is a separate key (`external.remoteSchema`),
2278+
* which SQLite has no namespace for. It is NOT held to
2279+
* {@link assertSafeIdentifier}: `external.remoteName` is authored as any
2280+
* string, since it names a table this platform does not own, and the local
2281+
* face reads such a table through knex's own identifier quoting. Refusing it
2282+
* here would make a name the local face reads (`order-lines`) unreadable on
2283+
* this one. Doubling the quote keeps it a name: nothing inside it can close
2284+
* the quoting and continue as grammar.
2285+
*
2286+
* For a managed object the table is the object's own snake_case name, and
2287+
* the statement is byte-for-byte the one this transport emitted before.
2288+
*/
2289+
private tableSql(table: string): string {
2290+
return this.aliasIdentifierSql(table);
2291+
}
2292+
22312293
/**
22322294
* Build a CREATE TABLE SQL string for the given object definition.
22332295
* Shared by syncSchema() and syncSchemasBatch() to avoid duplication.
@@ -2588,12 +2650,12 @@ export class RemoteTransport {
25882650
* `orderBy` is likewise an ANSWER — #4363's carve-out for an unpaged
25892651
* unordered read — and emitting no ORDER BY for it is correct.
25902652
*/
2591-
private buildSelectSQL(object: string, query: any): { sql: string; args: any[] } {
2653+
private buildSelectSQL(object: string, query: any, table: string): { sql: string; args: any[] } {
25922654
const fields = query.fields && Array.isArray(query.fields) && query.fields.length > 0
25932655
? query.fields.map((f: string) => `"${this.mapSortField(f)}"`).join(', ')
25942656
: '*';
25952657

2596-
let sql = `SELECT ${fields} FROM "${object}"`;
2658+
let sql = `SELECT ${fields} FROM ${this.tableSql(table)}`;
25972659
const allArgs: any[] = [];
25982660

25992661
// WHERE

0 commit comments

Comments
 (0)