Repository navigation
fix(service-datasource)!: remote-tables refuses a schema filter the driver's introspection cannot apply, instead of a wrong 200 - #24706
Conversation
…trospection cannot apply ?schema= and external.allowedSchemas were skipped for every table whose name carries no schema, which is every table the in-tree SQL driver introspects, so a named schema answered 200 with the wrong tables and an allow-list let every table through. A filter in force now narrows or refuses with 400 EXTERNAL_DATASOURCE_ERROR; a producer that qualifies its names is narrowed as before. Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
…arrowing Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
…mote-tables-schema-filter Claude-Session: https://claude.ai/code/session_01LR1cD9JqgkeqY7k9rrZH8s Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1b64b5283bb5467f6e7dfb9e788fded1d30d948f && git checkout 1b64b5283bb5467f6e7dfb9e788fded1d30d948f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d1173825b709dfd8ad4ef383757a5df0a2e82d2a 1181698060abfe3cee3fbb48e164fc0429ba34e2 && git checkout -B drift-repro d1173825b709dfd8ad4ef383757a5df0a2e82d2a && git merge --no-ff 1181698060abfe3cee3fbb48e164fc0429ba34e2
node scripts/docs-audit/affected-docs.mjs --json d1173825b709dfd8ad4ef383757a5df0a2e82d2a |
Contract reviewServed-tier: Read-only shape held: the net diff against Check-runs on this head 2026-10-20T00:59Z: 28 concluded ① Derived judgmentsEvery accept-set and public-surface change the diff implies, read in the cited code:
No wrong derived judgment found. No governed surface in the file list; head repo equals base repo; +389/−4. ② Semver levelThe changeset ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · #24664 ·
Generated by Claude Code |
Fixes #24664
Clause-②: no (narrowing)
What changes
ExternalDatasourceService.listRemoteTablesservesGET /api/v1/datasources/:name/remote-tablesand its twinGET /api/v1/datasources/:name/external/tables. It learns a table's schema only from a schema-qualified name (sales.orders):IntrospectedTabledeclares no schema of its own. Both filters,?schema=and the datasource'sexternal.allowedSchemas, skipped every table whose name carries no schema. The in-tree SQL driver reports bare names on every dialect, so both filters did nothing.Now a filter in force is applied to every table, or the call refuses:
?schema=orexternal.allowedSchemasis in force and the introspection holds one, the call throws400 EXTERNAL_DATASOURCE_ERROR, the code already registered for a refusal from this service. Both route spellings already answer a throw from this method with that envelope, so no route code changes. The message names the datasource, the filter and one unattributed table, and gives the fix.[]: an empty allow-list, or a?schema=the allow-list leaves out.Files:
external-datasource-service.ts(the listing, plus two module-private helpers), one comment paragraph inadmin-routes.tson the route, one new test file, and one changeset. No driver file and nothing underpackages/specis touched.Premise, measured (A1)
I measured the real
SqlDriver.introspectSchema()from thedistbuilt at baseeadea5720against a private PostgreSQL 16 cluster. The database heldpublic.customers,sales.ordersandsales.customers. I then fed the result to the service asplugin.tsdoes, unmodified.?schema=sales, base?schema=sales, this branchcustomers(thepublicone)[customers]EXTERNAL_DATASOURCE_ERRORsearchPath: ['sales'](config.schema: 'sales')orders,customerssearch_path = public, salescustomers(public),orderswith 0 columnscustomers,ordersEvery table record's own keys were
name,columns,foreignKeysandprimaryKeys. No table carried a schema in its name or anywhere else.allowedSchemas: ['public']with no?schema=answered every table at base and refuses on this branch.allowedSchemas: ['public']with?schema=salesanswers[]on this branch.Card premise: confirmed. The roll-up's mechanism, the
tableSchema &&short-circuit on both filters, is exactly the:383/:385pair.Route choice: (b), refuse where the schema is unknown, for every in-tree dialect
Route (a), narrowing for real on Postgres, was measured out. Two reasons:
IntrospectedTablehas no schema field, and adding one is apackages/specchange, which is out of scope here. The only carrier is a qualifiedname/key. The driver's introspection also feeds managed-datasource consumers (schema drift, the objectql util, the datasource health count) that key tables by bare object name. Qualifying there is not a cheap or local change.sales. The Postgres listing is scoped tocurrent_schemas(false), and every per-table read resolves the bare name through the search path. On a default deploymentsalesis never introspected. Narrowing to it would answer an empty 200, which is still silently wrong. Reaching it needs a schema-scoped introspection capability, a new driver contract member. That is its ownpackages/specdecision.Four axes:
?schema=areos datasource list-tables --schemaand@objectstack/client'sdatasources.external.listTables(name, { schema }). The console's schema browser (objectuiSchemaBrowser,DatasourceResourcePage) never sends it. No example app declaresexternal.allowedSchemas, and its spec row islivein the liveness ledger. Nobody measured pulls real Postgres narrowing on in-tree drivers. The need the card states is correctness: narrow or refuse, never a wrong 200. (b) meets it.main, MySQL's database, Postgres's search path), which would be a second source of truth about the driver. The qualified path stays, so a future schema-aware introspection narrows with no change here.allowedSchemas, or calls?schema=, on an in-tree-driver datasource is told the filter is unenforceable. They no longer get a believable unfiltered list. What is declared is now enforced or refused.SQLite was decided on the same axes. A
?schema=mainwould happen to be answerable by a service that knew SQLite's schema name. Nobody sends it except the QA checklist step. Teaching the service that fact is the dialect table the third axis rejects, so SQLite refuses like the others.Tests
New
src/__tests__/remote-tables-schema-attribution.test.ts, 12 cases:SqlDriverintrospection: an unfiltered list (preservation),?schema=refused, andallowedSchemasrefused.salespath:?schema=salesrefused (the card's row), refused even when every table is in fact insales,allowedSchemasrefused, an unsatisfiable filter answers[], and an unfiltered list.?schema=, by the allow-list and by both. One bare name among qualified ones refuses.GET /api/v1/datasources/pg/remote-tables?schema=salesover the Postgres shape answers400witherror.code: 'EXTERNAL_DATASOURCE_ERROR'through the realHonoHttpServerand the entitled-caller fixture. Without?schema=it answers200with the table.Refusal pins assert
codeandstatus, plus the named subject (the schema, orexternal.allowedSchemas, or the bare table). No pin asserts the prose.Ablation (one, on the tested behaviour). I ran
node scripts/ablation-replace.mjsin wrap mode, inside the lock. It replacedunattributed.push(table.name);with the old pass-throughtables.push(...): anchor 1 to 0, blobec53e2afb7e5toe0b6c0c14fb9. Direction as predicted: 7 failed / 5 passed. The seven are every refusal pin plus the route pin. The five that stayed green are the unfiltered lists, the[]case and the qualified controls. The restore leg was proven: blob back toec53e2afb7e5, equal to HEAD,git diff HEADempty. The service is imported relatively in the test, so the subject resolves tosrc/and no build sits between the mutation and the run.Local verification
All readings are on head
118169806, the final commit, which is0f038913fmerged withorigin/main5018fc303. No conflict. The closure was rebuilt after the merge, and@objectstack/spec check:generatedis green.pnpm --filter @objectstack/service-datasource test: 46 files, 850 passed.pnpm --filter @objectstack/service-datasource typecheck: exit 0.tsc --listFilescounts the new test file once.pnpm --filter @objectstack/rest exec vitest run src/remote-tables-twin.equivalence.test.ts src/external-datasource-envelope.conformance.test.ts: 26 passed. The twin pin aliasesservice-datasourcetosrc, so it reads this change.node scripts/pm/dispatch-gates.mjs --commandsderived 68 commands, and all 68 exited 0. Reconciliation:--ranreported "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN".check-changeset-no-majorlevel axis, driven offline with this body as--event: "this PR declares clause-②no (narrowing)" and no package moved is gradedpatch. The changeset isminor.check-adr-0087-registrationacceptsnot-required (no-migration-prescription).Lint & Repo Gates,TypeScript Type Check,Test Core,Build Core) are not re-run here. They run on this PR.Acceptance notes
integration-system.external-schema-introspectionchanges reading. Step 2's?schema=mainon the stock SQLiteshowcase_externalnow answers400 EXTERNAL_DATASOURCE_ERROR, where it used to answer the full set. The item's firstknownGapsentry describes the old no-op. The next checklist pass should restate that step's expectation as "refused". This PR does not touchdocs/qa.allowedSchemasrow inpackages/spec/liveness/datasource.jsonstill says the filter "only bites on tables whose schema can be attributed". Its anchors (listRemoteTables, theconst allowed = ds?.external?.allowedSchemasread) are unchanged and still resolve. Only the prose is now stale. Carrier: none, so this is noted here and not filed.IntrospectedTableor a schema-takingintrospectSchema. That is apackages/speccontract decision with no measured consumer today. It is not filed.search_path = public, sales),SqlDriver.introspectSchema()gets two things wrong. It reportssales.orderswith zero columns: per-table reads resolve throughknex(name).columnInfo(), which reads only the session's current schema. It also silently dropssales.customers, because tables are keyed by bare name and thepublicone wins. The listing shows this asorderswithcolumnCount: 0.allowedSchemasreaches only the listing.generateObjectDraft,importObjectandrefreshCatalogdo not consult it. ADR-0015 §6.1 scopes the filter tolistRemoteTables, so this is noted and not filed.Generated by Claude Code