Repository navigation
Commit dbddf02
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
- .changeset
- packages/drivers/driver-turso/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1370 | 1370 | | |
1371 | 1371 | | |
1372 | 1372 | | |
| 1373 | + | |
| 1374 | + | |
| 1375 | + | |
| 1376 | + | |
| 1377 | + | |
| 1378 | + | |
| 1379 | + | |
| 1380 | + | |
| 1381 | + | |
| 1382 | + | |
| 1383 | + | |
| 1384 | + | |
| 1385 | + | |
| 1386 | + | |
| 1387 | + | |
| 1388 | + | |
| 1389 | + | |
1373 | 1390 | | |
1374 | | - | |
| 1391 | + | |
1375 | 1392 | | |
1376 | 1393 | | |
1377 | | - | |
| 1394 | + | |
1378 | 1395 | | |
1379 | 1396 | | |
1380 | 1397 | | |
| |||
1397 | 1414 | | |
1398 | 1415 | | |
1399 | 1416 | | |
1400 | | - | |
| 1417 | + | |
1401 | 1418 | | |
1402 | 1419 | | |
1403 | 1420 | | |
| |||
1421 | 1438 | | |
1422 | 1439 | | |
1423 | 1440 | | |
1424 | | - | |
| 1441 | + | |
1425 | 1442 | | |
1426 | | - | |
| 1443 | + | |
1427 | 1444 | | |
1428 | 1445 | | |
1429 | 1446 | | |
| |||
1440 | 1457 | | |
1441 | 1458 | | |
1442 | 1459 | | |
1443 | | - | |
| 1460 | + | |
1444 | 1461 | | |
1445 | 1462 | | |
1446 | 1463 | | |
| |||
1592 | 1609 | | |
1593 | 1610 | | |
1594 | 1611 | | |
1595 | | - | |
| 1612 | + | |
1596 | 1613 | | |
1597 | 1614 | | |
1598 | 1615 | | |
| |||
1656 | 1673 | | |
1657 | 1674 | | |
1658 | 1675 | | |
1659 | | - | |
| 1676 | + | |
1660 | 1677 | | |
1661 | 1678 | | |
1662 | 1679 | | |
| |||
1672 | 1689 | | |
1673 | 1690 | | |
1674 | 1691 | | |
1675 | | - | |
| 1692 | + | |
1676 | 1693 | | |
1677 | 1694 | | |
1678 | 1695 | | |
1679 | 1696 | | |
1680 | | - | |
| 1697 | + | |
1681 | 1698 | | |
1682 | 1699 | | |
1683 | 1700 | | |
| |||
1715 | 1732 | | |
1716 | 1733 | | |
1717 | 1734 | | |
1718 | | - | |
| 1735 | + | |
| 1736 | + | |
| 1737 | + | |
| 1738 | + | |
| 1739 | + | |
| 1740 | + | |
1719 | 1741 | | |
1720 | 1742 | | |
1721 | 1743 | | |
1722 | 1744 | | |
1723 | 1745 | | |
1724 | 1746 | | |
1725 | | - | |
| 1747 | + | |
1726 | 1748 | | |
1727 | 1749 | | |
1728 | 1750 | | |
1729 | 1751 | | |
1730 | | - | |
| 1752 | + | |
1731 | 1753 | | |
1732 | 1754 | | |
1733 | 1755 | | |
1734 | 1756 | | |
1735 | 1757 | | |
1736 | 1758 | | |
1737 | | - | |
| 1759 | + | |
| 1760 | + | |
| 1761 | + | |
| 1762 | + | |
| 1763 | + | |
| 1764 | + | |
1738 | 1765 | | |
1739 | 1766 | | |
1740 | 1767 | | |
| |||
1755 | 1782 | | |
1756 | 1783 | | |
1757 | 1784 | | |
1758 | | - | |
| 1785 | + | |
1759 | 1786 | | |
1760 | 1787 | | |
1761 | 1788 | | |
| |||
1777 | 1804 | | |
1778 | 1805 | | |
1779 | 1806 | | |
1780 | | - | |
| 1807 | + | |
1781 | 1808 | | |
1782 | 1809 | | |
1783 | 1810 | | |
1784 | 1811 | | |
1785 | 1812 | | |
1786 | 1813 | | |
1787 | | - | |
| 1814 | + | |
1788 | 1815 | | |
1789 | 1816 | | |
1790 | | - | |
| 1817 | + | |
1791 | 1818 | | |
1792 | 1819 | | |
1793 | 1820 | | |
1794 | 1821 | | |
1795 | 1822 | | |
1796 | | - | |
| 1823 | + | |
1797 | 1824 | | |
1798 | 1825 | | |
1799 | 1826 | | |
1800 | | - | |
| 1827 | + | |
1801 | 1828 | | |
1802 | 1829 | | |
1803 | 1830 | | |
| |||
1828 | 1855 | | |
1829 | 1856 | | |
1830 | 1857 | | |
1831 | | - | |
| 1858 | + | |
| 1859 | + | |
| 1860 | + | |
| 1861 | + | |
| 1862 | + | |
| 1863 | + | |
1832 | 1864 | | |
1833 | 1865 | | |
1834 | 1866 | | |
1835 | | - | |
| 1867 | + | |
1836 | 1868 | | |
1837 | 1869 | | |
1838 | 1870 | | |
| |||
1841 | 1873 | | |
1842 | 1874 | | |
1843 | 1875 | | |
1844 | | - | |
| 1876 | + | |
| 1877 | + | |
| 1878 | + | |
| 1879 | + | |
| 1880 | + | |
1845 | 1881 | | |
1846 | 1882 | | |
1847 | | - | |
| 1883 | + | |
1848 | 1884 | | |
1849 | 1885 | | |
1850 | 1886 | | |
1851 | 1887 | | |
1852 | 1888 | | |
1853 | | - | |
| 1889 | + | |
| 1890 | + | |
| 1891 | + | |
| 1892 | + | |
| 1893 | + | |
1854 | 1894 | | |
1855 | 1895 | | |
1856 | | - | |
| 1896 | + | |
1857 | 1897 | | |
1858 | 1898 | | |
1859 | 1899 | | |
1860 | 1900 | | |
1861 | 1901 | | |
1862 | | - | |
| 1902 | + | |
1863 | 1903 | | |
1864 | 1904 | | |
1865 | 1905 | | |
1866 | 1906 | | |
1867 | 1907 | | |
1868 | | - | |
| 1908 | + | |
1869 | 1909 | | |
1870 | 1910 | | |
1871 | 1911 | | |
1872 | 1912 | | |
1873 | | - | |
| 1913 | + | |
1874 | 1914 | | |
1875 | 1915 | | |
1876 | 1916 | | |
1877 | 1917 | | |
1878 | 1918 | | |
1879 | 1919 | | |
1880 | 1920 | | |
1881 | | - | |
| 1921 | + | |
1882 | 1922 | | |
1883 | 1923 | | |
1884 | 1924 | | |
1885 | 1925 | | |
1886 | 1926 | | |
1887 | 1927 | | |
1888 | | - | |
| 1928 | + | |
1889 | 1929 | | |
1890 | 1930 | | |
1891 | 1931 | | |
1892 | | - | |
| 1932 | + | |
1893 | 1933 | | |
1894 | 1934 | | |
1895 | 1935 | | |
| |||
2228 | 2268 | | |
2229 | 2269 | | |
2230 | 2270 | | |
| 2271 | + | |
| 2272 | + | |
| 2273 | + | |
| 2274 | + | |
| 2275 | + | |
| 2276 | + | |
| 2277 | + | |
| 2278 | + | |
| 2279 | + | |
| 2280 | + | |
| 2281 | + | |
| 2282 | + | |
| 2283 | + | |
| 2284 | + | |
| 2285 | + | |
| 2286 | + | |
| 2287 | + | |
| 2288 | + | |
| 2289 | + | |
| 2290 | + | |
| 2291 | + | |
| 2292 | + | |
2231 | 2293 | | |
2232 | 2294 | | |
2233 | 2295 | | |
| |||
2588 | 2650 | | |
2589 | 2651 | | |
2590 | 2652 | | |
2591 | | - | |
| 2653 | + | |
2592 | 2654 | | |
2593 | 2655 | | |
2594 | 2656 | | |
2595 | 2657 | | |
2596 | | - | |
| 2658 | + | |
2597 | 2659 | | |
2598 | 2660 | | |
2599 | 2661 | | |
| |||
0 commit comments