diff --git a/database-compose.yml b/database-compose.yml index 63cdd197..95ff424a 100644 --- a/database-compose.yml +++ b/database-compose.yml @@ -648,9 +648,21 @@ services: # # THERE IS NO ICEBERG CATALOG HERE, and that is a deliberate limit rather than an # oversight. A materialized view is the one object kind Trino cannot create without one, - # and on 476 only a HIVE-METASTORE-backed Iceberg catalog will create one at all: the - # JDBC and REST catalog types both answer `createMaterializedView is not supported`. So - # an Iceberg catalog here would mean a metastore service, a warehouse volume and a + # and on 476 only a HIVE-METASTORE-backed Iceberg catalog will create one at all. + # + # THE CHEAP ROUTE WAS PROBED FOR #789 AND REFUSED, so the next person does not have to + # spend the same afternoon finding out. The hope was that Trino's Iceberg connector would + # take `iceberg.catalog.type=jdbc` against the `postgres` service above, giving a + # materialized-view fixture for the price of one properties file and a warehouse volume + # with NO new image in this file. Measured on 2026-09-13, the JDBC catalog was brought + # all the way up and WORKS - `CREATE SCHEMA`, `CREATE TABLE` and a two-row `INSERT` all + # succeeded - and `CREATE MATERIALIZED VIEW` still answered + # `createMaterializedView is not supported for Iceberg JDBC catalogs`. Two traps found on + # the way, both recorded in docs/providers/trino.md: Trino 476 never creates the JDBC + # catalog's own `iceberg_tables` table, and a `file://` warehouse needs + # `fs.hadoop.enabled=true` rather than the native local filesystem. + # + # So an Iceberg catalog here would mean a metastore service, a warehouse volume and a # second image in this file for one object kind. The object surface therefore answers # `materialized_view: 0` everywhere on this cluster, which is the TRUE answer for it, and # docs/providers/trino.md carries the catalog properties and the exact statements that diff --git a/docker/clickhouse-init/01-object-fixture.sql b/docker/clickhouse-init/01-object-fixture.sql index 5b199a99..f9c280db 100644 --- a/docker/clickhouse-init/01-object-fixture.sql +++ b/docker/clickhouse-init/01-object-fixture.sql @@ -144,3 +144,32 @@ ENGINE = Dictionary(demo.dict_customers); -- A user-defined function. CREATE FUNCTION takes no database qualifier, because a -- ClickHouse UDF is SERVER-GLOBAL: system.functions has no database column at all. CREATE FUNCTION order_total_with_tax AS (total) -> total * 1.2; + +-- Two ADVERSARIAL NAMES for the source read's escaper (#789). +-- +-- Measured on 26.7.1.1315 (#789 probe 11): a backslash inside a QUOTED IDENTIFIER is +-- processed as an ESCAPE on this engine, in the double-quote form and the backtick form +-- alike, so a name ending in one swallows the closing quote and the parser runs on into +-- whatever follows. The source read therefore takes NO identifier position at all: every +-- caller-supplied name reaches its statements as a STRING LITERAL, where the same hazard +-- exists and the provider's `literal()` answers it by escaping the backslash as well as +-- doubling the quote. +-- +-- These two tables are what makes a test of that non-vacuous rather than a claim in a +-- docblock. `bs_one\` stores exactly ONE trailing backslash (hex(name) = 62735F6F6E655C, +-- length 7), and reading its definition back with the backslash left unescaped fails with +-- code 62, `Single quoted string is not closed`, at the position of the clause that +-- followed. Renaming or dropping either one makes that measurement unrepeatable. +CREATE TABLE demo.`bs_one\\` +( + x UInt8 +) +ENGINE = MergeTree +ORDER BY x; + +CREATE TABLE demo.`dq"two` +( + x UInt8 +) +ENGINE = MergeTree +ORDER BY x; diff --git a/docker/couchbase-init/01-object-fixture.sh b/docker/couchbase-init/01-object-fixture.sh index 1308b980..69f107ce 100755 --- a/docker/couchbase-init/01-object-fixture.sh +++ b/docker/couchbase-init/01-object-fixture.sh @@ -55,6 +55,20 @@ # 7. `_default`.`airline` carries the SAME collection name as `inventory`.`airline`, with # an index of the same name over a DIFFERENT key, so a scope-blind filter on a # collection's indexes reports the wrong keys rather than merely the wrong count. +# 8. The two `discount` functions are also what the SOURCE READ is measured on +# (#789 Phase 2). Measured on Server 8.0.2 Community, `system:functions` +# answers `inventory`.`discount` as +# {"#language":"inline","expression":"(`price` - ((`price` * `pct`) / 100))", +# "parameters":["price","pct"],"text":"price - (price * pct / 100)"}. +# `text` is the body AS AUTHORED and `expression` is the engine's +# normalisation of it, which is the measurement behind reading `text` and +# captioning the part `origin: "stored"`. The two bodies differ from each +# other, so a read that matched on the name alone answers the wrong one +# rather than the same one by luck. There is no EXTERNAL JavaScript function +# here and there cannot be: Community Edition refuses to create one +# ("Functions of type javascript are only supported in Enterprise Edition", +# measured verbatim), which is why the provider's no-body refusal branch is +# driven by its suite and not by this fixture. # # The `_system` scope and its collections (`_mobile`, `_query`) are the server's # own and are created by it, not here. They are what the `_system` exclusion is @@ -121,7 +135,11 @@ n1ql "CREATE INDEX \`ix_name\` IF NOT EXISTS ON \`$BUCKET\`.\`_default\`.\`airli # Enterprise Edition", measured, which is why the provider never classifies on # definition.`#language`. n1ql "CREATE OR REPLACE FUNCTION \`$BUCKET\`.\`inventory\`.\`discount\`(price, pct) { price - (price * pct / 100) }" -n1ql "CREATE OR REPLACE FUNCTION \`$BUCKET\`.\`_default\`.\`discount\`(x) { x }" +# The body is `x / 2` and not a single token on purpose: the object-surface +# conformance helper bounds the LONGEST definition it read and refuses a +# definition under two characters, because a one-character text cannot be told +# bounded from unbounded (#789). +n1ql "CREATE OR REPLACE FUNCTION \`$BUCKET\`.\`_default\`.\`discount\`(x) { x / 2 }" # 4. A GLOBAL function, which must never reach the tree. n1ql "CREATE OR REPLACE FUNCTION \`celsius\`(f) { (f - 32) / 1.8 }" diff --git a/docker/duckdb-init/01-object-fixture.sql b/docker/duckdb-init/01-object-fixture.sql new file mode 100644 index 00000000..ec8153c6 --- /dev/null +++ b/docker/duckdb-init/01-object-fixture.sql @@ -0,0 +1,69 @@ +-- The DuckDB object-surface and object-source fixture (#789). +-- +-- Two catalogs, four schemas, and at least one object of every declared kind, so every +-- assertion `tests/integration/db/duckdb-provider.test.ts` makes about counts, listings, +-- detail and definition text is made about objects THIS file created. +-- +-- Applied two ways, by one reader: the integration suite replays it into `:memory:`, and +-- `bun docker/duckdb-init/build-fixture.ts` replays it into a database FILE a person can +-- point Studio at. Before #789 this DDL lived only inside the suite, which is the shape +-- standing ruling 5i forbids: a measurement nobody outside the test run can re-run. +-- +-- THE SECOND CATALOG'S TARGET IS A PLACEHOLDER, and it has to be. `ATTACH ':memory:' AS +-- warehouse` gives the suite a second real catalog inside one process; a database FILE +-- needs a sibling file there instead, or every `warehouse` object vanishes the moment the +-- builder exits and a person opening the file finds one catalog where the tests saw two. +-- `readFixtureStatements()` substitutes {{warehouse}} and throws by name when nothing +-- substituted it. +-- +-- DuckDB has NO trigger and NO stored procedure (both are parser errors on v1.5.5), so +-- neither appears below and neither is declared. + +CREATE SCHEMA analytics; + +CREATE TABLE main.customers (id INTEGER PRIMARY KEY, name VARCHAR NOT NULL, note VARCHAR DEFAULT 'none'); +CREATE TABLE main.orders (id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES main.customers(id), total DECIMAL(12,2)); +CREATE INDEX ix_orders_customer ON main.orders(customer_id); + +CREATE TABLE analytics.events (id BIGINT, payload VARCHAR); + +-- Two SAME-NAMED tables in two schemas, with different columns and an index each. This is +-- what a detail read's schema filter is for: without it `main.customers` and +-- `analytics.customers` merge, and the merged answer is a table with columns it does not +-- have rather than an error anybody would notice. +CREATE TABLE analytics.customers (event_id BIGINT); +CREATE TABLE analytics.orders (id INTEGER); +CREATE INDEX ix_orders_customer ON analytics.orders(id); + +CREATE VIEW main.customer_names AS SELECT name FROM main.customers; +CREATE VIEW analytics.event_days AS SELECT id FROM analytics.events; + +-- One macro of each form. A scalar macro is `function_type` 'macro' and a table macro is +-- 'table_macro'; they are the whole vocabulary `FUNCTION_TYPE_RULES` accounts for, and +-- both are here so the two arms cannot rot into one. +CREATE MACRO main.add_one(x) AS x + 1; +CREATE MACRO analytics.recent_events(n) AS TABLE SELECT * FROM analytics.events LIMIT n; + +CREATE SEQUENCE main.customer_seq START 1; +CREATE SEQUENCE analytics.event_seq START 100; + +-- One name, three kinds, in one schema, and it is the reason `readObjectSource` takes the +-- KIND as well as the path. Measured on DuckDB v1.5.5: `CREATE SEQUENCE overlap` and +-- `CREATE MACRO overlap(x)` both succeed while the table `overlap` exists, and only +-- `CREATE VIEW overlap` is refused (`Catalog Error: Table with name "overlap" already +-- exists!`). A source read keyed on the name alone answers one of the three at random. +CREATE TABLE main.overlap (id INTEGER); +CREATE SEQUENCE main.overlap; +CREATE MACRO main.overlap(x) AS x; + +-- A second real catalog, with a schema set of its own and no macro or sequence, so a +-- declared-and-empty folder has somewhere to be measured. +ATTACH '{{warehouse}}' AS warehouse; +CREATE SCHEMA warehouse.stock; +CREATE TABLE warehouse.main.ledger (id INTEGER); +-- Same SCHEMA name, same TABLE name, different CATALOG. This is the two-level engine's +-- characteristic case and the only thing that can show the catalog filter working: a +-- detail read that dropped `database_name = $1` would merge `memory.main.customers` with +-- this one and report a table with columns from both. +CREATE TABLE warehouse.main.customers (sku VARCHAR); +CREATE TABLE warehouse.stock.items (sku VARCHAR); diff --git a/docker/duckdb-init/build-fixture.ts b/docker/duckdb-init/build-fixture.ts new file mode 100644 index 00000000..69b7463a --- /dev/null +++ b/docker/duckdb-init/build-fixture.ts @@ -0,0 +1,167 @@ +/** + * The DuckDB object-surface and object-source fixture, read and applied (#789). + * + * Two callers and one reader. `tests/integration/db/duckdb-provider.test.ts` calls + * `readFixtureStatements(":memory:")` and replays the result into an in-memory database, so + * the objects its object-surface and Source assertions reason about are created BY + * `01-object-fixture.sql`; running this file as a script replays the same statements into a + * database FILE a person can open in Studio. Before #789 the DDL lived only inside the + * suite, which is the shape standing ruling 5i forbids: a live measurement nobody outside + * the test run can re-run. + * + * Run it: + * + * bun docker/duckdb-init/build-fixture.ts # ./.duckdb-fixture/object-fixture.duckdb + * bun docker/duckdb-init/build-fixture.ts /tmp/demo.duckdb # anywhere else + * + * It is re-runnable: an existing file at the target path is removed first, together with + * the `.wal` sidecar a crashed session can leave beside it and the warehouse sibling below, + * so a second run produces a database identical to the first rather than one holding both + * runs' objects. + * + * THE SECOND CATALOG IS WHY THIS FILE SUBSTITUTES A PLACEHOLDER RATHER THAN JUST SPLITTING. + * The fixture's `ATTACH '{{warehouse}}' AS warehouse` is a second REAL catalog, and the two + * callers need two different targets: the suite wants `:memory:`, which lives and dies with + * the process, and a file build wants a sibling file, or every `warehouse` object vanishes + * when the builder exits and a person opening the result finds one catalog where the tests + * saw two. + * + * ONE LIMIT, STATED HERE RATHER THAN DISCOVERED: DuckDB does NOT persist an attachment + * inside a database file. So opening the built file shows the `memory` half only, and the + * second catalog is reached with `ATTACH '.warehouse.duckdb' AS warehouse` in a + * query tab. The sibling is still built, because the alternative - dropping the second + * catalog from the file build - would give a person a fixture that cannot show the two + * container levels this engine's whole object model is about. + */ +import { DuckDBInstance } from "@duckdb/node-api"; +import * as fs from "fs"; +import * as path from "path"; + +/** Where the fixture lives, so a caller names a file rather than a path. */ +export const FIXTURE_DIRECTORY = import.meta.dir; + +/** The fixture the DuckDB provider's own suite replays. */ +export const DUCKDB_FIXTURE_FILE = "01-object-fixture.sql"; + +/** The default target, relative to the repository root. */ +export const DEFAULT_FIXTURE_PATH = ".duckdb-fixture/object-fixture.duckdb"; + +/** + * The token `ATTACH` names its target with, and the one thing in this fixture that is not + * literal SQL. + * + * Spelled once and exported, so the substitution and the guard that it happened cannot + * disagree about what they are looking for. + */ +export const WAREHOUSE_PLACEHOLDER = "{{warehouse}}"; + +/** The second catalog's target for a file build, derived from the target's own path. */ +export function warehouseSibling(file: string): string { + return `${file}.warehouse.duckdb`; +} + +/** + * Every statement of the fixture, comments removed, in the file's own order, with the + * second catalog's target substituted in. + * + * A line whose first non-blank characters are `--` is dropped whole; there is no `--` + * inside a string literal in this fixture, and a splitter that pretended otherwise would be + * claiming a lexer it does not have. A statement ends at a line ending in `;`, which is + * enough here for a reason DuckDB gives rather than luck: this engine has no trigger and no + * stored procedure, so no statement in the fixture carries a body holding its own + * semicolon. That is the trap `docker/sqlite-init/build-fixture.ts` needs a trigger rule + * for, and it cannot arise on this engine. + * + * THREE THROWS, each over a zero that would otherwise pass silently: + * + * - no statement at all, which builds an EMPTY database and makes every count assertion + * downstream read zero against zero. The same throw covers a path resolving to the wrong + * file; + * - no substitution, which would send DuckDB the literal string `{{warehouse}}` as a file + * PATH. Measured on v1.5.5: that ATTACH succeeds and creates a file called + * `{{warehouse}}` in the working directory, so the failure is silent litter rather than + * an error, and the suite would still see two catalogs; + * - an empty warehouse target, because `ATTACH '' AS warehouse` is the same shape one step + * further along. + */ +export function readFixtureStatements(warehouse: string, file: string = DUCKDB_FIXTURE_FILE): string[] { + if (warehouse.trim() === "") { + throw new Error("the warehouse target is blank, so the fixture's second catalog would attach nothing"); + } + const resolved = path.isAbsolute(file) ? file : path.join(FIXTURE_DIRECTORY, file); + const text = fs.readFileSync(resolved, "utf8"); + const statements: string[] = []; + let buffer = ""; + for (const line of text.split("\n")) { + if (line.trim().startsWith("--")) continue; + const body = line.trimEnd(); + if (!body.endsWith(";")) { + buffer += `${line}\n`; + continue; + } + const statement = `${buffer}${body.slice(0, -1)}`.trim(); + if (statement !== "") statements.push(statement); + buffer = ""; + } + if (statements.length === 0) { + throw new Error(`${resolved} yielded no statement, so applying it would build an empty database`); + } + const substituted = statements.map((statement) => statement.split(WAREHOUSE_PLACEHOLDER).join(warehouse)); + if (substituted.every((statement, index) => statement === statements[index])) { + throw new Error( + `${resolved} carries no ${WAREHOUSE_PLACEHOLDER} to substitute, so the second catalog would attach a file ` + + "named after the placeholder itself", + ); + } + return substituted; +} + +/** + * Delete a DuckDB database, the `.wal` sidecar, and the warehouse sibling beside it. + * + * ABSENCE IS THE ONLY FAILURE THIS SWALLOWS, and the narrowing is the point: the file not + * being there IS the state this function is asked for, while any other errno means the file + * is still on disk. A bare `catch {}` reported success there, and `buildObjectFixture` then + * opened the STALE database and replayed the fixture DDL on top of it, so the first failure + * a person saw was `CREATE TABLE main.customers` raising a catalog error that named nothing + * about an undeleted file. Raising here is also the house rule: no silent recovery and no + * symptom-masking guard. + */ +export function removeDatabaseFile(file: string): void { + for (const target of [file, `${file}.wal`, warehouseSibling(file), `${warehouseSibling(file)}.wal`]) { + try { + fs.unlinkSync(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} + +/** Build the fixture at `file`, replacing whatever is there, with its warehouse sibling. */ +export async function buildObjectFixture(file: string): Promise { + removeDatabaseFile(file); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const instance = await DuckDBInstance.create(file); + const connection = await instance.connect(); + try { + for (const statement of readFixtureStatements(warehouseSibling(file))) await connection.run(statement); + // Both catalogs, so neither file is left holding an uncheckpointed write-ahead log a + // reader would have to replay. The first is spelled bare rather than by name: a DuckDB + // file's catalog is named after its own stem, so the connected catalog here is + // `object-fixture` and never the `memory` the in-process suite sees. + await connection.run("CHECKPOINT"); + await connection.run("CHECKPOINT warehouse"); + } finally { + connection.closeSync(); + instance.closeSync(); + } +} + +if (import.meta.main) { + const target = path.resolve(process.argv[2] ?? DEFAULT_FIXTURE_PATH); + await buildObjectFixture(target); + process.stdout.write( + `DuckDB object fixture written to ${target}\n` + + `Second catalog: ${warehouseSibling(target)} (ATTACH '${warehouseSibling(target)}' AS warehouse)\n`, + ); +} diff --git a/docker/mariadb-init/01-object-fixture.sql b/docker/mariadb-init/01-object-fixture.sql index ebd3163c..7ff3f8e0 100644 --- a/docker/mariadb-init/01-object-fixture.sql +++ b/docker/mariadb-init/01-object-fixture.sql @@ -117,6 +117,23 @@ CREATE EVENT orders_nightly ON SCHEDULE EVERY 1 DAY DO DELETE FROM order_archive WHERE archived < (CURRENT_DATE() - INTERVAL 1 YEAR); +-- An EXECUTE-only caller, for the source-read refusal recorded in docs/providers/mysql.md +-- (#789), and it sits HERE, above `SET sql_mode = 'ORACLE'`, so it is parsed under the +-- default mode with the rest of the file. +-- +-- MEASURED on MariaDB 12.3.2: this user sees all five `information_schema.ROUTINES` rows +-- with a NULL `ROUTINE_DEFINITION`, and `SHOW CREATE PROCEDURE`, `SHOW CREATE FUNCTION`, +-- `SHOW CREATE PACKAGE` and `SHOW CREATE PACKAGE BODY` each answer a ROW whose body column +-- is NULL rather than raising. MariaDB utters no sentence for it, so the provider supplies +-- its own. +-- +-- A caller holding NOTHING on `app` is a DIFFERENT case and is deliberately not modelled +-- here: that caller is told `ERROR 1305 (42000) PROCEDURE order_archive does not exist`, +-- which is byte-identical to what a genuinely absent object answers, and it sees no row in +-- `information_schema.ROUTINES` either, so it never reaches the source read. +CREATE USER IF NOT EXISTS 'src_probe'@'%' IDENTIFIED BY 'src_probe'; +GRANT EXECUTE ON app.* TO 'src_probe'@'%'; + -- LAST, and everything below it is a package. ORACLE mode is what makes CREATE PACKAGE -- parse at all, and it rewrites the grammar of every statement after it. -- @@ -144,4 +161,18 @@ CREATE PACKAGE BODY orders_pkg AS END; END // +-- A package SPECIFICATION with no BODY, which is the ONE-PART shape of the source read +-- (#789). MEASURED on MariaDB 12.3.2: `SHOW CREATE PACKAGE app.spec_only_pkg` answers the +-- spec text and `SHOW CREATE PACKAGE BODY app.spec_only_pkg` answers +-- `ERROR 1305 (42000) PACKAGE BODY spec_only_pkg does not exist`, so a body's absence is +-- told apart from the package's absence by asking for the SPEC first. The other direction +-- is measured too and is why one PACKAGE row per package is a complete count: a +-- `CREATE PACKAGE BODY` with no specification is refused with the same ER_SP_DOES_NOT_EXIST. +-- +-- `information_schema.ROUTINES` holds ONE row for this package, ROUTINE_TYPE 'PACKAGE', +-- where `orders_pkg` holds two. +CREATE PACKAGE spec_only_pkg AS + PROCEDURE p1(p_id INT); +END // + DELIMITER ; diff --git a/docker/mongodb-init/01-object-fixture.js b/docker/mongodb-init/01-object-fixture.js index 27748b42..bf8c301a 100644 --- a/docker/mongodb-init/01-object-fixture.js +++ b/docker/mongodb-init/01-object-fixture.js @@ -32,7 +32,16 @@ // sort one way by code point and another way by `JSON.stringify`. They live in their // own database so the `app` counts stay about the kinds rather than about sorting. // 6. `active_customers` is a view, and its `options.viewOn` and `options.pipeline` come -// back on the same `listCollections` call that classified it. Phase 2 reads them. +// back on the same `listCollections` call that classified it. Phase 2 reads them, and +// this one is the ORDINARY view: `options` holds those two keys and nothing else. +// 7. `configstore.dark_settings` is the ADVERSARIAL view, and it exists to refute two +// shapes the source read could otherwise have taken (#789). Measured on MongoDB 8.2.12: +// a view's `options` also carries `collation` when it was created with one, so a read +// rendering only `viewOn` and `pipeline` would drop it while calling itself complete; +// and a pipeline may hold BSON values, so `JSON.stringify` renders the `/^th/i` below as +// `{}` and loses it in silence, while MongoDB Extended JSON renders it as +// `$regularExpression`. For `active_customers`, whose pipeline holds no BSON value, the +// two renderings are byte-identical, so only this view can tell them apart. const app = db.getSiblingDB("app"); @@ -71,5 +80,24 @@ oddnames.createCollection("x\\a"); const configstore = db.getSiblingDB("configstore"); configstore.settings.insertMany([{ key: "theme", value: "dark" }]); +// The adversarial view of note 7. A regular expression and a date in the pipeline, and a +// collation the server expands to ten fields of its own. +configstore.createCollection("dark_settings", { + viewOn: "settings", + pipeline: [{ $match: { key: /^th/i, changed: { $gt: new Date("2026-01-01T00:00:00Z") } } }], + collation: { locale: "tr", strength: 2 }, +}); + +// 8. `libredb_nolist` holds `read` on `configstore` and on nothing else, so it makes the +// object surface's REFUSAL re-runnable rather than a measurement in a report. Measured on +// 8.2.12: asking it for `app` answers `not authorized on app to execute command +// { listCollections: 1, ... }`, which both `countObjects` and `readObjectSource` carry +// verbatim, while `configstore` still reads normally and is the control that makes the +// refusal a fact about privilege rather than about the connection (#789). +db.getSiblingDB("admin").createUser({ + user: "libredb_nolist", + pwd: "libredb_nolist", + roles: [{ role: "read", db: "configstore" }], +}); print("libredb object fixture applied: app, configstore, oddnames"); diff --git a/docker/mssql-init/01-object-fixture.sql b/docker/mssql-init/01-object-fixture.sql index ecf45746..1874f2b8 100644 --- a/docker/mssql-init/01-object-fixture.sql +++ b/docker/mssql-init/01-object-fixture.sql @@ -9,7 +9,7 @@ -- docker exec /opt/mssql-tools18/bin/sqlcmd \ -- -S localhost -U sa -P '' -C -b -i /tmp/fixture.sql -- --- Three things about this file are load-bearing and must not be "tidied": +-- Four things about this file are load-bearing and must not be "tidied": -- -- 1. The `GO` separators. They are a client convention that never reaches the server, and -- they are correct HERE because sqlcmd is what runs this file: CREATE VIEW, CREATE @@ -23,6 +23,36 @@ -- 3. A DATABASE-scoped DDL trigger. It is absent from sys.objects entirely (measured -- below on SQL Server 2022 CU26), so a trigger count taken from sys.objects alone is -- wrong in a way no test on sys.objects can see. +-- 4. TWO encrypted modules, a VIEW and a PROCEDURE, and the `src_probe` login (#789). +-- Together they make all THREE causes of one NULL definition reachable: an encrypted +-- module, a caller without VIEW DEFINITION, and a module-less object. Two encrypted +-- objects and not one, so the refusal is visibly per-OBJECT rather than per-kind: the +-- encrypted view sits beside an unencrypted procedure in the same schema. `src_probe` +-- is granted EXECUTE as well as SELECT, and that is load-bearing rather than generous: +-- measured, SQL Server's metadata visibility hides every PROCEDURE in a schema from a +-- principal holding only SELECT ON SCHEMA::app, so OBJECT_ID resolves to nothing and +-- the encrypted-procedure case cannot be reached at all. + +-- ============================================================================ +-- src_probe: a SERVER principal, created before the databases that grant to it +-- ============================================================================ +-- The second of the three NULL definitions (#789). This login is deliberately given no +-- VIEW DEFINITION anywhere, so `sys.sql_modules` answers it a ROW with a NULL `definition` +-- rather than no row at all - measured on SQL Server 2022 RTM-CU26 (16.0.4265.3), and that +-- is what makes a denial distinguishable from an absence. CHECK_POLICY is OFF because the +-- host's own password policy is not this fixture's to satisfy. +-- +-- The password is the login's own name, which is the convention every other credential in +-- this repository's fixtures follows (`postgres`, `root`, `admin`, `druid`). It was a +-- realistic-looking string until 2026-09-13, and a secret scanner reported the pull request +-- that added it: a fixture credential shaped like a real password is indistinguishable from +-- one, to a scanner and to a reader. A value that obviously belongs to its own fixture says +-- what it is without a comment. +IF SUSER_ID('src_probe') IS NOT NULL + DROP LOGIN src_probe; +GO +CREATE LOGIN src_probe WITH PASSWORD = 'src_probe', CHECK_POLICY = OFF; +GO -- ============================================================================ -- libredb_objects: the connected database, two user schemas @@ -109,10 +139,28 @@ CREATE VIEW app.order_summary AS LEFT JOIN app.customers c ON c.id = o.customer_id; GO +-- ENCRYPTED, and it sits in the same schema as the readable view above ON PURPOSE (#789). +-- WITH ENCRYPTION makes `sys.sql_modules` answer a ROW whose `definition` is NULL for +-- everybody, sa included, which is the FIRST of the three causes of that NULL. Its name +-- sorts AFTER `order_summary`, so the shared conformance walk - which reads the first +-- object a listing returns - still reads a definition here and the refusal is reached by +-- this suite's own named test rather than by accident. +CREATE VIEW app.order_summary_secret WITH ENCRYPTION AS + SELECT o.id, o.total FROM app.orders o WHERE o.total > 0; +GO + CREATE PROCEDURE app.touch_order @order_id INT AS UPDATE app.orders SET note = 'touched' WHERE id = @order_id; GO +-- The SECOND encrypted module, and a second kind. One encrypted object would let a reader +-- believe the refusal is a property of the KIND; two, in two kinds, beside an unencrypted +-- sibling in each, say it is a property of the OBJECT. Sorts after `touch_order` for the +-- reason the encrypted view sorts after `order_summary`. +CREATE PROCEDURE app.touch_order_secret @order_id INT WITH ENCRYPTION AS + UPDATE app.orders SET note = 'secret' WHERE id = @order_id; +GO + -- FN: a scalar function. CREATE FUNCTION app.order_total (@order_id INT) RETURNS DECIMAL(12, 2) AS BEGIN @@ -193,6 +241,29 @@ GO CREATE SEQUENCE app.order_number_seq AS INT START WITH 1 INCREMENT BY 1; GO +-- The database half of `src_probe` (#789). SELECT and EXECUTE on the schema and NOTHING +-- else: no VIEW DEFINITION at the object, schema or database level, which is what makes +-- this principal's read of `app.touch_order` answer a row with a NULL definition and a NULL +-- encryption flag, telling a DENIAL apart from an ENCRYPTION. +-- +-- THE FLAG IS `sys.syscomments.encrypted` AND NOT `OBJECTPROPERTY(..., 'IsEncrypted')`, which +-- the provider rejects by name: measured on SQL Server 2022 RTM-CU26, OBJECTPROPERTY resolves +-- its object id in the CONNECTED database whatever database a three-part name addresses, and +-- this is the one engine whose catalog level is part of every path. `docs/providers/mssql.md` +-- carries the measurement. Reading it here would answer for somebody else's object. +-- +-- EXECUTE is not generosity. Measured on SQL Server 2022 RTM-CU26: a principal holding +-- only SELECT ON SCHEMA::app sees no PROCEDURE in the schema at all, because SQL Server's +-- metadata visibility shows an object only to a principal holding some permission ON it, +-- and SELECT is not applicable to a procedure. Without this grant `OBJECT_ID` resolves to +-- nothing for every procedure and the encrypted-procedure case is unreachable. +CREATE USER src_probe FOR LOGIN src_probe; +GO +GRANT SELECT ON SCHEMA::app TO src_probe; +GO +GRANT EXECUTE ON SCHEMA::app TO src_probe; +GO + -- ============================================================================ -- libredb_objects_two: a DIFFERENT schema set, for the cross-database reads -- ============================================================================ diff --git a/docker/mysql-init/01-object-fixture.sql b/docker/mysql-init/01-object-fixture.sql index c7a2a1d3..bb3ad7a8 100644 --- a/docker/mysql-init/01-object-fixture.sql +++ b/docker/mysql-init/01-object-fixture.sql @@ -101,3 +101,20 @@ DELIMITER ; CREATE EVENT orders_nightly ON SCHEDULE EVERY 1 DAY DO DELETE FROM order_archive WHERE archived < (CURRENT_DATE() - INTERVAL 1 YEAR); + +-- An EXECUTE-only caller, for the source-read refusal recorded in docs/providers/mysql.md +-- (#789). This is the caller the Source tab has to say something true about: MEASURED on +-- MySQL 26.7.0, `SHOW CREATE PROCEDURE app.order_archive` answers a ROW whose +-- `Create Procedure` column is NULL rather than raising, and the same is true of +-- `SHOW CREATE FUNCTION`. MySQL utters no sentence for it, so the provider supplies its own. +-- +-- A caller holding NOTHING on `app` is a DIFFERENT case and is deliberately not modelled +-- here: that caller is told `ERROR 1305 (42000) PROCEDURE order_archive does not exist`, +-- which is byte-identical to what a genuinely absent object answers, and it cannot see the +-- routine in `information_schema.ROUTINES` either, so it never reaches the source read. +-- +-- The routines above carry an explicit `DEFINER` for exactly this reason: +-- `information_schema.ROUTINES` is privilege filtered, and a routine whose definer is the +-- connecting user is the easy case that hides it. +CREATE USER IF NOT EXISTS 'src_probe'@'%' IDENTIFIED BY 'src_probe'; +GRANT EXECUTE ON app.* TO 'src_probe'@'%'; diff --git a/docker/oracle-init/01-object-fixture.sql b/docker/oracle-init/01-object-fixture.sql index 77e89b66..50d2526f 100644 --- a/docker/oracle-init/01-object-fixture.sql +++ b/docker/oracle-init/01-object-fixture.sql @@ -144,6 +144,126 @@ GRANT ADMINISTER DATABASE TRIGGER TO app; CREATE OR REPLACE TRIGGER app.app_logon_trg AFTER LOGON ON app.SCHEMA BEGIN NULL; END; / +-- --------------------------------------------------------------------------- +-- Wrapped PL/SQL, and the units built to defeat each half of the rule (#789). +-- --------------------------------------------------------------------------- +-- +-- Measured on Oracle XE 21.3.0.0.0 (gvenzl/oracle-xe): EXECUTE ON DBMS_DDL is already +-- granted to PUBLIC on this image, so APP needs no extra grant to run CREATE_WRAPPED. +-- +-- WHY THESE OBJECTS EXIST. DBMS_METADATA.GET_DDL answers a wrapped unit with the +-- encoder's obfuscated bytes and raises nothing, so a provider that hands that text to an +-- editor shows something that is not a definition and cannot say so. The detection rule is +-- the header POSITION: the token immediately after the closing double quote of the object's +-- quoted name is the bare keyword `wrapped`, and the next physical line is the wrap format +-- marker matching ^[a-z][0-9]{6}$ (`a000000` on 21.3.0.0.0). +-- +-- The four plain units below are the point of this block and must not be "tidied". Three of +-- them are VALID, COMPILING functions built to defeat the naive TEXTUAL rule: one ends its +-- first source line with the token `wrapped`, one carries the wrap format marker on its +-- second line, and APP_CONJ_DEFEATER does both at once. The fourth, APP_ZERO_ARG, is the +-- control for the header position itself and the paragraph below says why. What none of the +-- four can imitate is the header position, because GET_DDL writes the object name inside +-- double quotes and what follows it is decided by the PARSER: a plain unit admits only `(`, +-- RETURN, IS or AS there. A test that reads only a wrapped unit certifies nothing; these +-- four are what make the predicate non-vacuous, and if a future Oracle ever admits +-- `wrapped` in that position for a plain unit, the assertion over them fails by name. +-- +-- APP_ZERO_ARG is the closest PLAIN shape to a wrapped header there is, a zero-argument +-- function whose header carries no parameter list at all, so the token after the quoted +-- name is the bare word `return`. It is the control for the position itself. +-- +-- APP_MARKERLESS_HEADER, at the end of this block, attacks the OTHER conjunct and is the one +-- unit here that does not compile. Its own comment carries the measurement. + +BEGIN + DBMS_DDL.CREATE_WRAPPED( + 'CREATE OR REPLACE FUNCTION app.app_wrapped_multi(p NUMBER) RETURN NUMBER IS' || CHR(10) || + ' v NUMBER := 2;' || CHR(10) || + 'BEGIN' || CHR(10) || + ' RETURN p * v;' || CHR(10) || + 'END;'); +END; +/ + +CREATE OR REPLACE FUNCTION app.app_first_line_wrapped(p NUMBER) RETURN NUMBER IS -- wrapped +BEGIN + RETURN p; +END; +/ + +CREATE OR REPLACE FUNCTION app.app_second_line_marker(p NUMBER) RETURN NUMBER IS /* +a000000 +*/ +BEGIN + RETURN p; +END; +/ + +CREATE OR REPLACE FUNCTION app.app_conj_defeater(p NUMBER) RETURN NUMBER IS /* wrapped +a000000 +*/ +BEGIN + RETURN p; +END; +/ + +CREATE OR REPLACE FUNCTION app.app_zero_arg RETURN NUMBER IS +BEGIN + RETURN 1; +END; +/ + +-- The unit that defeats the OTHER half of the conjunction, and the one object in this block +-- that does NOT compile. The detection rule is `wrapped` in the header position AND the wrap +-- format marker on the next line; the four units above all attack the first conjunct, and +-- until this object existed the SECOND conjunct was asserted by nothing at all, because a +-- real wrapped unit always carries its marker. +-- +-- MEASURED on Oracle XE 21.3.0.0.0 (gvenzl/oracle-xe), and it is not what the header +-- position's parser rule would lead you to expect: `wrapped` after the function name is +-- ACCEPTED, because it is the wrap keyword. The unit then fails to compile with +-- `PLS-00753: malformed or corrupted wrapped unit` (one row in `USER_ERRORS`, line 0), the +-- object is created FUNCTION / INVALID, and `DBMS_METADATA.GET_DDL` answers its source +-- verbatim anyway: the header carries the keyword and the next line is `BEGIN`, so the +-- predicate must answer NOT WRAPPED and the reader gets a readable text rather than a +-- manufactured refusal. Do not "fix" the missing marker: the missing marker is the point. +-- +-- A `CREATE OR REPLACE` on an object left INVALID is harmless here and is the same state +-- APP_BROKEN_PKG is committed in. +CREATE OR REPLACE FUNCTION app.app_markerless_header wrapped +BEGIN + RETURN 1; +END; +/ + +-- A package whose SPEC is plain and whose BODY is wrapped, which is the whole argument for +-- reading PACKAGE_SPEC and PACKAGE_BODY as two parts instead of the bare PACKAGE type. One +-- concatenated CLOB would give a reader neither half honestly: the spec is readable and the +-- body is not, and only two parts can say so. +CREATE OR REPLACE PACKAGE app.app_wrapped_pkg IS + FUNCTION total(p NUMBER) RETURN NUMBER; +END app_wrapped_pkg; +/ + +BEGIN + DBMS_DDL.CREATE_WRAPPED( + 'CREATE OR REPLACE PACKAGE BODY app.app_wrapped_pkg IS' || CHR(10) || + ' FUNCTION total(p NUMBER) RETURN NUMBER IS BEGIN RETURN p; END;' || CHR(10) || + 'END app_wrapped_pkg;'); +END; +/ + +-- A package with a SPECIFICATION AND NO BODY, which is legal on Oracle and is the shape +-- that decides how many parts a package emits. Its missing body is an ABSENCE and not a +-- refusal: there is nothing to refuse, because nobody ever wrote one. The provider emits +-- ONE part for it, and the ALL_OBJECTS second question is what tells that absence apart +-- from a body ORA-31603 refuses to hand over. +CREATE OR REPLACE PACKAGE app.app_spec_only_pkg IS + FUNCTION total(p NUMBER) RETURN NUMBER; +END app_spec_only_pkg; +/ + -- Explicit, rather than relying on SQL*Plus committing on EXIT. Measured on gvenzl/oracle-xe -- 21.3.0: EXIT does commit, so this line changes nothing today, and it is here because the -- INSERTs above are the only DML in the file and a fixture whose data survives on a client diff --git a/docker/postgres-init/03-object-fixture.sql b/docker/postgres-init/03-object-fixture.sql index 3f265929..aee62fb4 100644 --- a/docker/postgres-init/03-object-fixture.sql +++ b/docker/postgres-init/03-object-fixture.sql @@ -22,3 +22,11 @@ CREATE TABLE IF NOT EXISTS public.orders ( id INTEGER PRIMARY KEY, note TEXT ); + +-- A role holding nothing at all, for the source-read privilege probe recorded in +-- docs/providers/postgres.md (#789). It is the control that makes "PostgreSQL has no +-- unreadable case for object source" a measurement rather than a belief: this role cannot +-- EXECUTE app.order_total and still reads every character of it, because the pg_get_* +-- family applies no privilege check at all. +DROP ROLE IF EXISTS src_probe; +CREATE ROLE src_probe LOGIN PASSWORD 'src_probe'; diff --git a/docker/redis-init/01-object-fixture.redis b/docker/redis-init/01-object-fixture.redis index 27c04d64..937b3132 100644 --- a/docker/redis-init/01-object-fixture.redis +++ b/docker/redis-init/01-object-fixture.redis @@ -12,3 +12,5 @@ DEL report:daily SET report:daily 42 SELECT 0 FUNCTION LOAD REPLACE "#!lua name=libredb_probe\nlocal function echo_key(keys, args)\n return redis.call('GET', keys[1])\nend\nlocal function ping(keys, args)\n return 'pong'\nend\nredis.register_function('libredb_echo_key', echo_key)\nredis.register_function('libredb_ping', ping)" +FUNCTION LOAD REPLACE "#!lua name=LIBREDB_PROBE\nlocal function upper_ping(keys, args)\n return 'PONG'\nend\nredis.register_function('LIBREDB_UPPER_PING', upper_ping)" +ACL SETUSER libredb_nofunction on >nofunction ~* +@all -function diff --git a/docker/search-init/01-object-fixture.sh b/docker/search-init/01-object-fixture.sh index 1e167002..362b81d8 100755 --- a/docker/search-init/01-object-fixture.sh +++ b/docker/search-init/01-object-fixture.sh @@ -49,6 +49,29 @@ # provider's own system-index rule hides: without this kind the tree can reach a # data stream's data through nothing at all. `SELECT * FROM probe_stream` # answers a column list on both products (measured). +# 6. `probe_json_edges` is a second ingest pipeline, and it exists for the SOURCE read +# (#789) rather than for the listing. Its three `set` processors carry the values a +# JSON re-serialisation changes: a long past 2^53, a map with integer-like keys as +# one processor's `value`, and a value the server prints in exponent form. Measured +# on Elasticsearch 9.1.4 on 2026-09-13, the cluster answers `9223372036854775807` and +# JSON.parse plus JSON.stringify renders `9223372036854776000`, the map arrives keyed +# `zz, 10, 2, aa` and comes back keyed `2, 10, zz, aa`, and `1.0E30` becomes `1e+30`. +# Both provider docs record those three as what this product's renderer does to the +# cluster's own bytes, and this object is what makes the claim re-runnable. +# The integer-like keys live inside a `set` processor's VALUE rather than in the +# pipeline's `_meta`, where the shorter spelling would have put them, and that is a +# measured product difference rather than a style choice: OpenSearch 3.8.0 refuses +# `_meta` on an ingest pipeline outright ("pipeline [probe_json_edges] doesn't support +# one or more provided configuration parameters [_meta]", HTTP 400) while +# Elasticsearch 9.1.4 accepts it, and this script's claim to apply unchanged to both +# is worth more than the shorter spelling. So this fixture writes no `_meta` at all. +# 7. `probe pipe/slash` is an ingest pipeline whose NAME holds a space and a slash, which +# both products accept. It is the object that makes the source read's percent-encoding +# non-vacuous: measured, `GET /_ingest/pipeline/probe%20pipe%2Fslash` answers the +# pipeline and the same request with the slash unencoded is HTTP 400, "no handler +# found for uri". A template name may NOT hold a space (HTTP 400, +# `invalid_index_template_exception`), so the pipeline endpoint is the only one where +# an adversarial name can live at all. set -euo pipefail SEARCH_URL="${SEARCH_URL:-http://localhost:9200}" @@ -106,4 +129,20 @@ put /_index_template/probe_stream_template '{ }' put /_data_stream/probe_stream || true +echo "6. ingest pipeline probe_json_edges, the renderer-fidelity object" +put /_ingest/pipeline/probe_json_edges '{ + "description": "libredb source-render fidelity fixture (#789)", + "processors": [ + { "set": { "field": "big", "value": 9223372036854775807 } }, + { "set": { "field": "sci", "value": 1e30 } }, + { "set": { "field": "keys", "value": { "zz": 1, "10": "ten", "2": "two", "aa": 2 } } } + ] +}' + +echo "7. ingest pipeline 'probe pipe/slash', the percent-encoding object" +put '/_ingest/pipeline/probe%20pipe%2Fslash' '{ + "description": "libredb source-read escaping fixture (#789)", + "processors": [ { "set": { "field": "escaped", "value": "yes" } } ] +}' + echo "Done." diff --git a/docker/sqlite-init/01-object-fixture.sql b/docker/sqlite-init/01-object-fixture.sql new file mode 100644 index 00000000..2c9fbf37 --- /dev/null +++ b/docker/sqlite-init/01-object-fixture.sql @@ -0,0 +1,98 @@ +-- The SQLite object-surface fixture (#789). +-- +-- This file IS the fixture: `tests/integration/db/sqlite-provider.test.ts` reads it through +-- `readFixtureStatements()` in `build-fixture.ts` and replays it into an in-memory database, +-- so every object the object-surface and Source tests reason about is created BY this file +-- rather than by a literal inside the test. `bun docker/sqlite-init/build-fixture.ts` turns +-- the same text into a database FILE a person can point Studio at. +-- +-- There is no `docker compose` service for SQLite and there never will be: the engine is a +-- file, and the build script is what an init directory would be for any other engine. +-- +-- Statements are separated by a `;` at the end of a line. A `CREATE TRIGGER` body holds its +-- own semicolons, so the splitter ends such a statement only at the `;` after its `END` - +-- the trap D53 recorded, and the reason the splitter is a shared function rather than a +-- `split(";")` in each caller. `ANALYZE` appears nowhere: sqld refuses it outright, and this +-- file is applied to that server too. + +-- A UNIQUE column, so SQLite also creates `sqlite_autoindex_customers_1`, which is the one +-- row of `sqlite_schema` whose `sql` is NULL. The provider's `name NOT LIKE 'sqlite\_%'` +-- filter is what keeps it out of every listing, which is why the source read has no +-- reachable refusal on this engine. +CREATE TABLE customers (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL); + +-- Two foreign keys of the two shapes SQLite publishes differently, and a generated column, +-- which `PRAGMA table_info` drops and `table_xinfo` publishes. Written over several lines +-- on purpose: `sqlite_schema.sql` keeps the newlines and the inner spacing, which is what +-- `origin: "stored"` means and what a regeneration would destroy. +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + customer_id INTEGER REFERENCES customers, + customer_email TEXT REFERENCES customers(email), + total INTEGER NOT NULL DEFAULT 0, + total_with_tax INTEGER GENERATED ALWAYS AS (total * 2) VIRTUAL + ); + +-- A composite primary key, so `table_info.pk` carries the ranks 1 and 2. +CREATE TABLE archive (region TEXT, year INTEGER, PRIMARY KEY (region, year)) WITHOUT ROWID; + +-- AUTOINCREMENT, so the engine adds `sqlite_sequence` to `PRAGMA table_list`. +CREATE TABLE audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT, note TEXT); + +INSERT INTO audit_log (note) VALUES ('seed'); + +-- A user table the UNESCAPED `LIKE 'sqlite_%'` would also exclude, because `_` is LIKE's +-- single-character wildcard. SQLite reserves only the `sqlite_` prefix, so this name is one +-- a user can really have. +CREATE TABLE sqliteXledger (id INTEGER PRIMARY KEY); + +-- One `virtual` row and five `shadow` rows in `PRAGMA table_list`. +CREATE VIRTUAL TABLE notes USING fts5(body); + +CREATE VIEW order_summary AS SELECT id, total FROM orders; + +CREATE INDEX idx_orders_customer ON orders(customer_id); + +-- An index on an EXPRESSION, whose key publishes a null column name. +CREATE INDEX idx_orders_doubled ON orders(total * 2); + +CREATE TRIGGER orders_stamp AFTER INSERT ON orders BEGIN UPDATE orders SET total = total; END; + +-- SQLite allows an INSTEAD OF trigger on a VIEW, so a trigger's parent segment is not always +-- a table even though the kind declares `attachedTo: "table"`. +CREATE TRIGGER order_summary_guard INSTEAD OF INSERT ON order_summary BEGIN SELECT 1; END; + +-- A TRIGGER whose name is ALSO a TABLE's, which SQLite accepts: measured on SQLite 3.53.2, +-- `CREATE TRIGGER audit_log ON audit_log` is legal while `CREATE INDEX audit_log` answers +-- "there is already a table named audit_log" and `CREATE VIEW audit_log` answers "table +-- audit_log already exists". A trigger has its own namespace and a table, a view and an +-- index share one. +-- +-- It is here for the source read and for nothing else. `SELECT sql FROM sqlite_schema WHERE +-- name = ?` answers TWO rows for this name and the table's comes first, so a read that took +-- its `type` from what the name matched rather than from the KIND would hand a reader the +-- table's DDL under a trigger's address. Without this object that defect is invisible: every +-- other name in this file resolves to exactly one row whatever the type filter does. +CREATE TRIGGER audit_log AFTER INSERT ON audit_log BEGIN SELECT 1; END; + +-- Session scratch that shadows `main`, and must never reach the tree. The temp `orders` +-- carries ONE column, so a detail read that forgot the schema is visible as a different +-- column list rather than as an error. +CREATE TEMP TABLE orders (id INTEGER); + +CREATE TEMP VIEW order_summary AS SELECT 1 AS x; + +CREATE TEMP TRIGGER orders_stamp_temp AFTER INSERT ON orders BEGIN SELECT 1; END; + +CREATE INDEX temp.idx_orders_customer_temp ON orders(id); + +-- Another database file entirely, which the connection was not configured for. +ATTACH DATABASE ':memory:' AS attached; + +CREATE TABLE attached.orders (id INTEGER); + +CREATE VIEW attached.order_summary AS SELECT 1 AS x; + +CREATE INDEX attached.idx_orders_customer_attached ON orders(id); + +CREATE TRIGGER attached.orders_stamp_attached AFTER INSERT ON orders BEGIN SELECT 1; END; diff --git a/docker/sqlite-init/02-libsql-object-fixture.sql b/docker/sqlite-init/02-libsql-object-fixture.sql new file mode 100644 index 00000000..ea94e9fa --- /dev/null +++ b/docker/sqlite-init/02-libsql-object-fixture.sql @@ -0,0 +1,69 @@ +-- The libSQL object-surface fixture (#789), and the file that closes D53. +-- +-- It lived as a fenced block in `docs/providers/libsql.md` and nowhere else, which is the +-- shape standing ruling 5i forbids: a person could read it and could not apply it, and the +-- capture in `tests/integration/db/libsql-provider.test.ts` was therefore a measurement +-- nobody could re-run. Sending this file to a running sqld rebuilds exactly the catalog that +-- suite answers from, and `bun docker/sqlite-init/build-fixture.ts 02-libsql-object-fixture.sql` +-- replays the same text into a local database FILE. +-- +-- Apply it to the compose service. The image carries NEITHER a `sqlite3` binary nor `curl`, +-- so the only way in is the Hrana HTTP API and `apply-to-libsql.ts` is the applier that +-- speaks it: +-- +-- docker compose -f database-compose.yml up -d libsql +-- bun docker/sqlite-init/apply-to-libsql.ts http://127.0.0.1:18080 +-- +-- WHY IT IS NOT `01-object-fixture.sql`. The two fixtures are NOT the same DDL, which the +-- Phase 2 plan assumed they were. This one holds a STRICT table, a `UNIQUE`-on-a-ROWID-table +-- autoindex and a foreign-key parent with no primary key, and it holds ten tables where 01 +-- holds six; 01 holds `TEMP` and `ATTACH`ed objects, which sqld refuses outright, and an +-- `AUTOINCREMENT` audit table 02 folds into `customers`. Converging them would rewrite every +-- count in both suites and invalidate a live capture neither task took. So one directory, +-- one splitter, one build script, and one file per engine's own captured catalog. +-- +-- `ANALYZE` is absent and must stay absent: sqld refuses it ("SQL not allowed statement"). + +CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, country TEXT DEFAULT 'TR'); + +CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers, + total REAL NOT NULL, tax REAL GENERATED ALWAYS AS (total * 0.2) VIRTUAL, placed_at TEXT); + +CREATE TABLE regions (region TEXT NOT NULL, year INTEGER NOT NULL, revenue REAL, + PRIMARY KEY (region, year)) WITHOUT ROWID; + +CREATE TABLE archive (id INTEGER PRIMARY KEY, body TEXT) STRICT; + +CREATE TABLE sqliteXledger (id INTEGER PRIMARY KEY, note TEXT); + +CREATE TABLE legacy (note TEXT); + +CREATE TABLE legacy_ref (id INTEGER PRIMARY KEY, note TEXT REFERENCES legacy); + +CREATE TABLE shipments (id INTEGER PRIMARY KEY, order_id INTEGER REFERENCES orders(id), carrier TEXT); + +CREATE TABLE badges (id INTEGER PRIMARY KEY, code TEXT UNIQUE, label TEXT); + +CREATE VIRTUAL TABLE notes USING fts5(title, body); + +CREATE VIEW order_summary AS SELECT c.name, o.total FROM orders o JOIN customers c ON c.id = o.customer_id; + +CREATE INDEX idx_orders_customer ON orders(customer_id); + +CREATE INDEX idx_orders_placed ON orders(date(placed_at)); + +CREATE UNIQUE INDEX idx_customers_name ON customers(name); + +CREATE TRIGGER orders_stamp AFTER INSERT ON orders + BEGIN UPDATE orders SET placed_at = datetime('now') WHERE id = NEW.id; END; + +CREATE TRIGGER order_summary_guard INSTEAD OF INSERT ON order_summary + BEGIN SELECT RAISE(ABORT, 'read only'); END; + +-- A TRIGGER whose name is ALSO a TABLE's. sqld accepts it, exactly as the file engine does: +-- `SELECT sql FROM sqlite_schema WHERE name = 'badges'` answers TWO rows and the table's +-- comes first, so a source read taking its `type` from what the name matched rather than +-- from the KIND hands a reader the table's DDL under a trigger's address. Every other name +-- in this file resolves to one row whatever the type filter does, so without this object +-- that defect is invisible. +CREATE TRIGGER badges AFTER INSERT ON badges BEGIN SELECT 1; END; diff --git a/docker/sqlite-init/apply-to-libsql.ts b/docker/sqlite-init/apply-to-libsql.ts new file mode 100644 index 00000000..737c88b0 --- /dev/null +++ b/docker/sqlite-init/apply-to-libsql.ts @@ -0,0 +1,60 @@ +/** + * Send `02-libsql-object-fixture.sql` to a running sqld, and print the catalog it built (#789). + * + * sqld ships no client: the image carries neither a `sqlite3` binary nor `curl`, and the only + * way in is the Hrana HTTP API. So the fixture needs an applier of its own, and this is it. + * Without one the file would be as unapplicable as the fenced block D53 was filed for. + * + * docker compose -f database-compose.yml up -d libsql + * bun docker/sqlite-init/apply-to-libsql.ts http://127.0.0.1:18080 + * bun docker/sqlite-init/apply-to-libsql.ts http://127.0.0.1:18080 + * + * It prints `sqlite_schema` as `type|name|tbl_name|sql` afterwards, which is what the capture + * in `tests/integration/db/libsql-provider.test.ts` is taken from. Statements run ONE PER + * REQUEST rather than as one batch, because a batch stops at the first failure and a fixture + * that half-applied is worse than one that did not: each statement's own outcome is printed. + */ +import { readFixtureStatements, LIBSQL_FIXTURE_FILE } from "./build-fixture"; + +interface HranaOutcome { + type: string; + error?: { message: string }; + response?: { result?: { rows?: { type: string; value?: string }[][] } }; +} + +async function send(base: string, token: string | undefined, sql: string): Promise { + const response = await fetch(`${base.replace(/\/$/, "")}/v2/pipeline`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ requests: [{ type: "execute", stmt: { sql } }, { type: "close" }] }), + }); + // A FAILED STATEMENT IS AN HTTP 200 with the failure inside `results[]`, so `response.ok` + // is never the verdict here (docs/providers/libsql.md section 3). A non-200 is a transport + // or an auth failure and is the one case worth raising on. + if (!response.ok) throw new Error(`${base} answered HTTP ${response.status}: ${await response.text()}`); + const body = (await response.json()) as { results: HranaOutcome[] }; + return body.results[0]; +} + +if (import.meta.main) { + const base = process.argv[2] ?? "http://127.0.0.1:18080"; + const token = process.argv[3]; + let failed = 0; + for (const statement of readFixtureStatements(LIBSQL_FIXTURE_FILE)) { + const outcome = await send(base, token, statement); + const head = statement.split("\n")[0].slice(0, 72); + if (outcome.type === "ok") process.stdout.write(`ok ${head}\n`); + else { + failed += 1; + process.stdout.write(`FAIL ${head}\n ${outcome.error?.message ?? "no message"}\n`); + } + } + const catalog = await send(base, token, "SELECT type, name, tbl_name, sql FROM sqlite_schema"); + for (const row of catalog.response?.result?.rows ?? []) { + process.stdout.write(`${row.map((cell) => (cell.type === "null" ? "" : (cell.value ?? ""))).join("|")}\n`); + } + if (failed > 0) process.exitCode = 1; +} diff --git a/docker/sqlite-init/build-fixture.ts b/docker/sqlite-init/build-fixture.ts new file mode 100644 index 00000000..54c92a14 --- /dev/null +++ b/docker/sqlite-init/build-fixture.ts @@ -0,0 +1,124 @@ +/** + * The SQLite object-surface fixture, read and applied (#789). + * + * Two callers and one reader. `tests/integration/db/sqlite-provider.test.ts` calls + * `readFixtureStatements()` and replays the result into an in-memory database, so the + * objects its object-surface and Source assertions reason about are created BY + * `01-object-fixture.sql`; running this file as a script replays the same statements into a + * database FILE a person can open in Studio. A fixture that only a test can apply is the + * shape standing ruling 5i forbids, and a fixture that lives only as a fenced block in a + * provider doc is the same defect one step further away from the code. + * + * Run it: + * + * bun docker/sqlite-init/build-fixture.ts # ./.sqlite-fixture/object-fixture.sqlite + * bun docker/sqlite-init/build-fixture.ts /tmp/demo.sqlite # anywhere else + * bun docker/sqlite-init/build-fixture.ts /tmp/l.sqlite 02-libsql-object-fixture.sql + * + * It is re-runnable: an existing file at the target path is removed first, together with the + * `-wal` and `-shm` sidecars a crashed session can leave beside it, so a second run produces + * a database identical to the first rather than one holding both runs' objects. + * + * TWO TRAPS THIS SPLITTER EXISTS FOR, both recorded by D53 before this file closed it. + * + * `sql.split(";")` CANNOT read this fixture. A `CREATE TRIGGER` body holds its own + * semicolons - `BEGIN UPDATE orders SET total = total; END` is one statement carrying two - + * so a naive split hands the engine `... BEGIN UPDATE orders SET total = total` and then a + * bare `END`, and the engine refuses both. A trigger statement therefore ends at the first + * `;` whose statement text already ends in `END`, which is SQLite's own rule for where a + * trigger body closes. + * + * `ANALYZE` appears in neither fixture and must not be added to either. sqld refuses it + * outright ("SQL not allowed statement"), and `02-libsql-object-fixture.sql` is applied to + * that server; on the file engine it would also add `sqlite_stat1`, a reserved-name table + * that every count in both suites is written to exclude. + */ +import { Database } from "bun:sqlite"; +import * as fs from "fs"; +import * as path from "path"; + +/** Where the two fixtures live, so a caller names a file rather than a path. */ +export const FIXTURE_DIRECTORY = import.meta.dir; + +/** The fixture the SQLite provider's own suite replays. */ +export const SQLITE_FIXTURE_FILE = "01-object-fixture.sql"; + +/** The fixture the libSQL provider doc's capture was taken from. */ +export const LIBSQL_FIXTURE_FILE = "02-libsql-object-fixture.sql"; + +/** The default target, relative to the repository root. */ +export const DEFAULT_FIXTURE_PATH = ".sqlite-fixture/object-fixture.sqlite"; + +/** + * Every statement of one fixture file, comments removed, in the file's own order. + * + * A line whose first non-blank characters are `--` is a comment and is dropped whole; there + * is no `--` inside a string literal in either fixture, and a splitter that pretended + * otherwise would be claiming a lexer it does not have. A trailing comment on a statement + * line would survive here and SQLite would drop it from `sqlite_schema.sql` anyway, which is + * measured in `docs/providers/sqlite.md`, so neither fixture writes one. + * + * THROWS when the file yields no statement at all. A fixture reader that answers an empty + * array builds an EMPTY database and every count assertion downstream then reads zero + * against zero, which passes: the caller is handed a file it cannot see is empty. The same + * throw covers a path that resolves to the wrong file. + */ +export function readFixtureStatements(file: string = SQLITE_FIXTURE_FILE): string[] { + const resolved = path.isAbsolute(file) ? file : path.join(FIXTURE_DIRECTORY, file); + const text = fs.readFileSync(resolved, "utf8"); + const statements: string[] = []; + let buffer = ""; + for (const line of text.split("\n")) { + if (line.trim().startsWith("--")) continue; + const body = line.trimEnd(); + if (!body.endsWith(";")) { + buffer += `${line}\n`; + continue; + } + const candidate = `${buffer}${body.slice(0, -1)}`; + // A trigger body carries its own `;`, so only the one after `END` closes the statement. + // Read off the TEXT rather than off a flag, because the same rule decides for a + // statement that opened and closed a body on one line. + if (/^\s*CREATE\s+(TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i.test(candidate) && !/\bEND\s*$/i.test(candidate)) { + buffer += `${line}\n`; + continue; + } + const statement = candidate.trim(); + if (statement !== "") statements.push(statement); + buffer = ""; + } + if (statements.length === 0) { + throw new Error(`${resolved} yielded no statement, so applying it would build an empty database`); + } + return statements; +} + +/** Delete a SQLite database and the two sidecars a WAL session can leave beside it. */ +export function removeDatabaseFile(file: string): void { + for (const target of [file, `${file}-wal`, `${file}-shm`]) { + try { + fs.unlinkSync(target); + } catch { + /* the file not being there is the state this function is asked for */ + } + } +} + +/** Build the fixture at `file` from `fixture`, replacing whatever is there. */ +export function buildObjectFixture(file: string, fixture: string = SQLITE_FIXTURE_FILE): void { + removeDatabaseFile(file); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const db = new Database(file, { create: true }); + try { + for (const statement of readFixtureStatements(fixture)) db.exec(statement); + } finally { + db.close(); + } +} + +if (import.meta.main) { + const target = path.resolve(process.argv[2] ?? DEFAULT_FIXTURE_PATH); + const fixture = process.argv[3] ?? SQLITE_FIXTURE_FILE; + buildObjectFixture(target, fixture); + process.stdout.write(`SQLite object fixture (${fixture}) written to ${target}\n`); +} diff --git a/docker/trino-init/01-object-fixture.sql b/docker/trino-init/01-object-fixture.sql index 3002fa66..05183e16 100644 --- a/docker/trino-init/01-object-fixture.sql +++ b/docker/trino-init/01-object-fixture.sql @@ -45,7 +45,10 @@ CREATE TABLE IF NOT EXISTS memory.app.customers ( CREATE OR REPLACE VIEW memory.app.customer_names AS SELECT id, name FROM memory.app.customers; --- Three catalog-stored functions, two of them an OVERLOADED PAIR. Standing ruling 2 (#789) +-- The catalog-stored functions the LISTING needs, two of them an OVERLOADED PAIR. This heading +-- counts nothing for the same reason the one further down does: a digit here would say how many +-- functions the block holds and would be read as how many the schema holds, and the two have +-- already drifted apart once. Standing ruling 2 (#789) -- wants the engine's own disambiguated identifier, and `plus_one` alone would give two -- objects one address: this pair is the fixture that makes the argument-type segment -- observable instead of theoretical. `label` differs in arity as well as in type, so a @@ -61,3 +64,57 @@ CREATE OR REPLACE FUNCTION memory.app.plus_one(x double) CREATE OR REPLACE FUNCTION memory.app.label(id bigint, prefix varchar) RETURNS varchar RETURN prefix || CAST(id AS varchar); + +-- EVERY FUNCTION BELOW THIS LINE exists for the source read (#789), and none of them is +-- padding: each one defeats a shortcut the read would otherwise take, and the comment above +-- each one says which. This heading deliberately counts nothing, so it cannot go stale the +-- way a digit would when the list grows. +-- +-- `SHOW CREATE FUNCTION` answers ONE ROW PER OVERLOAD and carries no `Argument Types` column +-- of its own, so the row belonging to a path segment has to be found by comparing the +-- segment's argument types against the parameter list rendered inside each CREATE statement, +-- and the two renderings are NOT the same text. Measured on 476, for `hard`: +-- +-- SHOW FUNCTIONS ... `Argument Types` decimal(10,2), array(varchar), row("a" bigint,"b" varchar) +-- SHOW CREATE FUNCTION ... parameters amount decimal(10, 2), tags array(varchar), r ROW(a bigint, b varchar) +-- +-- three differences in one signature: a space inside `decimal(10, 2)`, `ROW` in upper case +-- against `row`, and field names quoted on one side and bare on the other. A provider +-- comparing the two strings would miss every overload of a type more structured than a +-- scalar, and would then report a function that exists as absent. +CREATE OR REPLACE FUNCTION memory.app.hard(amount decimal(10,2), tags array(varchar), r row(a bigint, b varchar)) + RETURNS varchar + RETURN CAST(amount AS varchar); + +-- The EMPTY argument list, which is the boundary of that comparison: the segment is +-- `answer()` and the rendered parameter list is the empty string, so a matcher that split +-- on commas without a zero-length arm would answer one phantom argument. +CREATE OR REPLACE FUNCTION memory.app.answer() + RETURNS bigint + RETURN 42; + +-- A function whose NAME carries an open parenthesis. The path segment is `we(ird(bigint)` +-- and the CREATE statement opens `CREATE FUNCTION memory.app."we(ird"(x bigint)`, so the +-- FIRST `(` in either string belongs to the name and not to the parameter list. Both scans +-- have to be quote aware, and this object is what makes that non-vacuous rather than +-- defensive: measured on 476, the name round-trips through `SHOW FUNCTIONS` as `we(ird`. +CREATE OR REPLACE FUNCTION memory.app."we(ird"(x bigint) + RETURNS bigint + RETURN x; + +-- A ROW FIELD NAME HOLDING A CLOSE PARENTHESIS, which is the object that proves the two +-- quote-aware scans in the source read are load-bearing rather than defensive. Measured on +-- 476 on 2026-09-13, the whole battery: a top-level PARAMETER name may be quoted (`"order"` +-- for a reserved word) but may NOT hold a space, a comma or a parenthesis - all three are +-- refused at creation with a bare `Internal error` - while a ROW FIELD name may hold any of +-- them, and this one round-trips through BOTH renderings: +-- +-- SHOW FUNCTIONS ... `Argument Types` row("a)b" bigint,"c" varchar) +-- SHOW CREATE FUNCTION ... parameters r ROW("a)b" bigint, c varchar) +-- +-- so a scan for the parameter list's matching `)` that was not quote aware would stop at the +-- `)` inside the field name and read the parameter list as `r ROW("a`. Without this object in +-- the fixture, deleting that quote awareness left the whole suite green. +CREATE OR REPLACE FUNCTION memory.app.rowparen(r row("a)b" bigint, c varchar)) + RETURNS bigint + RETURN 1; diff --git a/docs/ADDING_A_PROVIDER.md b/docs/ADDING_A_PROVIDER.md index d6fc4f31..60ddc7e2 100644 --- a/docs/ADDING_A_PROVIDER.md +++ b/docs/ADDING_A_PROVIDER.md @@ -218,15 +218,91 @@ is kept in sync with its per-provider doc). Don't copy a skeleton from this guid | Embedded (in-process, no wire protocol) | `BaseDatabaseProvider` | `embedded/libredb.ts` | [libredb.md](./providers/libredb.md) | **Implement the abstract methods** from the `DatabaseProvider` interface: `connect`, `disconnect`, -`query`, the five object methods (`listContainers`, `countObjects`, `listObjects`, `describeObject`, -`describeObjects`), `getHealth`, `runMaintenance`, plus the monitoring set (`getOverview`, +`query`, the five REQUIRED object methods (`listContainers`, `countObjects`, `listObjects`, +`describeObject`, `describeObjects`), `getHealth`, `runMaintenance`, plus the monitoring set (`getOverview`, `getPerformanceMetrics`, `getSlowQueries`, `getActiveSessions`, `getTableStats`, `getIndexStats`, -`getStorageStats`). None can be omitted, but a method whose data your engine does not expose returns +`getStorageStats`). None of those can be omitted, but a method whose data your engine does not expose returns a neutral value rather than throwing. Mind the return types: the list-valued ones (`getSlowQueries`, `getActiveSessions`, `getTableStats`, `getIndexStats`, `getStorageStats`) return `[]`, while `getOverview()` and `getPerformanceMetrics()` return DTOs and need a zeroed object. `libredb.ts` is the reference for doing this honestly. +The sixth object method is the one exception to both halves of that sentence, and the next section +is about it: it is declared optional on the interface, it IS omitted by a provider whose engine +publishes no definition text, and a neutral value is the one thing it must never answer. + +### `readObjectSource`: the sixth object method (#789) + +This one is paired with a DECLARATION, which is what makes it different from the other five, and the +pairing is enforced. A kind offers a Source tab only if its `ObjectKindSpec` sets `hasSource: true`, +and a kind that sets it must also set `sourceLanguage`. Read the refusals off the declaration and +never off the kind id. + +**It is OPTIONAL, and omitting it entirely is the right answer for an engine that publishes no +definition text.** `readObjectSource?` is declared optional on `DatabaseProvider` in +`src/lib/db/types.ts` for that reason: a provider with no source-bearing kind can never reach the +method, so requiring it would put an unreachable throw in each. Two shipped providers are exactly +that case and say so in their own docs, `druid` and `libredb`. If yours is a third, declare +`hasSource` on no kind, write no method, and add your type-id to the committed ABSTAINER list in +`tests/isolated/object-source-declarations.test.ts` beside those two. Do NOT write the method +answering an empty document, an empty string or any other neutral value: the pairing fails by name +on a method with no source-bearing kind, and an empty text is a RAISE everywhere in the table below. + +- **`hasSource: true`** on each kind whose definition text your engine really publishes. A kind + whose text the engine does not hold simply does not set it, and the row then offers no View + Source at all, which is the correct answer rather than a failure to read. +- **`sourceLanguage`** is a Monaco language id, handed straight to the editor as the model's + language. Monaco does NOT raise on an id it never registered: it falls back to plain text, so a + wrong id ships a Source tab that is simply not highlighted and nothing goes red. `plsql`, `tsql` + and `cql` are not registered by the installed bundle, which is why Oracle, SQL Server and + Cassandra all declare `sql`. +- **No fallback literal in the method.** A kind that declares `hasSource` and no `sourceLanguage` + RAISES a `QueryError` naming the kind, before any round trip. Every provider that reads source + does this, and the two that once wrote `?? "sql"` and `?? "lua"` were corrected: a literal there + hides a deleted declaration behind a tab that has quietly stopped highlighting. +- **Check the path shape**, with the same function and the same sentence `describeObject` uses. The + HTTP route bounds an empty path, but the method is published through `@libredb/studio` and is + called by the embedded host seam and by the conformance helper, none of which sees the route. + +**A REFUSAL and an ABSENCE are different answers and must not arrive as one.** This is the rule the +whole surface is built on: + +| The engine… | The answer | Why | +|---|---|---| +| declined the read, and said so | a PART carrying `unavailable`, holding the engine's own sentence **unprefixed** and with no `text` | the reader needs the server's words to act on. A part is never both `unavailable` and `text` | +| holds no such object | RAISE a `QueryError` naming the object | a document invented for a dropped object is a claim the engine never made | +| answered nothing, or an empty text | RAISE | an empty text puts an empty editor over a definition nobody read, which is the shape this contract exists to make unrepresentable | +| never answered at all (a dropped socket, a timeout) | RAISE a `ConnectionError` | nobody answering is not the server answering "no", and a transport message rendered as this object's refusal is a symptom presented as a fact | + +Emptiness is sometimes absence itself: measured on Redis 8.10.0, +`FUNCTION LIST LIBRARYNAME no_such_library WITHCODE` answers an empty array rather than an error, so +whatever reads it has to treat emptiness as the absence and raise. + +**Every part carries `origin` and `form`, and both are facts about the TEXT rather than decoration:** + +- `origin` is `stored` when the bytes are the author's own, as the engine kept them, and + `regenerated` when the engine composed the statement from its catalog. Oracle's + `DBMS_METADATA.GET_DDL` is `regenerated`; SQLite's `sqlite_schema.sql` is `stored`. Say which in + the provider doc, with the measurement. +- `form` is `complete` when the text runs as given, and `body` when it is the definition's body + without the `CREATE` statement around it. A caller that pastes a `body` into an editor and runs it + gets a syntax error, so the two must not be conflated. + +**The two isolated tests a new provider must satisfy**, neither of which any provider suite can +stand in for, because both read the WHOLE fleet at once: + +- `tests/isolated/object-source-declarations.test.ts`, the census. THREE things move per new + type-id, and the third is the one a contributor misses: add one row to `SOURCE_DECLARATIONS`, + transcribed from what the engine publishes and not from your build; move the three committed + totals; and, if your engine declares no source-bearing kind, add the type-id to + `CENSUS_ABSTAINERS` as well. That list is asserted whole, so a new abstainer missing from it + fails the population assertion rather than the declaration one. The file also pins the PAIRING: a + type-id declares source-bearing kinds if and only if its built provider implements + `readObjectSource`, so a declaration with no method and a method with no declaration each fail by + name. +- `tests/isolated/monaco-language-ids.test.ts`, where every declared `sourceLanguage` is checked + against the ids the INSTALLED editor bundle registers, extracted from the bundle rather than typed. + **Override the metadata hooks** so the shared UI renders correctly: - `getCapabilities()` — query language (`sql` | `json`), `defaultPort`, supported `maintenanceOperations`, the `supportsExplain`/`supportsConnectionString`/`supportsCreateTable` flags, and `schemaRefreshPattern`. @@ -869,7 +945,7 @@ range) is still checked on its numeral only, deliberately, so that no numeral go **And the tests for every exhaustive map**, which are the real checklist — several are exhaustive *by construction* (`Record` in `db-ui-config`, `PICKER_COVERAGE` in the connection-form test), so the compiler and those tests refuse to pass until each is updated: -`tests/unit/db/factory.test.ts`, `tests/unit/lib/db-ui-config.test.ts`, +`tests/isolated/factory.test.ts`, `tests/unit/lib/db-ui-config.test.ts`, `tests/unit/lib/db-icons.test.tsx`, `tests/unit/lib/connection-string-parser.test.ts`, `tests/unit/lib/query-generators.test.ts`, `tests/unit/seed/types.test.ts`, `tests/hooks/use-connection-form.test.ts`, diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index cb299489..3d879c75 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -28,10 +28,10 @@ None of it is a GitHub issue. **Sections** - [SQL statement reading](#sql-statement-reading) — S2–S6 · 4 -- [Drivers and connections](#drivers-and-connections) — D1–D55, U17 · 16 +- [Drivers and connections](#drivers-and-connections) — D1–D69, U17 · 29 - [Value interpolation](#value-interpolation) — V1 - [Row editing](#row-editing) — R1 -- [Studio UI and query execution](#studio-ui-and-query-execution) — X2–X13, U2–U21 · 7 +- [Studio UI and query execution](#studio-ui-and-query-execution) — X2–X16, U2–U21 · 10 - [Dependencies](#dependencies) — P1–P5 · 5 - [Documentation](#documentation) — DOC3, DOC4 · 2 - [Release pipeline](#release-pipeline) — REL1–REL3 · 3 @@ -544,6 +544,12 @@ The connection's own port is read for management (`:367`) and never for the quer Measured on Couchbase CE 8.0.2 during the object-model epic's live acceptance: a node published on 38091/38093 failed while the same node on 8091/8093 worked. +Reproduced on a second port pair on 2026-09-13, against Couchbase Server 8.0.2 Community published on +host ports 18091/18093 (#789). `connect()` succeeds over the management port, and the first +query-service call fails with `Couchbase request failed: Unable to connect. Is the computer able to +access the url?`, because the node map advertises 8093 and nothing listens there on the host. +Publishing 8091/8093 makes the same provider work unchanged, which is the control. + Couchbase's own answer to this is `alternateAddresses.external`, which the transport already prefers when the cluster publishes it (`:473-477`), so an operator-configured cluster is fine today. What is not handled is the ordinary developer case of a stock image published on other ports, where nothing @@ -556,20 +562,6 @@ is a major already carrying seventeen providers. mapping, with the precedence between the node map, the external addresses and the user's own port stated where a reader meets it. -### D53. The libSQL object fixture exists only as prose - -Every other engine's object fixture is a file under `docker/*-init/` mounted by -`database-compose.yml`. libSQL's is a fenced SQL block in `docs/providers/libsql.md:584`, so it is -applied by hand and cannot drift-check against the suite that depends on it. Standing ruling 5i in -the object-model epic says a fixture is a deliverable rather than scaffolding, and this one is the -exception nobody chose. - -Applying it also needs a client that does not split `CREATE TRIGGER ... BEGIN ... END` on the -semicolon, which is a real trap for anyone reproducing the suite and is currently unwritten. - -**Done when:** the libSQL fixture is a file applied the way the other sixteen are, or the doc says -why it cannot be and how to apply it safely. - ### D54. The data profiler can only profile columns on PostgreSQL-family engines `src/app/api/db/profile/route.ts:115-116` casts every column with `${safeCol}::text` to take @@ -603,6 +595,336 @@ list it lands on still shows a bare name. **Done when:** a row in that list is identifiable without relying on what marked it. +### D56. A Druid lookup's JSON definition is unreachable from the one URL a connection carries + +Fifteen of the seventeen shipped type-ids read object source under #789, measured by the census in +`tests/isolated/object-source-declarations.test.ts`; druid and libredb are the two that read none. +Two of Druid's three kinds have nothing to read: a datasource and a system table were never written +down as a statement, measured from the parser's own refusal, which enumerates every statement it +expected and includes no form of `CREATE`. The third is different. A `lookup` IS authored, as a JSON +spec, and `GET /druid/coordinator/v1/lookups/config/{tier}/{id}` answers that spec back. Nothing in +this product can ask for it. + +What SQL answers instead is the lookup's key and value PAIRS (`SELECT * FROM lookup.`, columns +`k` and `v`, measured on Apache Druid 37.0.0). Those are its content. The spec's type (`map` versus +`cachedNamespace`), its polling period and the namespace it extracts from appear nowhere in SQL, so +rendering the pairs under a caption that says "definition" would show a user something that is not +the definition. + +Three things make this a transport change rather than a source read: + +- `src/lib/db/providers/sql/druid/transport.ts` publishes exactly two members, `query(sql, opts)` + and `close()`. `query` takes a SQL string, so no member can address any other path on the cluster. +- `tests/unit/db/druid/seam-guard.test.ts` parses every file in the provider directory and fails the + build when a bare `fetch` or an endpoint path appears outside `http-transport.ts`, so provider + logic cannot reach around the seam either. +- A connection carries ONE host and ONE port. A Broker-only deployment serves the SQL endpoint and no + Coordinator API at all, and a Router serves it only when `druid.router.managementProxy.enabled` is + set, which `database-compose.yml` sets for this repository's own cluster and a production + deployment need not. So the read has to be able to come back empty-handed for a reason about the + DEPLOYMENT rather than about the object, which needs a refusal sentence this provider does not + have. + +Two further things anyone taking this on has to settle before writing code, both open: + +- The endpoint above is DOCUMENTED against the Druid 37.0.0 API reference and was NOT measured + against a cluster here, so measuring it is step one. +- Which tier to ask for. The path takes a tier, `__default` being the usual one, and a Router-only + deployment gives no list of tiers to a caller who has not already reached the Coordinator. Whether + to enumerate tiers first, or to ask `__default` and refuse by name, is the design question. +- Writing back is not symmetrical with reading. Posting a lookup spec requires its `version` field to + be BUMPED, so #778's edit half cannot round-trip a read spec unchanged, and the version handling is + part of the work rather than a detail after it. + +**Done when:** a Druid lookup shows its own JSON spec, or the object surface says in the engine's own +terms why this deployment cannot reach it. + +### D57. MariaDB's `package` and `sequence` folders are never drawn in the standalone tree + +`POST /api/db/provider-meta` reads `getCapabilities()` off a provider it never connects +(`src/app/api/db/provider-meta/route.ts:44`, #457), and `MySQLProvider.objectKinds` is the one +declaration in the fleet resolved from the server's own `VERSION()` string, so an unconnected +provider answers the MySQL six and the client's copy of the declaration never gains MariaDB's two. +The tree draws its folders from that copy (`src/components/object-tree/flatten.ts`), so the two kinds +have no folder and their source cannot be reached from the tree. + +Both kinds are fully implemented behind the API: a connected provider counts, lists, describes and, +since #789, reads the source of both. Only the client's copy is stale. + +The smallest correct fix reads the connected provider out of the factory cache and re-reads +`provider-meta` once the connection is warm, about ten lines. A `peekConnectedProvider(connectionId)` +on `factory.ts` that returns the already-connected instance opens no socket and keeps +`tests/unit/db-tunnel-discipline.test.ts` green, measured. The design question inside it is WHEN to +re-read: an unconditional re-read costs a round trip on all seventeen engines and changes the +capabilities object identity, invalidating every memo keyed on it. + +Two limits measured while writing this. It is NOT fleet-wide: `ProviderCapabilities` has exactly +three connection-resolved values, `objectKinds`, `supportsExplain` and `explainFormat`, so a correct +fix also changes when the EXPLAIN affordance is offered on PostgreSQL and the four MySQL-wire +relatives, and that is a behaviour change rather than a repair. And the embedded half cannot be +closed the same way, because a host declares its own capabilities to `StudioWorkspace`, so closing it +there is a published-surface change. + +**Done when:** a MariaDB connection draws its Packages and Sequences folders in both shells, or the +provider doc says which surface cannot have them and why. + +### D58. A ClickHouse function with a non-SQL origin has never been read live + +The source read's refusal arm for `ExecutableUserDefined` and `WasmUserDefined` is driven in the +suite by a server answering an empty `create_query`, and killed by mutation, but no such function has +ever existed on the fixture. Creating one needs a `*_function.xml` in the server configuration +directory beside the script it runs, and `database-compose.yml` mounts neither directory. + +What is owed once the compose file is free to change: add a `*_function.xml` mount and a script +directory to the clickhouse service, create one executable function in `docker/clickhouse-init/`, and +read it back through the real provider to confirm the server answers an empty `create_query` and an +`origin` of `ExecutableUserDefined`, which is what the refusal sentence claims. + +Cost if wrong: the sentence names an origin the server does not report that way, and a reader is told +a body is an external program on a server that spells the absence differently. The Enum8 vocabulary +is measured (`Enum8('System' = 0, 'SQLUserDefined' = 1, 'ExecutableUserDefined' = 2, 'WasmUserDefined' += 3)`), so only the empty-`create_query` half is unmeasured. + +**Done when:** one executable function exists in the fixture and its refusal is read back from a +running server rather than from a double. + +### D59. A Trino materialized view has no fixture here, and the cheap route is measured shut + +`docker/trino-init/01-object-fixture.sql` seeds no materialized view, so the one object kind whose +source read this repository cannot reproduce is `trino.materialized_view`. The read IS implemented +and IS tested, against a payload captured from a live cluster, but the cluster that produced it is +not one `database-compose.yml` can start. + +THE CHEAP ROUTE WAS PROBED AND REFUSED, and the measurement is the point of this entry, so the next +attempt starts from a fact rather than from the same hope. Measured 2026-09-13 on trinodb/trino:476 +with an Iceberg JDBC catalog on PostgreSQL 18: + +- The JDBC catalog WORKS. `CREATE SCHEMA` answered `CREATE SCHEMA`, `CREATE TABLE + iceberg.warehouse.orders (id bigint, total double)` answered `CREATE TABLE`, and `INSERT INTO + iceberg.warehouse.orders VALUES (1, 10.0), (2, 20.0)` answered `INSERT: 2 rows`. +- `CREATE MATERIALIZED VIEW iceberg.warehouse.order_totals AS SELECT id, total FROM + iceberg.warehouse.orders` answered `createMaterializedView is not supported for Iceberg JDBC + catalogs`. +- Two traps on the way: Trino 476 never creates the JDBC catalog's own `iceberg_tables`, so every + statement fails `Cannot check and eventually update SQL schema` until the two Iceberg V1 tables are + created by hand; and a `file://` warehouse needs `fs.hadoop.enabled=true`, where + `fs.native-local.enabled` plus `local.location` refuses to START the coordinator with `Invalid + configuration property local.location: file does not exist: file:/data/warehouse` for a directory + that exists and is writable inside the container. +- The materialized view WAS then created on an `apache/hive:4.0.1` standalone metastore, which is + what produced the measured `Create Materialized View` reply column. + +So the remaining price is a metastore service and a warehouse volume in `database-compose.yml`, and +the decision to pay it is a compose-file decision rather than an object-surface one. +`docs/providers/trino.md` carries the full command set meanwhile. + +**Done when:** `database-compose.yml` starts a cluster on which the shipped fixture creates a +materialized view, or the provider doc is accepted as the permanent home of those commands. + +### D60. `countObjects` reports a MongoDB transport failure as the engine's own refusal + +`src/lib/db/providers/document/mongodb.ts` `countObjects` catches every `listCollections` rejection +and answers `{ unavailable: }` for every declared kind. + +Measured against mongodb 7.6.0 and MongoDB 8.2.12: only a `MongoServerError` is the server's own +error reply. A `MongoServerSelectionError` ("connect ECONNREFUSED ...") or a `MongoNotConnectedError` +("Client must be connected before running operations") is a transport failure the server never +answered, and the tree then badges a folder with a socket message as though MongoDB had refused the +read. + +#789 fixed this for `readObjectSource` only (`isServerErrorReply` in the same file), because +`KindCount`'s `unavailable` arm is a contract shared by the whole fleet and one provider moving alone +would make the fleet inconsistent. + +The decision to take is whether `KindCount.unavailable` means "the engine refused" fleet-wide, in +which case every provider's count catch needs the same discrimination and a transport failure should +raise. + +**Done when:** a test per provider drives a transport-shaped rejection through `countObjects` and +asserts it raises rather than badging, and the same for `listObjects`. + +### D61. Elasticsearch and OpenSearch object source re-serialises the cluster's JSON, so three values are re-spelled + +`readObjectSource` renders a pipeline's or a template's definition with `JSON.parse` followed by +`JSON.stringify`, because the definition is a sub-document of the endpoint's answer and there is no +extended-JSON writer for a REST payload. + +Measured on Elasticsearch 9.1.4 and OpenSearch 3.8.0 on 2026-09-13 against `probe_json_edges` in +`docker/search-init/01-object-fixture.sh`: the cluster answers `9223372036854775807` and the pane +shows `9223372036854776000`, `1.0E30` becomes `1e+30`, and a map keyed `zz, 10, 2, aa` is rendered +`2, 10, zz, aa`. + +Nothing is dropped, so `form: "complete"` is true, and both provider docs record all three under +"Object source (#789)". A faithful rendering would need the sub-document sliced out of the response +TEXT rather than re-serialised, which is a small JSON scanner nobody owns today. + +It matters for #778 Phase 3: a definition holding a long past 2^53 must not be edited and PUT back +from the pane. + +**Done when:** either the pane shows the cluster's own bytes, or the edit half is refused on a +definition whose re-serialisation is not byte-identical to what was read. + +### D62. Two PostgreSQL source refusals are unverified on CockroachDB and Materialize + +`PostgresProvider.readObjectSource` reports exactly two SQLSTATEs as a refusal part, 42883 (`pg_get_*` +absent) and 42703 (`pg_proc.prokind` absent), and both arms exist because this type id also serves +CockroachDB and Materialize. + +The sentences in `tests/integration/db/postgres-provider.test.ts` are the SHAPE PostgreSQL 18.4 +answers for a missing function and a missing column, measured; neither fork was brought up. The +provider carries no string of its own, so a wording difference cannot break it, and what is +unverified is only the claim that those two SQLSTATEs are what a fork answers there. + +**Done when:** each fork is brought up, a view's and a routine's source is asked for through the +shipped statements, and the SQLSTATE and the sentence are recorded in `docs/providers/postgres.md`. +If either answers a third code, that arm is a code change and not a doc change. + +### D63. `postgres.ts`'s `describeObject` still binds `[path[0], path[1]]` + +Standing ruling 5g's second spelling, in `describeObject` in +`src/lib/db/providers/sql/postgres.ts`. It is behaviour-identical at depth 1 and silently wrong at +depth 2, and the source read does not use it. The object-model epic assigned it to a final sweep +rather than to the task that found it, so it is recorded here rather than left in a work file. + +**Done when:** the name is `path[path.length - 1]` and the container is +`path.slice(0, containerDepth(capabilities))`, plus the two-level `spyOn` test driven all the way to +the binds, the way `readObjectSource` is already pinned. + +### D64. The PostgreSQL trigger LISTING join is unpinned, so the tree could list no trigger at all + +`LIST_TRIGGERS_SQL` in `src/lib/db/providers/sql/postgres.ts` joins +`pg_catalog.pg_class c ON c.oid = t.tgrelid`, which is what makes a trigger's row name its base table. +Mutating that one column to `tgconstrrelid` leaves the whole suite green. + +Measured 2026-09-13: with the mutation applied, `bun test tests/integration/db/postgres-provider.test.ts` +is 205 pass 0 fail, identical to the unmutated control. `tgconstrrelid` is 0 for every ordinary +trigger, so a real server would join nothing and the Triggers folder would list nothing, while the +count beside it kept counting. Nothing in the tree would say so. + +The SOURCE statement added by #789 IS pinned as text at +`tests/integration/db/postgres-provider.test.ts:4393`; this is the Phase 1 LISTING statement beside +it, which is not. + +**Done when:** the listing statement is pinned as text the way the source statement is, and the +mutation above fails by name. + +### D65. A provider suite whose double dispatches on the statement the test builds cannot see the statement change + +Five mutants of one class survived the PostgreSQL suite until its first fix round: three predicate +deletions, one relkind swap and one pretty flag. All of them are edits to statement TEXT that leave +the binds untouched, and all are invisible to a double that routes by the `pg_get_*` function name +the test itself constructed. + +#789 closed this for the SOURCE statements: each provider task pinned its own source statement as +text and reported its mutation numbers. The Phase 1 listing and counting statements across the fleet +were not swept the same way, and D64 is the one instance that has been measured. + +**Done when:** every provider's listing and counting statements are pinned as text, one assertion per +statement, with the mutation numbers recorded rather than a sample of them. + +### D66. The SQLite kind-vocabulary guard scrapes source text, so a kind can be declared and unmapped + +The guard in `tests/unit/lib/agent/context-snapshot.test.ts` scrapes `SQLITE_OBJECT_KINDS` with a +`{ id: "..."` regex that only matches a SINGLE-LINE entry, so a kind written across two lines drops +out of the population the guard compares. + +Measured 2026-09-13, both directions. Exploding an EXISTING entry past 120 columns is a LOUD red: two +declared ids against the agent side's four, 1 fail, "Expected - 0 / Received + 2", with `table` and +`trigger` unmatched. So that half is safe. But adding a FIFTH kind as a multi-line entry drops it from +the guard's population AND it is absent from `COMPOSED_KIND_WORDS`, both sides shrink together, and +the guard passes at 1 pass 0 fail, while the same kind written on one line fails. + +So the defect is a kind that is declared and unmapped, not a formatter. The provider keeps its entries +on one line so they stay scrapable and says so in a comment. + +**Done when:** the guard reads the declaration through +`createDatabaseProvider("sqlite").getCapabilities()` instead of scraping source text, and a +multi-line entry for an unmapped kind fails it. + +### D67. `assertObjectPathShape` is written out eight times, and four more shapes twice or three times + +Measured in the tree on 2026-09-13: `assertObjectPathShape` is DEFINED, not imported, in eight +provider files (`postgres.ts`, `mysql.ts`, `oracle.ts`, `sqlite.ts`, `libsql/objects.ts`, +`clickhouse/objects.ts`, `cassandra/objects.ts`, `document/mongodb.ts`). It belongs beside +`containerDepth` in `src/lib/db/object-kinds.ts`, which is where `comparePaths` already went: that +one was written four times, was hoisted to `src/lib/db/object-path.ts`, and is now imported by every +caller, so the pattern is settled and only this helper is left behind. + +Four more shapes are duplicated verbatim by the source reads: + +- `OBJECT_SOURCE_SQL` and `SOURCE_CATALOG_TYPES`, twice, in `sqlite.ts` and `libsql/objects.ts`. +- `blankDefinitionShape` and `blankDefinitionReason`, three times, in those two plus + `duckdb/objects.ts`. The last two carry three sentences each, so there are copies of six sentences + that must not drift, and only the duckdb pair is exported. + +libSQL IS SQLite and the two source reads are the same statement against two transports, which is why +that pair is worth taking first. + +None was hoisted when it was found because concurrent implementers held the checkout and a hoist +collides with every one of them. + +**Done when:** one definition of each replaces the copies, with the sqlite and libsql source read +sharing its statement. + +### D68. `bun run test` is red on a shared process, and only CI's per-file isolation hides it + +`bun run test` is the pre-commit command CLAUDE.md documents, and it runs +`bun test tests/unit tests/api tests/integration` in ONE bun process. `mock.module()` is +process-wide, so a mock one layer needs reaches every file in that process. CI runs +`tests/run-core.sh` instead, one process per file, and is blind to the whole class by +construction. + +Measured 2026-09-13, and the same numbers at `acf50738` and on the #789 branch, so it predates +that epic: every file under `tests/api/` mocks `@/lib/auth` with stubbed `signJWT`, `verifyJWT`, +`getSession`, `login` and `logout`, which is that layer's standard pattern. Run +`tests/unit/lib/auth.test.ts`, `tests/unit/lib/auth-jwt-config.test.ts` and +`tests/unit/seed/resolve-connection.test.ts` beside `tests/api/db-objects.test.ts` and the four +files together are 31 fail; each of them alone is 0 fail. + +The cost is not a red gate, because no gate runs that shape. It is that a contributor following +CLAUDE.md sees 31 failures on a clean checkout and cannot tell them from their own. + +#789 removed its own three instances by moving the files with the unshareable assumption into +`tests/isolated/`, where `tests/run-components.sh` gives each a process and +`tests/unit/component-runner-coverage.test.ts` makes an unregistered one a red test. The same +remedy does not fit here: it is not three files but a whole layer's mocking pattern against three +unit files that legitimately want the real module. `docs/TOOLCHAIN.md` carries the diagnosis. + +**Done when:** `bun run test` on a clean checkout is green, either because the auth mocking pattern +stops reaching `tests/unit`, or because the documented command runs the same isolation CI does. + +### D69. Six type-ids still open `readObjectSource` with their own entry guard, and one of its sentences is less true + +`requireSourceKind` in `src/lib/db/object-kinds.ts` is the one entry guard for `readObjectSource`: +it raises separately for a kind the engine never declared, for a declared kind that publishes no +definition text, and for a source-bearing kind carrying no `sourceLanguage`. Measured on 2026-09-13, +nine providers call it (sqlite, libsql, duckdb, clickhouse, cassandra, postgres, mssql, mysql, +trino) and six type-ids do not: couchbase, mongodb, redis, elasticsearch and opensearch (one shared +module) and oracle. + +All five of those modules COLLAPSE the first two facts into one throw. They test +`spec?.hasSource !== true` and answer ` declares no readable source for the kind "X"`, so a +kind the engine has never heard of and a declared kind with no definition text arrive as the same +sentence. That sentence is not merely shorter, it is less true: it tells the caller the kind exists +and has no source. Three of them (couchbase, mongodb, search) also spell the third arm differently, +"declares source for the kind X and no sourceLanguage, so its text has no language to render in" +rather than "declares readable source for the kind X and no sourceLanguage to render it with". + +A fourth spelling of the same refusal lives at the route layer: `src/lib/api/object-route.ts` raises +` declares no readable source for kind "X"` as an `ObjectRouteError` with a 400, before any +provider is consulted. It guards a different fact and answers a different error type, so it is not +simply a call site, but it is a fourth wording of one refusal. + +None of the six was converted when the guard was hoisted (#789), because converting them rewords +between one and two throws each, five provider suites assert on the exact wording, and a reworded +throw is a behaviour change that does not belong folded inside a refactor. The cost of leaving them +is that the hoist's second-order gain, that a new provider cannot silently forget one of the three +guards, holds for nine of seventeen type-ids only. + +**Done when:** the six call `requireSourceKind`, with the five provider suites' assertions moved onto +the guard's three sentences in the same commit, and the route layer either reuses one of those +sentences or its docblock says why a 400 raised before the provider is a different fact. + ## Value interpolation ### V1. Query history records the placeholders, not the values that were bound @@ -646,8 +968,11 @@ goes away in a major, with this work. ## Studio UI and query execution -`U2` came out of the #384 review. The `X` entries came out of the #422 export review — each was -named, weighed and left out of that PR, so they are recorded rather than re-derived. +`U2` came out of the #384 review. `X2` to `X13` came out of the #422 export review: each was +named, weighed and left out of that PR, so they are recorded rather than re-derived. `X14` and `X15` +came out of the #789 object-source design's own measurement passes: both are pre-existing, neither +is in the seam that epic touches, and both were re-measured against the tree before being written +here. ### X2. An export writes the page the grid holds, not the result the user asked for @@ -749,6 +1074,106 @@ derived groupings, read beside the engine-wide flag the way `kindAcceptsRowWrite `libredb.ts`'s `tablesAreDerivedGroupings` site and in `docs/providers/libredb.md`. Either way LibreDB's `table` and `collection` stop being refused by a flag that was never about them. +### X14. The workspace write that persists every tab has no quota guard + +`src/hooks/use-tab-manager.ts:213` writes the whole workspace with +`storage.setItem(workspaceKey, JSON.stringify(serialized))` inside a 500 ms `setTimeout`, with no +`try`/`catch` anywhere between the timer callback and the call. Every other localStorage writer in +this application already has one: `src/lib/storage/local-storage.ts:64` and `:82` both wrap their +`setItem`, log `Failed to write to localStorage` and answer `false`, so the guard is a pattern this +writer skipped rather than a pattern nobody has. + +The quota it writes against is shared. `STORAGE_COLLECTIONS` (`src/lib/storage/types.ts:28-38`) is +ten collections, connections and history and the audit log among them, and all of them plus this +record live inside one origin quota of about 5 MiB. The record itself is unbounded from the shell's +point of view because `PersistedTabState.query` copies each tab's editor text verbatim. + +The symptom is not a lost tab. A `QuotaExceededError` thrown inside a timer callback is not caught +by React and not caught here, so it reaches the window's error handler, tab persistence stops for +the WHOLE workspace, and nothing tells the user; the next tab change schedules the same timer and +throws again. Found while designing #789 and not fixed there, because Phase 2 touches this record +only to add one address-only field: a Source tab persists its `path` and `kind` and never one +character of the definition it read, for exactly this reason, which narrows the exposure and closes +nothing. The reasoning is in the `PersistedTabState` docblock at `use-tab-manager.ts:40-60`. + +**Done when:** the write is guarded the way `local-storage.ts` guards its own, and the failure is +observable rather than swallowed - a user whose workspace has stopped persisting is told, since a +silent `false` here means the tabs on screen are no longer the tabs that will come back. + +### X15. The studio tab bar is half the WAI-ARIA tabs pattern + +`StudioTabBar.tsx` has the tab half and none of the panel half. Measured 2026-09-13: `:98` is +`role="tablist"` with `aria-label="Editor tabs"`, `:150-153` gives every tab `role="tab"`, +`aria-selected` and a roving `tabIndex`, and `:72-79` implements Arrow, Home and End activation. No +tab carries `aria-controls`, and no element in either shell carries `role="tabpanel"`: the region +the tabs actually govern is the bare `
` at +`src/components/Studio.tsx:777` and at `src/workspace/StudioWorkspace.tsx:494`. + +So a screen reader announces the tab and its selected state and can never say which region the tab +governs, and there is no way to move from a tab to its content. + +The basis for the "nowhere in `src/`" form of this claim has moved and the entry says so rather than +repeating it: `grep -rn 'tabpanel' src/` now returns exactly one hit, +`src/components/object-source/ObjectSourceView.tsx:347`, which is the Source view's own part +switcher added by #789. The pattern is bare on purpose: that role is written as an object property, +`{ role: "tabpanel", ... }`, and never as a JSX attribute, so grepping the attribute form matches +nothing, which would read as an absence that is not there. The switcher is the complete pattern, +including the rule the studio bar will need: only the SELECTED tab may carry `aria-controls`, +because only the active panel is in the tree and a reference to an absent element is an +`aria-valid-attr-value` violation of its own. + +It is not a one-line fix, which is why it is here. The panel is ONE element shared by every tab, so +its `id` has to key on `activeTabId`, and the same element is the mount point for the schema diagram +overlay, which is not the tab's content at all. Both shells render the bar, so the fix lands twice +and is verified twice. + +**Done when:** the editor region carries `role="tabpanel"`, an id derived from `activeTabId` and +`aria-labelledby` naming the selected tab, the selected tab alone carries the matching +`aria-controls`, and both shells are checked, since a UI change verified in one is not verified in +the other. + +### X16. Opened at `127.0.0.1`, the dev server serves a page that never becomes interactive + +MEASURED on 2026-09-13 against Next.js 16.3.4 with Turbopack, in two independent browsers +(Playwright's Chromium and Chrome over CDP), while doing the browser QA for #789. + +`bun dev` prints `http://localhost:`. Open the SAME server at `http://127.0.0.1:` +instead and the page renders its server HTML and then does nothing at all: no button responds, the +login form submits natively to `/login?` and clears itself, and `POST /api/auth/login` is never +made. `Object.keys(document.querySelector('#email'))` carries no `__react*` key, so React never +hydrated. The only console output is one repeated +`WebSocket connection to 'ws://127.0.0.1:/_next/hmr' failed: Error during WebSocket +handshake: net::ERR_INVALID_HTTP_RESPONSE`. + +THE CAUSE IS THE DEV SERVER'S OWN ORIGIN CHECK ON THAT SOCKET, isolated with a control rather than +inferred. The same upgrade request, differing only in one header, run from the shell: + +| Request to `/_next/hmr` | Answer | +| --- | --- | +| no `Origin` header | `HTTP/1.1 101 Switching Protocols` | +| `Origin: http://localhost:` | `HTTP/1.1 101 Switching Protocols` | +| `Origin: http://127.0.0.1:` | the connection is closed with no HTTP response at all | +| `Origin: http://192.168.1.66:` | the connection is closed with no HTTP response at all | + +That empty answer is what the browser reports as `ERR_INVALID_HTTP_RESPONSE`, and the dev client's +bootstrap does not survive it. The chain closes both ways: served by the same process at the same +moment, `http://localhost:/login` hydrates and `http://127.0.0.1:/login` does not. + +Next 16 has a configuration key for exactly this and this repository sets none: +`grep -rn 'allowedDevOrigins' src/ next.config.ts` returns nothing. The production path is +unaffected, measured: `bun run build` plus `bun run start` hydrates at `127.0.0.1` and every part of +#789's browser pass ran there. + +It is filed rather than fixed because the value is a decision rather than a typo. The key names the +origins a developer's browser may drive the dev server from, so widening it widens a control Next +added deliberately, and `127.0.0.1` and a LAN address are not the same call. The cost of leaving it +is a developer who types the loopback address, or opens the LAN URL `bun dev` also prints, meeting a +dead page with one obscure console line. + +**Done when:** `bun dev` opened at `127.0.0.1` and at the LAN address the banner prints is +interactive, either by configuring `allowedDevOrigins` or by not printing a URL that does not work, +and a note in `docs/TOOLCHAIN.md` records which and why. + --- ### U2. The rule that catches an arity change on a JSX handler is configured but not aimed at components diff --git a/docs/TOOLCHAIN.md b/docs/TOOLCHAIN.md index e920a074..ee5f1a92 100644 --- a/docs/TOOLCHAIN.md +++ b/docs/TOOLCHAIN.md @@ -225,6 +225,69 @@ check (`bun run format`), linters (`bun run lint`, i.e. oxlint then ESLint), typ hook (`.claude/settings.json`) runs `lint && typecheck && test && build` and now transitively enforces oxlint and the type-aware layer via `bun run lint`. +### `bun run test` and the process-wide `mock.module()`, and where isolation has to sit + +`bun test` runs many files in ONE process and `mock.module()` is process-wide, which is why +`tests/run-core.sh` gives every core file its own process and `tests/run-components.sh` groups the +component files. CI runs both, so CI is structurally blind to a file that only passes when it loads +a module first. `bun run test` is not: it is the command CLAUDE.md documents for pre-commit, it runs +`bun test tests/unit tests/api tests/integration` in one process, and a contributor reads its +failures as their own. + +That is not a reason to accept a red developer command. A test file that breaks it is a defect +whether or not a gate notices, and the repair is to move the file whose assumption is unshareable +rather than to bend the files around it. + +Two instances measured in #789, and the rule they establish. + +The first is a file that must load a module before anything else does: + +- `tests/isolated/factory.test.ts` mocks six native driver packages and `@/lib/ssh/tunnel`, then + imports `@/lib/db/factory` under a temporary `NODE_ENV=production` so the SIGTERM and SIGINT + handlers the module registers on load can be captured by diffing `process.listeners`. Both steps + happen once per process. +- So it passes only while it is the FIRST file in its process to evaluate `@/lib/db/factory`. Any + earlier evaluation leaves it with an already-built module: no handler to capture, and unmocked + drivers behind `getOrCreateProvider`, whose cached entry throws inside the `clearProviderCache()` + in `beforeEach` and fails every remaining test in the file. +- Measured 2026-09-13: the file alone is 99 pass 0 fail; adding a three-line probe under + `tests/unit/` whose only content is an import of `@/lib/db/factory` makes it 44 pass 56 fail. A + probe importing `@/lib/ssh/tunnel` or `@/lib/db/compatibility` instead reproduces nothing, and an + empty probe reproduces nothing. Both CLI orders give the same 56, because bun does not run test + files in the order they are listed. +- It used to hold by accident: nothing else under `tests/unit` imported the factory. + `tests/isolated/exports-shim.test.ts` had already been moved out for the same reason, and its + group comment names this file by name. #789 added two `tests/unit` files that construct all + seventeen providers through the real factory, and a fleet census cannot do its job without + importing it, so the accident ran out. +- The file therefore moved from `tests/unit/db/factory.test.ts` to `tests/isolated/factory.test.ts` + with its own group in `tests/run-components.sh`. `tests/unit/component-runner-coverage.test.ts` + makes an unregistered file in `tests/isolated/` a red test, so the isolation cannot be forgotten. + +The second is the mirror image: a file that must read a module the rest of a layer replaces. + +- `tests/isolated/object-source-declarations.test.ts` censuses what all seventeen providers + declare, and `tests/isolated/monaco-language-ids.test.ts` checks every declared source language + against the ids the installed Monaco registers. Both build providers through the REAL + `createDatabaseProvider`, which is the point: a census that read a double would certify the + double. +- Every file under `tests/api/` mocks `@/lib/db` with a `createDatabaseProvider: mock()` that + answers undefined, which is that layer's standard pattern, and the mock reaches + `@/lib/db/factory` through the index re-export. Measured 2026-09-13: the census beside + `tests/api/db-objects.test.ts` is 3 fail, the language guard beside it is 1 fail, and each of + them alone is 0 fail. +- Nothing either file can do prevents that, so both moved to `tests/isolated/` with a shared group. + +A pre-existing instance of the same class is NOT fixed and is filed as `docs/BACKLOG.md` D68: the +same `tests/api/` mocks of `@/lib/auth` take `tests/unit/lib/auth.test.ts`, +`tests/unit/lib/auth-jwt-config.test.ts` and `tests/unit/seed/resolve-connection.test.ts` from 0 to +31 failures in a shared process. Measured identical at `acf50738` and on the #789 branch, so it +predates the epic. + +The rule: when a test file can only pass while it is the first to load some module, it belongs in +`tests/isolated/` with a group of its own and a docblock saying which module and what the failure +looks like. Do not push the constraint outward onto every file that might legitimately import it. + ### Dependency installation in CI Every workflow job installs dependencies through the local composite action diff --git a/docs/providers/cassandra.md b/docs/providers/cassandra.md index 8d951d5a..16d3eede 100644 --- a/docs/providers/cassandra.md +++ b/docs/providers/cassandra.md @@ -771,8 +771,10 @@ docker exec libredb-cassandra cqlsh -e \ `nodetool reloadtriggers` is load-bearing: without it the `CREATE TRIGGER` answers `Trigger class 'probe.NoopTrigger' couldn't be loaded` (measured, and measured again after the reload, where it succeeds). None of that makes a trigger a thing the engine does not have; it makes it a thing an -operator installs. Withholding the folder would hide an object somebody created. Phase 1 shows names -rather than bodies anyway, so no kind here declares `hasSource`. +operator installs. Withholding the folder would hide an object somebody created. +`trigger` is the one kind here that declares no `hasSource`, and section 12.2 records which absence +that is: the engine publishes no such text at all, because `DescribeStatement` has no TRIGGER target +and a trigger's body is a Java class on the node's filesystem. Six kinds do declare it. **So a clean apply of the fixture leaves `trigger: 0`, and that is the correct reading, not a defect.** The block above is the only step in this fixture that needs a JDK on the machine applying @@ -1411,3 +1413,257 @@ two counts against Cassandra's full set, and `full` in this table means every su every surface returned. Not `query-only` either, because the object browser, the column metadata and the index metadata all work — which is what separates this from Materialize and RisingWave, which have none of it. + +--- + +## 12. Object source (#789) + +This is the one engine in the object-source fleet whose definition text is **not in a catalog column +anybody can select**. `DESCRIBE` is a real server-side statement: the server executes it and answers +ordinary rows carrying `keyspace_name`, `type`, `name` and `create_statement`. Everything below was +measured on 2026-09-13 against a live `cassandra:5.0.9` through `cassandra-driver` 4.9.0, holding +[`docker/cassandra-init/01-object-fixture.cql`](../../docker/cassandra-init/01-object-fixture.cql) +applied through the mount with the trigger step that file documents. + +### 12.1 Which kinds declare source, and what the text is + +| Kind | Statement | `form` | `origin` | Monaco id | +|---|---|---|---|---| +| `table` | `DESCRIBE TABLE "".""` | `complete` | `regenerated` | `sql` | +| `materialized_view` | `DESCRIBE MATERIALIZED VIEW "".""` | `complete` | `regenerated` | `sql` | +| `index` | `DESCRIBE INDEX "".""` | `complete` | `regenerated` | `sql` | +| `type` | `DESCRIBE TYPE "".""` | `complete` | `regenerated` | `sql` | +| `function` | `DESCRIBE FUNCTION "".""` | `complete` | `regenerated` | `sql` | +| `aggregate` | `DESCRIBE AGGREGATE "".""` | `complete` | `regenerated` | `sql` | +| `trigger` | **none** | | | | + +`origin` is `regenerated` and not `stored`, measured rather than assumed: the fixture writes +`CREATE TABLE probe.customers (id, name, city, home, tags)` and the server answers the partition key +first and then the remaining columns alphabetically, with all twenty table options spelled out. +Nothing in the database holds the author's own bytes. + +**`cql` IS NOT A MONACO LANGUAGE ID**, and every row above says `sql` because of it. The installed +monaco-editor 0.56.0 bundle registers 89 ids and `cql` is not among them; an unregistered id degrades +to plain text with no throw and nothing observable. `sql` is the closest registered dialect, so a +`CREATE TABLE` renders correctly and the CQL-only spellings (`PRIMARY KEY ((a), b)`, +`frozen
`, a `$$ ... $$` function body) are highlighted as whatever the SQL tokenizer makes +of them. This is a limitation and not a claim that the text is highlighted as CQL. + +**A function's body is not CQL and the part does not pretend otherwise.** `DESCRIBE FUNCTION` answers +a CQL envelope wrapping the body verbatim between `$$` markers, and the body's language comes from +the catalog (`java` on 5.0). The part's `language` is the kind's declared `sql` because the part **is +the envelope**, not the body. A Java body inside it is highlighted as SQL, which is wrong, and there +is no second part to put it in without claiming a split the engine does not make. + +### 12.2 `trigger` declares nothing, and it is a result rather than a gap + +Of the two facts a kind declaring nothing can carry, the engine publishes no such text at all, or it +publishes it somewhere this product does not reach, a Cassandra trigger is squarely **the first**. + +`DescribeStatement` has no `TRIGGER` target. Measured: + +``` +DESCRIBE TRIGGER probe.probe_audit + -> line 1:17 no viable alternative at input 'probe' (DESCRIBE [TRIGGER] probe...) +``` + +And there is nothing behind that absence to reach. `system_schema.triggers` carries a trigger's name, +its base table and the `class` an operator installed, and that class is a compiled Java file sitting +in every node's trigger directory. The definition is not in the database in any form, so a `trigger` +row offers no Source action at all and nothing is being withheld. + +### 12.3 `DESCRIBE TABLE` does not answer one row + +The design predicted one row per read. Measured, `DESCRIBE TABLE "probe"."customers"` answers **four**: +the table, its two indexes and the materialized view over it, each typed by the reply's own `type` +column. Every one of those three is its own addressable object in the tree with its own Source, so the +read takes the row whose `type` names the kind that was asked for and the others are reached under +their own paths. Without that the Source tab for `customers` would show its indexes and its view +concatenated into one part. + +The `type` values are `table`, `index`, `materialized_view`, `type`, `function` and `aggregate`, which +happen to be this provider's own kind ids. They are still declared per kind on the catalog table +rather than inferred from the id, because an id and a wire value agreeing today is not a contract. + +The row is chosen by `type` and **never by position**. The server put the target first in every reply +captured here, so `rows[0]` would be behaviour-identical against every reply it has ever sent, and +nothing in the protocol promises that order. + +### 12.4 A routine's target is its bare name, and the overload is chosen from the reply + +`DESCRIBE` takes an identifier and no argument list. Measured: + +``` +DESCRIBE FUNCTION probe.render(int) + -> line 1:30 mismatched input '(' expecting EOF +DESCRIBE FUNCTION probe.render + -> two rows, name 'render(int)' and name 'render(text)' +``` + +So a routine read is two statements. The first is the listing statement the folder and the badge +already share, and the path segment is resolved through the **same identity builder** that produced +it (`function_name` plus the `argument_types` list), which is what keeps the resolution from drifting +from the listing. The second is the `DESCRIBE`, and the caller's overload is picked out of the reply. + +It is picked by the **argument list**, normalized for whitespace, and never by the reply's `name`. +Two measurements say why: + +| Measured | Reply `name` | +|---|---| +| `probe.sum_state(state int, value int)` | `sum_state(int, int)`, a SPACE after the comma, where this provider's identity segment writes none | +| a table created as `"MixedCase"` | `"MixedCase"`, **with** the quotes | +| a function created as `"Fn(x"` | `"Fn"(x(int)`, not a spelling anything can round-trip | + +What survives all three is the trailing parenthesized argument list, because a CQL type name is built +from angle brackets and holds no parenthesis, so the **last** `(` opens the signature. + +That last row is also the reason the split is taken from the last parenthesis rather than the first. +A **table** name must be alphanumeric-plus-underscore even when quoted, but a **function** name need +not be. Measured, and these objects are deliberately not in the committed fixture, because a fifth +function would move the `function` count this document, the suite and `database-compose.yml` all +carry, and that last file belongs to another task. The commands that recreate them: + +```bash +docker exec libredb-cassandra cqlsh -e " +CREATE KEYSPACE t14scratch WITH replication = {'class':'SimpleStrategy','replication_factor':1}; +CREATE TABLE t14scratch.\"pa(ren\" (id int PRIMARY KEY); + -- ConfigurationException: Table name must not be empty or not contain + -- non-alphanumeric-underscore characters (got \"pa(ren\") +CREATE TABLE t14scratch.\"MixedCase\" (id int PRIMARY KEY); -- accepted +CREATE FUNCTION t14scratch.\"Fn(x\"(a int) CALLED ON NULL INPUT RETURNS int + LANGUAGE java AS 'return a;'; -- accepted +CREATE FUNCTION t14scratch.\"qu\"\"ote\"(a int) CALLED ON NULL INPUT RETURNS int + LANGUAGE java AS 'return a;'; -- accepted +" +``` + +### 12.5 The escaper: quote the identifier, double the quote, leave the backslash alone + +Both segments are wrapped in double quotes and an embedded `"` is doubled. Neither half is decoration. + +Quoting is required because `system_schema` stores a name as it was written and the parser lowercases +an unquoted identifier: + +``` +DESCRIBE TABLE t14scratch.MixedCase + -> Table 'mixedcase' not found in keyspace 't14scratch' +DESCRIBE TABLE t14scratch."MixedCase" + -> the CREATE TABLE +``` + +Doubling is the whole escape, and the control is what makes that a measurement rather than a habit. +This epic's ClickHouse probe found that a backslash inside a quoted identifier **is** an escape there +and swallows the closing quote. CQL is the opposite: + +``` +DESCRIBE FUNCTION t14scratch."back\slash" -> resolves the function named back\slash +DESCRIBE FUNCTION t14scratch."back\\slash" -> User defined function 'back\\slash' not found +DESCRIBE FUNCTION t14scratch."qu""ote" -> resolves the function named qu"ote +``` + +So a backslash inside a quoted CQL identifier is data, and this engine needs no backslash rule at all. + +### 12.6 Absence raises, and `DESCRIBE` names the kind it looked for + +An object the read cannot find raises, carrying Cassandra's own sentence unprefixed. Every one of +those sentences **names the kind**, so a wrong-kind ask and a missing object are told apart by the +sentence rather than by a code: both are absences and both raise: + +| Sent | Server | +|---|---| +| `DESCRIBE TABLE probe.no_such_table` | `Table 'no_such_table' not found in keyspace 'probe'` | +| `DESCRIBE TABLE probe.customers_by_city` (a materialized view) | `Table 'customers_by_city' not found in keyspace 'probe'` | +| `DESCRIBE MATERIALIZED VIEW probe.customers` (a table) | `Materialized view 'customers' not found in 'probe'` | +| `DESCRIBE INDEX probe.no_such_index` | `Table for existing index 'no_such_index' not found in 'probe'` | +| `DESCRIBE TYPE probe.customers` | `User defined type 'customers' not found in 'probe'` | +| `DESCRIBE FUNCTION probe.total` (an aggregate) | `User defined function 'total' not found in 'probe'` | +| `DESCRIBE AGGREGATE probe.render` (a function) | `User defined aggregate 'render' not found in 'probe'` | +| `DESCRIBE TABLE no_such_ks.customers` | `'no_such_ks' not found in keyspaces` | + +All eight arrive as protocol code 8704, which the transport categorises `invalid` and the provider +maps to a `QueryError` carrying the message verbatim. + +### 12.7 The schema-change-mid-paging error is a RETRY and never a refusal + +It was reproduced rather than quoted: `DESCRIBE TABLE probe.customers` with `fetchSize: 1`, a +`CREATE TABLE` in another keyspace between pages, then a fetch of the second page with the first +page's `pageState`: + +``` +code 8704: The schema has changed since the previous page of the DESCRIBE statement result. + Please retry the DESCRIBE statement. +``` + +The server is telling the client to ask again, so reporting it as an `unavailable` part would put a +transient instruction in the Source pane as a fact about the definition. The read retries **once** and +then raises: a cluster whose schema moves faster than a catalog read completes is not fixed by a third +attempt, and an unbounded retry would turn a busy cluster into a pane that never resolves. + +Two things about its reach, stated rather than implied. This provider sends no `fetchSize`, so the +driver's own default of 5000 rows applies and a single-object `DESCRIBE` answers one row plus, for a +table, one per index and materialized view over it, so the paging this error needs is out of reach +for every shape the fixture holds, and the arm is driven in the suite rather than by a cluster. And +this is **the one place this provider reads a server sentence to decide anything**: 8704 alone cannot +discriminate, because an absent object carries it too. The trade is the opposite way round from the +monitoring degradation recorded in +[`src/lib/db/providers/sql/cassandra/transport.ts`](../../src/lib/db/providers/sql/cassandra/transport.ts), +where a rephrase would have silently disabled five panels. Here a rephrase costs exactly one thing: +the retry stops firing and the error propagates as a raise, which is what happens anyway when the +retry does not help. + +### 12.8 There is no privilege-driven refusal, measured + +`DESCRIBE` applies **no permission check** for a single named object, which is the same shape this +epic measured for PostgreSQL's `pg_get_*` family. On a 5.0.9 node started with +`authenticator: PasswordAuthenticator` and `authorizer: CassandraAuthorizer`, a role created with a +login and **no grant of any kind** read the complete `DESCRIBE TABLE probe.customers` and +`DESCRIBE FUNCTION probe.answer` text, in the same session where `SELECT table_name FROM +system_schema.tables WHERE keyspace_name='probe'` returned nothing. + +```bash +docker exec libredb-cassandra cqlsh -u cassandra -p cassandra \ + -e "CREATE ROLE nopriv WITH PASSWORD = 'nopriv' AND LOGIN = true;" +docker exec libredb-cassandra cqlsh -u nopriv -p nopriv -e "DESCRIBE TABLE probe.customers;" +``` + +So the `unavailable` arm of this read has exactly **one** producer: a `create_statement` that is empty +or whitespace only. No build measured in this epic has ever answered one. The arm exists because an +empty definition is not a definition and an empty editor over one is the failure the whole source read +was designed to prevent, and its sentence is **ours** rather than the engine's, which is a declared +deviation from the rule that a refusal carries the engine's own words: the read *succeeded* and the +server simply put nothing in the column, so there is no engine sentence to carry. + +### 12.9 Which servers and which drivers answer `DESCRIBE` + +The gate is the **server**, not the protocol and not the driver. + +| | Measured | +|---|---| +| Apache Cassandra 5.0.9 | Answers `DESCRIBE` for all six kinds | +| ScyllaDB 2026.2.4 (`release_version` 3.0.8) | Answers `DESCRIBE` with the **same four columns**, the same `type` values and the same absence sentence (`Table 'no_such_table' not found in keyspace 'probe'`), verified on `table`, `index` and `type` | +| `cassandra-driver` 4.9.0 | Negotiates native protocol **v4** against 5.0.9 (`isSupportedCassandra` caps at `0x04`), and `DESCRIBE` works over it. Note that `system.local.native_protocol_version` reports `5`, which is the server's maximum and not the negotiated version | + +The driver needs no feature support at all: it sends `DESCRIBE` as an ordinary one-shot query with +`prepare: false`, exactly like every other catalog statement here, and the answer arrives as rows. + +Server-side `DESCRIBE` is documented as landing in Apache Cassandra 4.0 (CASSANDRA-14825); before +that, `cqlsh` reconstructed it on the client and no client could ask the server for it. That lower +bound is **documented and not measured here**: no pre-4.0 server was run. On such a server the +statement is a syntax error at the `DESCRIBE` keyword, so it fails as a fact about the connection +rather than about any one object, and it raises rather than putting a parser error in a Source pane. + +### 12.10 The fixture already holds one object of every source-bearing kind + +Nothing was added to +[`docker/cassandra-init/01-object-fixture.cql`](../../docker/cassandra-init/01-object-fixture.cql) +for this read: three tables, one materialized view, three indexes of two catalog kinds, one +user-defined type, four functions including an overloaded pair, and one aggregate cover all six kinds, +and the trigger the file's own recipe installs is the one row in the tree that offers **no** Source +action at all. + +**Two of those kinds exist only because this deployment enables them.** `materialized_views_enabled` +and `user_defined_functions_enabled` both ship **disabled** in Cassandra 5.0, and the `cassandra` +service in `database-compose.yml` rewrites `cassandra.yaml` before the node starts for exactly that +reason. A stock 5.0 node legitimately holds no materialized view and no user-defined function, so on +one of those the `materialized_view`, `function` and `aggregate` folders are correctly empty and their +source reads have nothing to read. That is a property of the node, not of the provider. diff --git a/docs/providers/clickhouse.md b/docs/providers/clickhouse.md index d6f4b309..0a7a3b2c 100644 --- a/docs/providers/clickhouse.md +++ b/docs/providers/clickhouse.md @@ -1007,6 +1007,225 @@ backquoted identifier. --- +### 6.2 Object source (#789) + +`readObjectSource(path, kind, limit?)` answers ONE object's definition text as a document of +named parts. +Every declared kind has one, so there is no kind here that declares nothing, and every +document this engine produces carries exactly ONE part: ClickHouse has no package, no +specification-and-body split, and a materialised view's implicit inner table is storage the +server created rather than a second definition of the view. + +Everything below was measured on **ClickHouse 26.7.1.1315** against +`docker/clickhouse-init/01-object-fixture.sql` applied through the mount +`database-compose.yml` declares. + +| Kind | Statement | Monaco id | `form` | `origin` | +|---|---|---|---|---| +| `table`, `view`, `materialized_view` | `SELECT formatQuery(t.create_table_query) AS objectSource FROM system.tables AS t WHERE t.database = AND t.name = ` | `sql` | `complete` | `regenerated` | +| `dictionary` | the same statement; a row that is missing asks `SELECT d.origin AS objectOrigin FROM system.dictionaries AS d WHERE d.name = AND d.database = ''` | `sql` | `complete` | `regenerated` | +| `function` | `SELECT f.origin AS objectOrigin, f.create_query AS objectSource FROM system.functions AS f WHERE f.name = ` | `sql` | `complete` | `regenerated` | + +`complete` in words: each of these is a statement that RUNS AS GIVEN, never a body or a bare +SELECT. +`regenerated` in words: the server REBUILT it from its own catalog rather than storing what +the author typed, so a reader must never be shown it as an original. The fixture's +`total Decimal(12, 2) DEFAULT 0` comes back backquoted, and every MergeTree table comes back +carrying a `SETTINGS index_granularity = 8192` clause nobody wrote. + +`sql` is the right Monaco id and no part of it is a compromise: ClickHouse SQL is SQL, and the +installed monaco-editor 0.56.0 registers `sql`. The three ids this design had to refuse +elsewhere, `plsql`, `tsql` and `cql`, are not registered at all and are not needed here. + +#### The read takes NO identifier position, and that is the security decision + +`SHOW CREATE TABLE|VIEW|DICTIONARY` is the statement a ClickHouse user knows, and it takes an +IDENTIFIER where a bind would go. +On this engine that position is a statement-injection hazard rather than a quoting +inconvenience. + +MEASURED (#789 probe 11): a backslash inside a QUOTED IDENTIFIER is processed as an ESCAPE, in +the double-quote form and the backtick form alike. +`SELECT 1 AS "a\"b"` and `SELECT 1 AS "a""b"` produce the SAME identifier, and a table created +as `"x\\"` stores `hex(name) = 785C`, exactly one trailing backslash. +So a name ending in a backslash SWALLOWS the closing quote and the parser KEEPS READING: the +naive statement answers code 62, `Double quoted string is not closed`, and the error position +MOVES with whatever follows, which is how the swallow was told from a plain error. + +Two consequences, and both are why neither existing helper is used here: + +| Helper | Why not | +|---|---| +| `SQLBaseProvider.escapeIdentifier` | doubles ONLY the quote character, so it leaves the backslash escape open. Unsafe on this engine | +| `literal()` in `objects.ts` | a STRING escaper that emits single quotes. `SHOW CREATE DICTIONARY 'db'.'name'` is a parse error | + +The position is therefore REMOVED rather than escaped. All three statements above are +`WHERE x = ` reads of a system table, which is the same VALUE position the other nine +`literal()` call sites in that file use, and where the doubled quote plus the escaped backslash +are both measured to be correct. The fixture carries the two adversarial objects that make +that testable rather than assertable: + +| Object | Measured | +|---|---| +| ``demo.`bs_one\` `` | `hex(name) = 62735F6F6E655C`, `length(name) = 7`: exactly one trailing backslash | +| ``demo.`dq"two` `` | a double quote inside a backquoted identifier is an ordinary character | + +Reading ``bs_one\`` back with the backslash ESCAPED answers its definition; with the backslash +left alone the same statement fails with code 62, `Single quoted string is not closed`, at the +position of the clause that followed it, which is the value-position twin of what probe 11 +measured for identifiers. + +#### Nothing is lost by avoiding `SHOW CREATE`, and that is a measurement + +`formatQuery(create_table_query)` is BYTE-IDENTICAL to what `SHOW CREATE ` answers: + +| Object | `SHOW CREATE` | `formatQuery(create_table_query)` | +|---|---|---| +| `demo.orders` | 323 bytes | 323 bytes, identical | +| `demo.order_summary` | 238 bytes | 238 bytes, identical | +| `demo.dict_customers` | 212 bytes | 212 bytes, identical | +| `demo.mv_rollup` | identical | identical | + +The catalog column ON ITS OWN is a single line. That is the same statement and a much worse +thing to hand a reader, so the engine's own formatter supplies the spelling and the engine's +own catalog supplies the text. + +A `function` is the one kind NOT formatted, and that is forced rather than chosen: +`formatQuery('')` raises code 62 `Empty query`, `system.functions.create_query` really IS empty +for a non-SQL origin, and turning that per-object refusal into a hard error would take the +whole document away. Measured: the formatter leaves the one SQL function's text byte-identical +anyway, so the column is read raw. On `system.tables` the formatter is safe because 0 of 186 +rows on a bare server carry an empty `create_table_query`. + +#### `system.functions.create_query` is documented Obsolete and CARRIES THE TEXT + +The ClickHouse documentation marks that column Obsolete. The server does not: on 26.7.1.1315 it +answers `CREATE FUNCTION order_total_with_tax AS total -> (total * 1.2)`, 62 characters, for the +fixture's SQL function (#789 probe 10). +The measurement wins over the label, and both are recorded here so a later reader knows the +column is DEPRECATED rather than ABSENT. The control that makes it a per-object fact rather +than a server-wide one: grouped over the whole table, `System` has 1858 rows with 1858 EMPTY +`create_query` values beside the one `SQLUserDefined` row that is not. + +#### The three refusals, each PER OBJECT inside a declared kind + +`hasSource` answers for the KIND and the READ answers per object. A refusal is a PART carrying +a sentence, never a dropped declaration and never an absence. + +**A CONFIG-FILE dictionary.** It has no `system.tables` row at all and sits in +`system.dictionaries` with an EMPTY `database` and an `origin` naming the file, so the read that +answers for every other object of this kind answers no row for it. It sits beside a DDL +dictionary of the SAME KIND in the same fixture, which is what makes the refusal per object. +The sentence is OURS and names the engine's own `origin` value, and the reason it is not the +engine's own is measured: `SHOW CREATE DICTIONARY dict_regions_config` answers code 390 +`CANNOT_GET_CREATE_TABLE_QUERY`, ``Table `dict_regions_config` doesn't exist.``, which is a +FALSE claim about a dictionary the server is serving. That is the same shape as Oracle's +ORA-31603 "not found in schema", and the ruling there applies here: a refusal must say WHICH +absence it is. + +**A function with no SQL text.** `system.functions.origin` is +`Enum8('System' = 0, 'SQLUserDefined' = 1, 'ExecutableUserDefined' = 2, 'WasmUserDefined' = 3)`, +and an `ExecutableUserDefined` or `WasmUserDefined` function's body is an external program or a +WASM module rather than SQL, so its `create_query` is empty. The refusal carries the ORIGIN the +server reported, because that column is the only thing that can say WHICH absence an empty text +is. DISCLOSED: neither origin exists on this server and neither is in the fixture, because an +executable or WASM function is declared by a `*_function.xml` in the server configuration +directory beside the script it runs, and `database-compose.yml` mounts neither, so this origin +cannot be created on the shipped fixture. The arm is driven in the suite by a server that +answers an empty `create_query`, and the live half stays UNMEASURED. + +Both refusal sentences also have an arm for an origin the server does not report, which no +ClickHouse build produces: `system.dictionaries.origin` names the XML file and +`system.functions.origin` is an `Enum8`. It exists because a blank column would otherwise put a +hole in a sentence a reader is shown as the engine's own fact, and it is DRIVEN in the suite +rather than left to line coverage, which reported the line as hit while the arm was dead +(#789). + +**A privilege denial.** It arrives as HTTP 500 with exception code 497 and never as 403 +(section 3.3), and the sentence is the server's own, VERBATIM and unprefixed, never through the +provider's error mapping. Measured with a `src_probe` user holding only `SELECT ON demo.orders`: + +``` +Code: 497. DB::Exception: src_probe: Not enough privileges. To execute this query, it's +necessary to have the grant SELECT ON system.dictionaries. (ACCESS_DENIED) +``` + +Every OTHER failure RAISES. A timeout or a dropped socket is nobody answering at all, and +rendering it in the Source pane as this object's own refusal would present a symptom as a fact +about the object. + +#### `system.tables` FILTERS by grant where `system.dictionaries` DENIES + +Measured with the same restricted user, and it is the reason the denial above is reachable on +one catalog and not the other: + +| Read | As `src_probe`, holding `SELECT ON demo.orders` | +|---|---| +| `create_table_query` for `demo.orders` | the full definition | +| `create_table_query` for `demo.customers` | ZERO ROWS, no denial | +| `system.dictionaries` | code 497, ACCESS_DENIED | +| `system.functions` | answered, no grant needed | + +So on the table-backed kinds a privilege problem looks exactly like an absence, and this +provider raises `No ClickHouse table named customers in demo` for it. That is not a false claim +a user can meet from the tree: the SAME filtering hides the object from `system.tables` for the +count and the listing, so a caller who cannot see it never lists it and never reaches this read. + +#### An absence RAISES and an empty text is a refusal + +An object the provider cannot find raises a `QueryError` naming the object's last segment: +`No ClickHouse table named no_such_table in demo`, `No ClickHouse dictionary named ghost in demo`, +`No ClickHouse function named ghost`. It never answers a document and never answers a refusal +part, because the document's `parts` tuple leaves no empty value an absence could be confused +with. + +An empty or whitespace-only text is a REFUSAL and never a part with no text in it: an empty +definition is not a definition, and an editor buffer opened over one is the failure this whole +design exists to prevent. + +#### A DDL dictionary's credential comes back REDACTED + +`SOURCE(CLICKHOUSE(... PASSWORD '[HIDDEN]'))` is the SERVER's own substitution under +`format_display_secrets_in_show_and_select = 0`, which is the default, and `SHOW CREATE` does +exactly the same. The text is still `complete`: it is the statement the server publishes for +that object. Nothing in this product redacts anything, and a reader meeting the placeholder is +looking at the server's own answer rather than a password lost in transit. + +#### Paths, again, and the same rule + +The database is the container segment the DECLARATION names `schema` and the object's own name +is the LAST segment, never a literal index. A FUNCTION is server-global, so the container +segment of its path records where it was REACHED from and is deliberately not part of the +statement that reads it. The suite pins both derivations with a synthetic TWO-LEVEL declaration +AND with one that SWAPS the two levels over and feeds the path in the swapped order, where the +same three values must still reach the server. + +#### Reproducing the whole section + +```bash +docker compose -f database-compose.yml up -d clickhouse +# The user and the password are the compose service's own, read from the file that sets +# them rather than copied, so this block cannot go stale against it and carries no literal +# credential of its own. +CH_PASSWORD=$(awk '/CLICKHOUSE_PASSWORD/{print $2}' database-compose.yml) +# every object below is created by docker/clickhouse-init/01-object-fixture.sql +curl -s "http://127.0.0.1:8123/?user=libredb&password=$CH_PASSWORD&database=demo" \ + --data-binary "SELECT formatQuery(create_table_query) FROM system.tables WHERE database='demo' AND name='orders' FORMAT TSVRaw" +curl -s "http://127.0.0.1:8123/?user=libredb&password=$CH_PASSWORD&database=demo" \ + --data-binary "SHOW CREATE TABLE demo.orders FORMAT TSVRaw" # byte-identical to the line above +curl -s "http://127.0.0.1:8123/?user=libredb&password=$CH_PASSWORD&database=demo" \ + --data-binary "SELECT database, origin FROM system.dictionaries ORDER BY name FORMAT TSV" +curl -s "http://127.0.0.1:8123/?user=libredb&password=$CH_PASSWORD&database=demo" \ + --data-binary "SELECT name, origin, create_query FROM system.functions WHERE origin != 'System' FORMAT TSV" +# the escaper, both ways round, against the fixture's own adversarial table +curl -s "http://127.0.0.1:8123/?user=libredb&password=$CH_PASSWORD&database=demo" \ + --data-binary "SELECT name FROM system.tables WHERE database='demo' AND name='bs_one\\\\' FORMAT TSV" +curl -s "http://127.0.0.1:8123/?user=libredb&password=$CH_PASSWORD&database=demo" \ + --data-binary "SELECT name FROM system.tables WHERE database='demo' AND name='bs_one\\' FORMAT TSV" +``` + +--- + ## 7. Monitoring & health Every method below degrades to empty/zero on `ACCESS_DENIED` (497) or `UNKNOWN_TABLE` (60) — and diff --git a/docs/providers/couchbase.md b/docs/providers/couchbase.md index 64710a35..ea8f8bfb 100644 --- a/docs/providers/couchbase.md +++ b/docs/providers/couchbase.md @@ -605,7 +605,7 @@ provider in [`index.ts`](../../src/lib/db/providers/document/couchbase/index.ts) | Kind | Role | Path | Source | |------|------|------|--------| | `collection` | relation | `[bucket, scope, collection]` | `system:keyspaces` | -| `function` | routine | `[bucket, scope, function]` | `system:functions` | +| `function` | routine | `[bucket, scope, function]` | `system:functions`, and the only kind here declaring `hasSource` ([§6b](#6b-object-source-789)) | | `index` | config, `attachedTo: collection` | `[bucket, scope, collection, index]` | `system:indexes` | A collection declares `acceptsRowWrites: true`. That is the per-kind fact and it is deliberately @@ -727,7 +727,9 @@ A rejected `INFER` yields **no columns rather than an error**: the collection be `bookings` empty so that stays measured. `foreignKeys` is always `[]` for the same reason `declaresForeignKeys: false` is declared: SQL++ has no referential constraint. -A function's parameters and its body, and an index's keys as a first-class detail, are Phase 2. +A function's BODY is read by `readObjectSource` instead ([§6b](#6b-object-source-789)), not by +`describeObject`. A function's parameter list and an index's keys as a first-class detail remain +unmodelled. ### 6a.7b `describeObjects`, the bulk column read @@ -835,6 +837,10 @@ What it holds, so the counts below can be derived rather than remembered: service reports as the pre-scopes bucket-level row ([§6a.3](#6a3-the-two-row-shapes-and-the-bucket-level-one)). - **Functions:** `discount` in `inventory` and `discount` in `_default`. The global `celsius` is deliberately outside the tree ([§6a.5](#6a5-a-global-function-is-excluded)) and is not counted. + The two bodies differ on purpose, `price - (price * pct / 100)` against `x / 2`, so a source read + matching on the name alone answers the wrong one rather than the same one by luck, and neither is + a single character, because the object-surface conformance helper refuses a definition under two + characters as one a bound cannot be told from no bound. - **Indexes:** `ix_name` on each of `inventory`.`airline`, `inventory`.`hotel` and `_default`.`airline`, the primary index on `inventory`.`airline`, and the bucket-level primary index, which the `couchbase-init` sidecar creates before running the script. @@ -853,6 +859,134 @@ object**, so anything stated as a total elsewhere in this document is stated aga --- +## 6b. Object source (#789) + +`readObjectSource(path, kind, limit?)` answers one object's definition text. Everything below was +measured against **Couchbase Server 8.0.2 Community** running +[`docker/couchbase-init/01-object-fixture.sh`](../../docker/couchbase-init/01-object-fixture.sh), on +2026-09-13, including one end-to-end run of the provider itself against that node. + +### 6b.1 Per kind + +| Kind | `hasSource` | Statement | What the text IS | Monaco id | +|------|-------------|-----------|------------------|-----------| +| `function` | Yes | `SELECT f.identity AS identity, f.definition AS definition FROM system:functions AS f` | `definition.text`, the body as authored: `form: "partial"`, `origin: "stored"` | `sql` | +| `collection` | No | none | the engine publishes no such text at all | not applicable | +| `index` | No | none | the engine publishes no such text at all | not applicable | + +**`form: "partial"`, and it is a fact rather than a hedge.** `definition.text` is a BODY. The +parameter list is a separate field (`definition.parameters`) and there is no `CREATE FUNCTION` +header anywhere in the row, so what the pane shows is less than the statement that created the +object. Composing the three into one statement would show a reader something this product wrote +rather than something the engine published. + +**`origin: "stored"`, and the measurement that decides it.** The row carries the author's bytes +AND the engine's normalisation of them, side by side. The fixture's +`CREATE OR REPLACE FUNCTION travel.inventory.discount(price, pct) { price - (price * pct / 100) }` +answers: + +```json +{"#language":"inline", + "expression":"(`price` - ((`price` * `pct`) / 100))", + "parameters":["price","pct"], + "text":"price - (price * pct / 100)"} +``` + +`text` is what was typed; `expression` is a reconstruction. The read takes `text`, so the origin is +`stored`. Taking `expression` would have made it `regenerated` and would have shown a reader +backticks and parentheses they never wrote. + +### 6b.2 Why `collection` and `index` declare nothing + +- **`collection` is schemaless.** A Couchbase collection has whatever fields its documents carry. + There is no definition anybody authored, which is the same fact `describeObject` states by + INFERring columns from a document sample rather than reading a schema. +- **`index` publishes no statement.** `system:indexes` carries an index's name, its `index_key`, its + `is_primary`, its `using` and its state, and **no field holding the `CREATE INDEX` text**. A + Source tab there could therefore only show a statement composed out of the keys, which is a + statement this product invented rather than one the engine published. The index KEYS are a fact + the catalog does publish, and they are in `describeObject` + ([§6a.7](#6a7-describeobject)), which is where they belong. + +### 6b.3 The statement, the escaper, and what reaches the wire + +`FUNCTIONS_SQL` gained `f.definition` beside the `f.identity` it always read. It is the SAME +statement the listing sends, so `readObjectSource`, `listObjects` and `countObjects` cannot come to +look at different sets of functions. + +**No identifier and no bind reaches this statement.** The function's identity is matched IN CODE +against the rows the read returns, exactly as `kindObjects` places them +([§6a.5](#6a5-a-global-function-is-excluded) is the same rule, applied to the global function). So +there is no escaper in this engine's source read to get wrong. That is the whole answer to the +identifier-quoting question every other provider in #789 has to settle: the catalog is +namespace-wide and small, one row per user-defined function on the cluster, so reading all of it and +matching in code costs nothing and keeps one visible placement rule. + +### 6b.4 The refusals, and the one that CANNOT be verified on this image + +| Cause | What happens | Whose sentence | Verified live | +|-------|--------------|----------------|---------------| +| The row carries no readable `definition.text` | a refusal part carrying `unavailable`, no `text` | ours, see below | No, and it cannot be. See below | +| No row matches the path | RAISES `QueryError` naming the object, `No Couchbase function named in .` | ours | Yes | +| The statement itself is refused | RAISES, through the ordinary error map | the cluster's | Yes, in the suite | + +**The unverifiable branch, stated in advance rather than discovered.** An **EXTERNAL JavaScript +function's** body does not live in `system:functions` at all: Couchbase keeps it in a library on the +evaluator endpoint (`:8093/evaluator/v1/libraries/`), which the query service +does not expose and which this provider's transport does not speak. Its catalog row therefore +carries no `definition.text`, and the read answers the refusal part above. **That branch cannot be +driven against the image this repository runs.** Community Edition refuses to create such a function +at all: + +``` +CREATE OR REPLACE FUNCTION `travel`.`inventory`.`extfn`(a) LANGUAGE JAVASCRIPT AS "add" AT "mylib" +-> error 3000: Functions of type javascript are only supported in Enterprise Edition +``` + +measured verbatim on Server 8.0.2 Community on 2026-09-13, with `/pools` reporting +`"isEnterprise": false`. So the branch is written, and it is driven by +`tests/integration/db/couchbase-provider.test.ts` against an authored row rather than by a live +cluster. A whitespace-only body takes the same branch and is equally unproducible here: +`CREATE FUNCTION f() { }` is error 3000, a syntax error at the closing brace. + +The refusal sentence is **ours and not the cluster's**, declared rather than smuggled: the read +SUCCEEDS and the row it answers carries no body, so Couchbase said nothing there is anything to +carry. It names the object and says where the body actually lives. + +**A refused STATEMENT raises rather than becoming a refusal part**, and that is a decision. The read +is namespace-wide, so its failure says nothing about this object in particular, and `countObjects` +already carries the cluster's own sentence for the same read as `{ unavailable }` per kind +([§6a](#6a-the-object-surface-789)), which is where a whole-surface refusal belongs. + +### 6b.5 `system:functions` FILTERS BY PERMISSION, so a privilege problem arrives as absence + +Measured on 8.0.2 Community on 2026-09-13, with Community Edition's three roles (`admin`, +`ro_admin`, `bucket_full_access`): + +| Caller | `SELECT f.identity, f.definition FROM system:functions` | +|--------|--------------------------------------------------------| +| `admin` | all three rows, including the global `celsius` | +| `bucket_full_access[travel]` | the two `travel` rows, and NOT `celsius` | +| `ro_admin` | zero rows, `"status": "success"` | + +None of the three is an error. So a caller who may not see a function meets **absence**, +indistinguishable from a function that was never created, and the read raises for both. That is the +honest answer to both and it is why Couchbase is not on the phase's live-refusal list: this engine +has no privilege-driven refusal for object source to carry. + +### 6b.6 The derivations + +The bucket and the scope come from the segments the DECLARATION assigns to the `catalog` and +`schema` levels; the function's name is `path[path.length - 1]`. Never `path[0]`, `path[1]` or +`path[2]`. Couchbase's declaration is already two-level, so a test swapping in a two-level +`containerLevels` would match the real one and pass for a hardcoded implementation. The suite +therefore drives two varied declarations instead: one that SWAPS the two levels over, fed a path in +the swapped order, where the same row must still be found; and one giving `function` an `attachedTo` +so it is addressed at four segments, where `path[2]` names the base object while the last segment +names the function. + +--- + ## 7. Monitoring & health Every method below degrades to empty on a permission error @@ -1062,7 +1196,8 @@ Validation, connect/disconnect, capabilities, labels, `prepareQuery`, query exec shaping, the full error map, endpoint discovery (including `alternateAddresses` and the fallback port), SRV resolution and its fallback, TLS material, `request_plus` **and** the `not_bounded` override, collection listing, INFER flavour union, index mapping, every monitoring method and its -degraded path, all three maintenance operations, and the explain strategy. +degraded path, all three maintenance operations, the explain strategy, the whole object surface, and the source +read with both of its refusal branches and both of its derivation pins ([§6b](#6b-object-source-789)). ### 11.3 Run it diff --git a/docs/providers/druid.md b/docs/providers/druid.md index 7727dbe3..f2d89892 100644 --- a/docs/providers/druid.md +++ b/docs/providers/druid.md @@ -1297,6 +1297,68 @@ through `spyOn` and driving the reads to a bound value - with a catalog segment something other than `druid`, because Druid's catalog and its main schema share that name and a test written with the engine's own names would certify the defect it exists to catch. +#### Object source (#789): nothing to read, and three different reasons + +Druid contributes **no Source tab and no `unavailable` sentence**, and that is a different answer +from "the read was refused". No kind declares `hasSource`, the provider implements no +`readObjectSource`, and `assertObjectSurface` certifies that pairing directly: a declaration with no +method behind it, or a method with no declaration in front of it, fails the suite by name. The +provider's own suite pins the same absence from the other side, kind by kind and through +`kindHasSource()`, which is the derivation the route and the row menu read. + +The three kinds are absent from the source surface for **three different reasons**, and collapsing +them into one "Druid has no source" sentence would lose the only one of them that is work somebody +could do: + +| Kind | Why there is no source read | Which fact it is | +|---|---|---| +| `datasource` | the engine publishes no definition text for it, anywhere | the engine has no such text at all | +| `system_table` | the engine publishes no definition text for it, anywhere | the engine has no such text at all | +| `lookup` | it HAS a JSON definition, and it lives on a REST API this provider's transport cannot address | it exists somewhere this product does not reach, and it is filed | + +**`datasource` and `system_table`: there is no text, so there is nothing to refuse.** This is the +same measurement the declaration rests on, one level down. `CREATE` in any form is a syntax error and +the parser answers by enumerating every statement it expected, with no form of `CREATE` among them +(the refusal is quoted in full under [the declaration](#the-declaration) above). A datasource comes +into existence by ingestion, and a system table is compiled into the Broker; neither was ever written +down as a statement, so there is no stored text, no regenerable statement and nothing to render. A +refusal part would be worse than the absence, because a refusal sentence tells a user the read failed +when in truth there was never anything to read. + +**`lookup`: the definition exists and this transport cannot address it.** A lookup is the one kind +here that a person really does author, as a JSON spec, and four separate facts make reading it a +transport change rather than a source read: + +- **Where the definition is.** A lookup is registered by posting its spec to the Coordinator, and + `GET /druid/coordinator/v1/lookups/config/{tier}/{id}` answers that spec back. DOCUMENTED against + the Druid 37.0.0 API reference and **not** measured against a cluster here, which is exactly why + the first step of the filed work is to measure it. +- **What SQL answers instead, and why it is not the definition.** Measured on 37.0.0: + `SELECT * FROM lookup.` returns the key and value PAIRS, and `INFORMATION_SCHEMA.COLUMNS` + describes them as `k` and `v`. Those pairs are the lookup's CONTENT. The spec's type (`map` versus + `cachedNamespace`), its polling period and the namespace it extracts from appear nowhere in SQL, so + rendering the pairs under a caption that says "definition" would show a user something that is not + the definition, which is the failure the `rendered` origin exists to prevent. +- **Why it is the transport that would have to change.** + [`transport.ts`](../../src/lib/db/providers/sql/druid/transport.ts) publishes exactly two members, + `query(sql, opts)` and `close()`, and `query` takes a SQL string: there is no member that can + address any other path on the cluster. Nor can provider logic reach around it, because + [`tests/unit/db/druid/seam-guard.test.ts`](../../tests/unit/db/druid/seam-guard.test.ts) parses + every file in the provider directory and **fails the build** when a bare `fetch` or an endpoint + path appears outside [`http-transport.ts`](../../src/lib/db/providers/sql/druid/http-transport.ts). + So the smallest honest version of this read is a new seam member plus its one implementation, which + is a change to the contract every other read in this provider shares. +- **And the connection may not reach the Coordinator at all.** The connection carries one host and + one port ([§3.3](#33-router-8888-or-broker-8082--both-work-identically)). A Broker-only deployment + serves the SQL endpoint and no Coordinator API whatsoever, and a Router serves it only when + `druid.router.managementProxy.enabled` is set, which `database-compose.yml` does set for this + repository's own cluster and a production deployment need not. So the read would have to be able to + come back empty-handed for a reason that is about the deployment rather than about the object, + which is a refusal sentence this provider does not have today. + +That last one is the reason `lookup` is a **deferral and not an absence**, and it is filed in +[`docs/BACKLOG.md`](../BACKLOG.md) with this measurement rather than only here. + ## 7. Monitoring & health Every read below degrades to empty/zero when the failure `isMonitoringUnavailable()` — diff --git a/docs/providers/duckdb.md b/docs/providers/duckdb.md index 7e598d33..dca0ed44 100644 --- a/docs/providers/duckdb.md +++ b/docs/providers/duckdb.md @@ -760,8 +760,6 @@ listed DuckDB object carries no `rowCount` at all. alongside `system`, and both are excluded together. A session-scoped table is invisible in the tree. - Secrets, for the reason above. -- Object SOURCE, which is Phase 2's. `duckdb_views().sql`, `duckdb_sequences().sql` and - `duckdb_functions().macro_definition` all publish it, so the data is there when that phase arrives. - A macro's parameters. `duckdb_functions().parameters` is a `VARCHAR[]` of names, and a routine has no columns, so `describeObject` answers three empty arrays for a macro and a sequence without a round trip. @@ -775,6 +773,128 @@ rather than selected bare, unlike the libSQL case recorded in [libsql.md](./libs --- +### Object source (#789) + +Every declared kind publishes a definition text, so **all four declare `hasSource`, all four with +`sourceLanguage: "sql"`, and no kind declares nothing.** `readObjectSource(path, kind, limit?)` reads +one object and answers a document of exactly one part. + +`sql` is the honest Monaco id rather than a compromise. DuckDB's dialect is PostgreSQL-shaped, the +installed monaco-editor 0.56.0 registers no DuckDB id, and the text the engine publishes is ordinary +SQL. This is unlike Oracle, SQL Server and Cassandra, where `plsql`, `tsql` and `cql` are not +registrable ids in that bundle and `sql` really is a compromise those provider docs record. + +| kind | statement | what the text IS | `form` | `origin` | +|---|---|---|---|---| +| `table` | `SELECT sql AS definition FROM duckdb_tables() WHERE database_name = $1 AND schema_name = $2 AND table_name = $3` | a `CREATE TABLE` statement ending in `;`, rebuilt from the catalog | `complete` | `regenerated` | +| `view` | `SELECT sql AS definition FROM duckdb_views() WHERE database_name = $1 AND schema_name = $2 AND view_name = $3` | a `CREATE VIEW` statement ending in `;` | `complete` | `regenerated` | +| `sequence` | `SELECT sql AS definition FROM duckdb_sequences() WHERE database_name = $1 AND schema_name = $2 AND sequence_name = $3` | a `CREATE SEQUENCE` statement with every option spelled out | `complete` | `regenerated` | +| `macro` | `SELECT macro_definition AS definition FROM duckdb_functions() WHERE database_name = $1 AND schema_name = $2 AND function_type IN ('macro', 'table_macro') AND function_name = $3` | the macro **body**, never a statement | **`partial`** | `regenerated` | + +#### The macro is `partial`, deliberately + +`duckdb_functions().macro_definition` is a BODY. Measured on v1.5.5 against +`docker/duckdb-init/01-object-fixture.sql`: `CREATE MACRO main.add_one(x) AS x + 1` publishes +`(x + 1)`, and `CREATE MACRO analytics.recent_events(n) AS TABLE SELECT * FROM analytics.events +LIMIT n` publishes `SELECT * FROM analytics.events LIMIT n`. DuckDB publishes no `CREATE MACRO` +statement anywhere: there is no `duckdb_macros()`, and `information_schema` has no `.routines` on this +engine. + +The alternative was assembling a statement out of `duckdb_functions().parameters`. It is declined. +Assembling would show a user a statement the engine never published, which they might copy and run, +and the Source pane says `form: partial` with a caption that tells them the text is the body only. +A less pretty pane that is more true. Phase 3 assembles, per the rule that the provider builds the +statement and core never does. + +#### `origin` is `regenerated` on all four, and that is measured + +The three `sql` columns are **not** the author's bytes. Measured on v1.5.5: + +``` +CREATE TABLE main.customers (id INTEGER PRIMARY KEY, name VARCHAR NOT NULL, note VARCHAR DEFAULT 'none') + -> CREATE TABLE customers(id INTEGER PRIMARY KEY, "name" VARCHAR NOT NULL, note VARCHAR DEFAULT('none')); +CREATE TABLE main.orders (id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES main.customers(id), total DECIMAL(12,2)) + -> CREATE TABLE orders(id INTEGER PRIMARY KEY, customer_id INTEGER, total DECIMAL(12,2), FOREIGN KEY (customer_id) REFERENCES customers(id)); +CREATE SEQUENCE main.customer_seq START 1 + -> CREATE SEQUENCE customer_seq INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START 1 NO CYCLE; +``` + +The schema qualification is dropped, `name` is quoted, the default is parenthesised, an inline +`REFERENCES` becomes a table-level `FOREIGN KEY` clause, and every sequence option is spelled out +whether the author wrote it or not. SQLite is the engine in this fleet where `origin: "stored"` has a +producer; claiming it here would make the Source caption's distinction decoration. + +Both `form` and `origin` are declared **per kind, beside the statement that reads the text**, in the +same record the count and the listing are built from. They are one shape of fact, so a kind added +later whose text really is stored bytes has one place to say so and cannot inherit an origin from a +literal somewhere else. + +The `partial` form is this engine's only one, not the fleet's: PostgreSQL `view` and +`materialized_view` and Couchbase `function` produce the same arm, each for its own reason. + +#### The refusals: NONE, stated as a CANNOT + +**This engine has no privilege model and no refusal for a source read.** DuckDB has no users, no +roles and no passwords (§11), so there is nothing a read can be denied for. + +Nor does it answer a row carrying nothing. Measured on v1.5.5 over a fresh instance holding the +fixture: zero `duckdb_tables()` rows, zero of 47 `duckdb_views()` rows, zero `duckdb_sequences()` +rows and zero of 136 macros carry a NULL or whitespace-only text. Even a macro written to have no +body publishes something: `CREATE MACRO no_body() AS NULL` answers the four characters `NULL`. + +The provider still carries a blank arm, because an empty definition must never reach an editor as a +definition, and it says **which of four shapes** produced it: the reply carried no definition column +at all, the driver handed the column back as something other than a text, the column was NULL, or the +column held no non-whitespace character. The first two are facts about the READ and the last two are +facts about the ROW, and they are kept apart because a refusal stating a cause that is false for the +shape in front of it is worse than one stating none. The non-text shape is not hypothetical: +`@duckdb/node-api` 1.5.5-r.4 already hands a BIGINT `COUNT(*)` back as a decimal **string**, so this +driver's JavaScript mapping is per column type rather than fixed, and the sentence for that shape +names the type the driver answered with. Those four sentences are **ours**, not the engine's, which is +this engine's one exception to the "the engine's own sentence, unprefixed" guarantee: from DuckDB's +point of view the read SUCCEEDED and answered a row, so there is nothing to carry verbatim. + +#### A missing name is ABSENCE and raises + +A name nothing carries answers **zero rows**, not a row carrying nothing, so the read raises a +`QueryError` naming the object and its container (`No DuckDB macro named no_such_macro in +memory.main`). The container is in the message because on a two-level engine the same name in the +neighbouring catalog is a real object, and a caller needs to be told which one was looked for. + +#### The kind is a required argument, and this engine is one of the three reasons + +Measured on v1.5.5, and held by `docker/duckdb-init/01-object-fixture.sql`: a table, a sequence and a +macro can all be called `overlap` in one schema, and they answer three different definitions. + +``` +table -> CREATE TABLE overlap(id INTEGER); +sequence -> CREATE SEQUENCE overlap INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START 1 NO CYCLE; +macro -> x +``` + +A source read keyed on the path alone answers one of the three at random. The kind picks the catalog +function; the three binds pick the row. MySQL, MariaDB and DuckDB are the three engines that made +`kind` a required argument of `readObjectSource`. + +#### No escaper, and there is no identifier position to escape + +Every bind is a parameter. Each `duckdb_*` table function publishes its container as a COLUMN, so a +catalog and a schema are bound STRINGS rather than parts of a three-part name, and the object's name +is bound too. That removes the whole class of quoting defect ClickHouse and SQL Server need an +identifier escaper for. The same statement builder that the count, the listing and the four detail +reads use builds this one, so a change to the container filter or to the macro `function_type` +predicate moves the source read with it. + +#### A macro's overloads + +There are none to disambiguate. Measured on v1.5.5: `CREATE MACRO main.pair(a) AS a` followed by +`CREATE MACRO main.pair(a, b) AS a + b` is refused with +`Catalog Error: Macro Function with name "pair" already exists!`, so a macro name is unique within its +schema and the last path segment is the whole identity. This is unlike PostgreSQL, where the routine +segment carries an argument type list. + +--- + ## 7. Monitoring & health Measured through a fixture of `main.customers` (5 rows), `main.orders` (7 rows), @@ -867,10 +987,25 @@ sibling test files. Two things the tests deliberately do **not** assert, both from §3.5 and §7: `memory_limit` (80% of host RAM, machine-dependent) and any absolute byte figure parsed from a formatted size string. -The object-surface tests (§6) are the same shape and it matters more there: because the engine is -in-process, every claim about `duckdb_databases()`, `duckdb_schemas()` and `function_type` in this -document is re-measured against a REAL DuckDB on every test run, and the fixture `ATTACH`es a second -`:memory:` catalog so the two container levels are exercised live. Two facts are asserted on the +The object-surface and object-source tests (§6) are the same shape and it matters more there: +because the engine is in-process, every claim about `duckdb_databases()`, `duckdb_schemas()`, +`function_type` and every definition text in this document is re-measured against a REAL DuckDB on +every test run, and the fixture `ATTACH`es a second catalog so the two container levels are exercised +live. + +**The fixture is a committed file**, `docker/duckdb-init/01-object-fixture.sql`, replayed by the +suite through `docker/duckdb-init/build-fixture.ts`. The same reader builds a database FILE anybody +can point Studio at: + +```bash +bun docker/duckdb-init/build-fixture.ts # ./.duckdb-fixture/object-fixture.duckdb +bun docker/duckdb-init/build-fixture.ts /tmp/demo.duckdb # anywhere else +``` + +The fixture's second catalog is a placeholder the reader substitutes: the suite attaches `:memory:` +and a file build attaches a sibling file, because DuckDB does **not** persist an attachment inside a +database file. So opening the built file shows one catalog, and the builder prints the `ATTACH` +statement that reaches the other. Two facts are asserted on the STATEMENT rather than on rows, because no fixture on this engine can distinguish them: the macro `function_type` predicate, and `ORDER BY column_index` on the column read. @@ -992,6 +1127,8 @@ to a database login. | TEMP tables are absent from the object tree | `duckdb_databases()` marks `temp` `internal`, alongside `system` | Ours, §6 | | Secrets are not an object kind | A secret has no catalog and no schema, so nothing in the model can hold it | Ours, out of Phase 1's scope (§6) | | A listed object carries no row count | `estimated_size` is an estimate, and `count(*)` per object is an N+1 | Ours, §6 and §3.5 | +| A macro's Source pane shows a BODY, not a `CREATE MACRO` statement | `duckdb_functions().macro_definition` is the only text the engine publishes, and there is no `duckdb_macros()` | The engine's; ours to assemble in a later phase, and the pane says `form: partial` meanwhile (§6, Object source) | +| Every definition text is a regeneration, never the author's bytes | The catalog stores a parsed object, not the submitted statement | The engine's (§6, Object source) | --- diff --git a/docs/providers/elasticsearch.md b/docs/providers/elasticsearch.md index 30c77c7c..bfd6333a 100644 --- a/docs/providers/elasticsearch.md +++ b/docs/providers/elasticsearch.md @@ -906,8 +906,10 @@ no alias is listed with a **present**, empty map. - **No view, function, procedure or trigger.** Neither product's SQL surface has `CREATE VIEW`, and OpenSearch's grammar contains no `CREATE` statement of any kind (`CREATE TABLE t (id BIGINT)` answers `SQLFeatureNotSupportedException`, *"Query must start with SELECT, DELETE, SHOW or - DESCRIBE"*). Elasticsearch 9.4 adds an **ES|QL views API as a technical preview**; a preview surface - gets no folder, and Phase 2 is where it is revisited. + DESCRIBE"*). Elasticsearch 9.4 adds an **ES|QL views API as a technical preview**, and #789 Phase 2 + answered the question it left open by leaving it out: a preview surface gets no folder, so there is + no kind and nothing for the source read to read. A view kind here would also be a kind only one of + the two products this implementation serves could ever hold. - **No stored script.** Both products have them and neither has a list-all API: `GET /_scripts` is refused outright (*"Invalid index name [_scripts]"*), only get-by-id exists. An object that cannot be enumerated cannot be a tree node - the same call Redis's `EVAL` scripts got. @@ -982,7 +984,11 @@ docker exec libredb-opensearch bash /opt/search-init/01-object-fixture.sh It creates one instance of every declared kind: the index `probe_orders`, the alias `probe_orders_alias`, the ingest pipeline `probe_pipeline`, the composable index templates -`probe_template` and `probe_stream_template`, and the data stream `probe_stream`. +`probe_template` and `probe_stream_template`, and the data stream `probe_stream`. It creates two more +ingest pipelines for the source read (#789), each carrying a claim this doc makes: `probe_json_edges` +holds the values a JSON re-serialisation changes, and `probe pipe/slash` is named so that the +percent-encoding is exercised by an object rather than by an argument. The script's own header says +what each object is for. **The one measured difference between the two products, and it is not in the declaration.** The object surface's five kinds, their roles, their paths and their columns were driven against a live @@ -995,6 +1001,126 @@ the built-ins, measured, and they come back about twenty seconds later (the obje above records that run). On OpenSearch it is the ordinary first-run state. See [opensearch.md](opensearch.md) for the other half of that sentence. +#### Object source (#789) + +`readObjectSource(path, kind, limit?)` answers **one part** holding one object's definition, and two +of the five kinds declare it: `pipeline` and `template`, both `sourceLanguage: "json"`. The other +three declare nothing, and each absence is a different fact rather than one gap said three ways. + +| Kind | Request | The text | `form` | `origin` | +|---|---|---|---|---| +| `pipeline` | `GET /_ingest/pipeline/` | the value under the `` key | `complete` | `rendered` | +| `template` | `GET /_index_template/` | the `index_template` of the entry whose `name` matches | `complete` | `rendered` | + +`_index_template` and **not** `_template`, for the same reason the listing uses it: the legacy +namespace's names may collide with the composable ones, so one kind fed by both would hold two +objects at one path. + +**Why `index`, `alias` and `stream` declare nothing.** + +- **`index`.** `GET /` answers settings the **server** wrote - `index.uuid`, `creation_date`, + `version.created`, `provided_name` - so what a Source tab would show is not a definition anybody + could re-apply, and the round trip from that answer back to a `PUT` that recreates the index could + not be established. +- **`alias`.** One alias over N indices has **one definition per index** and a different create shape + (`POST /_aliases` with an actions array), while the tree deliberately deduplicates those N rows to + one object. There is no single text belonging to the row that exists. +- **`stream`.** A data stream's definition **is** the matching index template, which is a different + object in a different folder of the same tree. Showing it here would present another object's + definition as this one's. + +**The definition is unwrapped, and the key is matched EXACTLY.** Both endpoints answer a wrapper: a +pipeline arrives as `{"": {...}}` and a template as `{"index_templates":[{"name":..., +"index_template":{...}}]}`. Rendering the wrapper would show a reader a map whose only key is the name +of the object they already opened. Taking "the only key" or entry zero instead of matching the name +would be worse, and this is measured rather than defensive: **a `*` in the name is a wildcard on both +endpoints**, so `GET /_ingest/pipeline/probe*` answers HTTP 200 carrying `probe_pipeline` and +`GET /_index_template/probe*` answers **two** entries (both measured on 9.1.4, 2026-09-13). Encoding +does not help either: `%2A` is decoded before the match and wildcards just the same. So the read takes +the value under the exact name asked for, and a name the answer does not carry is an **absence**. + +**Absence RAISES, and the two endpoints spell it differently.** Measured on 2026-09-13: +`GET /_ingest/pipeline/no_such` answers HTTP **404** with the body `{}`, and +`GET /_index_template/no_such` answers HTTP **404** with the **full error envelope** +(`resource_not_found_exception`, *"index template matching [no_such] not found"*). Both mean the +object is not there, so the source read decides absence on the **status** for a named object and +raises a `QueryError` naming it. That is deliberately **not** the rule the listings follow: a listing's +404 has a second meaning ("there are none"), so a listing may read a 404 as empty only while the body +is a payload. Carrying the listing rule to a named object would print *"index template matching [x] +not found"* in the Source pane as the cluster refusing to show a definition. + +**Only an answer from the cluster is a refusal.** A denied endpoint, a fault the cluster named, or a +body this client could not read becomes a refusal part carrying the cluster's own sentence, +unprefixed - with one exception, the **denial**, whose sentence the transport composes from the +status because no 401 or 403 body could be captured (see CANNOT below). A dropped socket, an expired +client deadline and a cancellation **raise** instead: nobody answered, so a document carrying +*"connect ECONNREFUSED"* as this object's own refusal would offer no raise, nothing to retry and +nothing distinguishing it from a real denial. `isClusterRefusal()` in +[`search/index.ts`](../../src/lib/db/providers/sql/search/index.ts) is that split, written as a switch +with no `default` so a new seam category fails the build instead of joining the wrong half. All eight +of its arms are driven in the two integration suites, five to the refusal half and three to the +raising one. + +**An unreadable body is a refusal and never an absence.** Where the answer is HTTP 200 and is not the +shape this client parses - the wrapper is not an object, the value under the name is not an object, +`index_templates` is not an array, an entry carries no `name` - the read reports +*"Elasticsearch answered an ingest pipeline definition the client could not read"* (or *"an index +template definition"*). Skipping the entry or reading it as nothing would say *"No Elasticsearch +template named X"* about a row the tree is currently showing: a claim about the cluster where the +truth is a claim about this client, and the one a user cannot act on. + +**An EMPTY definition cannot arrive, measured rather than assumed.** Design guarantee 2 asks for a +refusal part where an engine answers empty or whitespace-only text, and neither endpoint here can +produce one: `PUT /_ingest/pipeline/` with `{}` is HTTP **400** (`parse_exception`, *"[processors] +required property is missing"*), a body carrying only a `description` is the same 400, and +`PUT /_index_template/` with `{}` is HTTP **400** (`illegal_argument_exception`, *"Required +[index_patterns]"*). The smallest definitions either endpoint stores are therefore +`{"processors":[]}` and `{"index_patterns":["..."]}` (both `PUT` 200, both read back with those +members), so the rendered text is always a JSON object with at least one member. Re-runnable against +the compose service with those four `PUT`s; measured on 9.1.4 on 2026-09-13. + +**A refusal is per ENDPOINT.** These are two separate endpoints and a security plugin grants +privileges per endpoint, so a pipeline read can be denied while a template read answers - the same +measurement `countObjects` rests on. + +**Neither refusal can be produced on the compose service: CANNOT.** `xpack.security.enabled` is +`false` there, a bogus `Basic` header is IGNORED and the cluster answers HTTP 200 (measured +2026-09-13), so no 401 or 403 body exists to capture from this container. Turning security on would be +an image-configuration change that invalidates the measurements this section rests on. The refusal +shape is therefore driven in the suite against the one signal HTTP itself fixes, and the sentence a +denied read carries is the transport's own, `"Elasticsearch refused the credentials (HTTP 403)"`. + +**The name is percent-encoded, and that is what makes the fixture's odd object readable.** A pipeline +name may hold a space **and** a slash (both `PUT` 200, measured), and +`GET /_ingest/pipeline/probe%20pipe%2Fslash` answers the object while the same request with the slash +unencoded is HTTP 400, *"no handler found for uri"*. The escaper is `encodeURIComponent` and +`docker/search-init/01-object-fixture.sh` creates `probe pipe/slash` so the escaping is driven by a +fixture rather than by an argument. A template name may **not** hold a space (HTTP 400, +`invalid_index_template_exception`), so the pipeline endpoint is the only one where such an object can +exist at all. + +**What the renderer does to the cluster's bytes, measured, because `origin: "rendered"` is a promise +about exactly this.** There is no extended-JSON writer for a REST payload the way there is for BSON, +and the definition is a **sub-document** of the answer rather than the answer, so the text cannot be +the bytes the cluster sent. It is `JSON.parse` followed by `JSON.stringify` with a two-space indent, +and three things change. Measured on 9.1.4 on 2026-09-13 against the fixture's `probe_json_edges` +pipeline, which exists for this measurement: + +| The cluster answered | This product renders | +|---|---| +| `9223372036854775807` | `9223372036854776000` | +| `1.0E30` | `1e+30` | +| a map keyed `zz, 10, 2, aa` | the same map keyed `2, 10, zz, aa` | + +Nothing is **dropped** - every value is present, which is what keeps `form: "complete"` true - but a +long past 2^53 loses precision, an exponent is re-spelled, and integer-like keys are hoisted ahead of +the others by JavaScript's own property order. A definition holding such a value must not be copied +out of the Source pane and `PUT` back unread. The three lines above are asserted in both integration +suites against the captured payload, so a renderer that starts dropping something instead fails. + +**The bound is the caller's**, applied through the shared `applySourceBound`, and a text that fits is +never marked. + --- ## 7. Monitoring & health diff --git a/docs/providers/libredb.md b/docs/providers/libredb.md index d0d8c5dc..8c1b0f4a 100644 --- a/docs/providers/libredb.md +++ b/docs/providers/libredb.md @@ -795,6 +795,57 @@ own database (`findOpenSingleWriterProvider`). The suite pins it: it asserts tha of the fixture throws while the provider holds it, then drives all four object methods and a the object surface through the held handle. +#### Object source (#789): the absence is measured, and one of the three kinds is interesting + +No kind declares `hasSource`, this provider implements no `readObjectSource`, and +`assertObjectSurface` certifies that pairing directly: a declaration with no method behind it fails +the suite by name. The provider's own suite pins the same absence from the other side, kind by kind +and through `kindHasSource()`, which is the derivation the route and the row menu read. + +**The measurement was re-run for #789 rather than carried over**, because a version bump could have +changed the answer and the whole value of this section is that the absence is measured instead of +assumed. Against `@libredb/libredb` **0.2.2**, the exact version this repository resolves: + +- The package's whole export surface is **twelve names** - `CATALOG_PREFIX`, `LibreDbError`, + `RESERVED_MARKER`, `catalog`, `doc`, `isReservedKey`, `kv`, `nodeFileSystem`, `open`, + `readonlyFileSystem`, `table`, `version`. +- A `Database` handle publishes `close` and `transact`. The `kv` lens publishes `get`, `set`, + `delete`, `prefix`, `range`; the `doc` lens `get`, `put`, `delete`, `find`, `all`; the `table` lens + `get`, `insert`, `delete`, `all`, `select`, `where`, `join`, `name`. +- There is no view, no routine, no procedure, no trigger, no index, no sequence and no constraint + anywhere in any of them, and **nothing takes or returns a definition text**. Every reader answers + keys, documents or rows. + +That covers `collection` and `keyspace` outright, and each for its own reason. A cataloged document +namespace records `{ kind: "document" }` and nothing else - documents are schemaless, so existence +and kind are all there is to record - and a `keyspace` row is a prefix this server DERIVED from a +bounded key scan rather than an object anybody named +([the derived-grouping refusal](#the-derived-grouping-refusal-and-the-declaration-that-carries-it)), +so there is no authored anything for it to have. + +**`table` is the one that needed a decision, and the answer is still no.** A relational table's +catalog entry does carry a structure, and it is genuinely persisted: `recordRelational` writes a +`CatalogEntry` as JSON under the reserved catalog key, and `catalog(db)` reads it back as +`{ primaryKey, columns }`. So the flat claim "nothing here is written down" would be too strong. +Three measured facts keep it out of the source surface anyway: + +- **It is already on screen, as itself.** That same map is exactly what `describeObject()` answers as + the table's columns ([above](#what-describeobject-answers)). A Source tab over it would be a second + rendering of a panel that already shows it in the form the user wants it in. +- **Nothing could re-apply an edited one.** #789 is the read half of #778, which is editing a + definition, and this engine has no statement to submit an edit through: the grammar is + `get` / `put` / `delete` / `prefix` / `range` ([§5.1](#51-command-grammar)), `supportsCreateTable` + is false, and the package itself refuses the operation - `recordRelational` validates a passed + schema against the persisted one and **throws on mismatch**, because there is no schema migration + in 0.2.x. A Source tab that can only ever be read is a worse answer than no tab. +- **Showing it as a statement would mean inventing one.** There is no `CREATE TABLE` in this engine, + so any statement-shaped rendering would be a sentence no one can run against the database it claims + to describe, which is the fabrication the `origin` arms exist to prevent. + +So the absence here is a CANNOT-usefully rather than a not-yet, and it is recorded in this file +rather than filed, because there is no deferred work behind it: the day this package grows a routine, +a view or a schema-altering statement is the day the question is worth reopening. + --- ## 7. Monitoring & health @@ -1033,7 +1084,7 @@ its real declared columns with the primary key marked (the relational signal); ( collection shows the generic `id`/`document` columns (the document signal); and (d) raw kv namespaces still group as `key`/`value` pseudo-tables. -The **single-writer reuse** (D3/B49) is asserted in `tests/unit/db/factory.test.ts` rather than +The **single-writer reuse** (D3/B49) is asserted in `tests/isolated/factory.test.ts` rather than here, because it is the factory's behaviour and it needs the real factory — which this file cannot import, since `tests/api/db/test-connection.test.ts` replaces `@/lib/db/factory` process-wide with `mock.module`. Two suites there run on the real package and real temp files: *single-writer file diff --git a/docs/providers/libsql.md b/docs/providers/libsql.md index eb647922..741ab2b9 100644 --- a/docs/providers/libsql.md +++ b/docs/providers/libsql.md @@ -578,39 +578,116 @@ product prefix in front of the server's words, and `listObjects()` raises. A fai #### The fixture, and running it -The object-surface tests answer from the catalog below, captured live in the engine's own order; the fake -server applies only the predicates each statement actually spells. To rebuild it, start the service and -send this DDL (the object surface reads it and never writes): +The DDL is [`docker/sqlite-init/02-libsql-object-fixture.sql`](../../docker/sqlite-init/02-libsql-object-fixture.sql). +It used to live here as a fenced block and nowhere else, which meant a reader could see the +measurement and could not re-run it; the file is what replaced that. The object-surface tests answer +from a catalog captured off a live server started from that file, and the fake applies only the +predicates each statement actually spells, so dropping a predicate from the provider widens the +population here the way it would against the real server. -```sql -CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, country TEXT DEFAULT 'TR'); -CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers, - total REAL NOT NULL, tax REAL GENERATED ALWAYS AS (total * 0.2) VIRTUAL, placed_at TEXT); -CREATE TABLE regions (region TEXT NOT NULL, year INTEGER NOT NULL, revenue REAL, - PRIMARY KEY (region, year)) WITHOUT ROWID; -CREATE TABLE archive (id INTEGER PRIMARY KEY, body TEXT) STRICT; -CREATE TABLE sqliteXledger (id INTEGER PRIMARY KEY, note TEXT); -CREATE TABLE legacy (note TEXT); -CREATE TABLE legacy_ref (id INTEGER PRIMARY KEY, note TEXT REFERENCES legacy); -CREATE TABLE shipments (id INTEGER PRIMARY KEY, order_id INTEGER REFERENCES orders(id), carrier TEXT); -CREATE TABLE badges (id INTEGER PRIMARY KEY, code TEXT UNIQUE, label TEXT); -CREATE VIRTUAL TABLE notes USING fts5(title, body); -CREATE VIEW order_summary AS SELECT c.name, o.total FROM orders o JOIN customers c ON c.id = o.customer_id; -CREATE INDEX idx_orders_customer ON orders(customer_id); -CREATE INDEX idx_orders_placed ON orders(date(placed_at)); -CREATE UNIQUE INDEX idx_customers_name ON customers(name); -CREATE TRIGGER orders_stamp AFTER INSERT ON orders - BEGIN UPDATE orders SET placed_at = datetime('now') WHERE id = NEW.id; END; -CREATE TRIGGER order_summary_guard INSTEAD OF INSERT ON order_summary - BEGIN SELECT RAISE(ABORT, 'read only'); END; +Apply it, and print the catalog it built: + +```bash +docker compose -f database-compose.yml up -d libsql # sqld on localhost:18080 +bun docker/sqlite-init/apply-to-libsql.ts http://127.0.0.1:18080 +bun docker/sqlite-init/apply-to-libsql.ts http://127.0.0.1:18080 # Turso Cloud +``` + +sqld ships no client: the image carries neither `sqlite3` nor `curl`, so the fixture needs an applier +of its own and that script is it. It sends one statement per request rather than one batch, because a +batch stops at the first failure and a half-applied fixture is worse than one that did not apply, and +it prints each statement's own outcome. The same file also builds a local database FILE: + +```bash +bun docker/sqlite-init/build-fixture.ts /tmp/demo.sqlite 02-libsql-object-fixture.sql ``` It holds one of every declared kind, all four `table_list.type` values, a generated column, a composite -primary key on a `WITHOUT ROWID` table, an `AUTOINCREMENT` table, an expression index, BOTH implicit-index -shapes (the `WITHOUT ROWID` one that only `pragma_index_list` publishes and the `UNIQUE`-on-a-ROWID-table -one that is also a `sqlite_schema` row), an `INSTEAD OF` trigger on a view, a `sqliteXledger` table, a -foreign key that names its column and two that do not, and a foreign-key parent with no primary key. -Counts: `table 10, view 1, index 3, trigger 2`. +primary key on a `WITHOUT ROWID` table, an `AUTOINCREMENT` table, an expression index, BOTH +implicit-index shapes (the `WITHOUT ROWID` one that only `pragma_index_list` publishes and the +`UNIQUE`-on-a-ROWID-table one that is also a `sqlite_schema` row), an `INSTEAD OF` trigger on a view, +a trigger whose name is also a table's, a `sqliteXledger` table, a foreign key that names its column +and two that do not, and a foreign-key parent with no primary key. +Counts: `table 10, view 1, index 3, trigger 3`. + +It is NOT the same DDL as [`01-object-fixture.sql`](../../docker/sqlite-init/01-object-fixture.sql), +which the SQLite provider's suite replays: that one holds `TEMP` and `ATTACH`ed objects sqld refuses +outright, and this one holds a `STRICT` table and the second implicit-index shape. One directory, one +splitter, one build script, one file per engine's own captured catalog. + +`ANALYZE` is absent from the file and must stay absent: measured on sqld 0.24.33, it answers +`SQL string could not be parsed: unsupported statement: ANALYZE`. + +### 6.2 Object source (#789) + +One statement answers every kind, because libSQL IS SQLite and `sqlite_schema` keeps the text the author submitted. + +```sql +SELECT s.sql AS sql + FROM sqlite_schema AS s + WHERE s.type = ? + AND s.name = ? +``` + +| Kind | `sqlite_schema.type` | `form` | `origin` | Monaco language | +| --- | --- | --- | --- | --- | +| `table` | `table` | `complete` | `stored` | `sql` | +| `view` | `view` | `complete` | `stored` | `sql` | +| `index` | `index` | `complete` | `stored` | `sql` | +| `trigger` | `trigger` | `complete` | `stored` | `sql` | + +No kind declares nothing: all four have a definition text and all four publish it. +A `VIRTUAL` table is typed `table` in `sqlite_schema`, so the `table` kind covers the FTS5 object the listing takes from `PRAGMA table_list`. + +#### What the text IS + +`form` is `complete` on every kind: each is a statement that runs as given, never a body or a bare `SELECT`. + +`origin` is `stored`, and on this engine family that is a real distinction rather than a formality. +Measured live against sqld 0.24.33: `orders` comes back carrying the twenty-one-space continuation indent of the fixture statement, exactly as it was sent, where PostgreSQL and MySQL hand back a statement rebuilt out of a catalog. +The Source tab's caption exists to keep those two apart, and if every engine reported `regenerated` the distinction would be decoration. + +The same `ALTER TABLE` caveat SQLite has applies here, and it is recorded in [`sqlite.md`](sqlite.md#what-the-text-is-and-the-one-caveat-on-stored): the engine REWRITES the stored text on a rename or an added column, so the bytes are the author's own up to the last schema change. +That is still a different fact from a regeneration. + +#### There is no refusal, and that is a CANNOT rather than an omission + +Three things could have produced one here and none of them does: + +| Candidate | Measured | +| --- | --- | +| A privilege refusal | There is no privilege system to refuse a read. A token that can query at all can read `sqlite_schema` whole | +| sqld's statement allowlist | It refuses `VACUUM`, `ANALYZE`, `ATTACH` and `PRAGMA query_only` ([§3.5](#35-the-server-refuses-four-statements-so-four-controls-are-withheld)). A plain `SELECT` from `sqlite_schema` is not in that set: this exact statement answered on sqld 0.24.33 | +| A NULL definition | `sqlite_schema.sql` is NULL for exactly one shape, an index the engine created for itself. Every listing here carries `name NOT LIKE 'sqlite\_%' ESCAPE '\'`, so no path the object tree produces addresses such a row | + +The provider still turns a NULL, an absent column or a whitespace-only text into a REFUSAL part rather than an empty definition, because an empty editor over a definition is the one failure this surface exists to prevent. +THOSE ARE THREE DIFFERENT FACTS AND THEY GET THREE DIFFERENT SENTENCES, because a refusal stating a cause that is false for the shape in front of it sends its reader somewhere there is nothing to find. +A stored NULL says the engine keeps NULL there only for an index it created for itself. +A whitespace-only text says the column holds no non-whitespace character, and claims no cause at all. +A reply carrying no `sqlite_schema.sql` column says exactly that, and says it is a fact about the read and not about the object: on this transport a wrong alias answers a row built from the column names the reply really carried, so the key is simply absent. +The sentences for those cases are OURS and not the server's, which is the exception to the rule that a refusal carries the engine's own words: the server supplies none, it simply stores NULL. + +NOTHING IN THIS PROVIDER OR ITS SUITE KEYS ON REFUSAL WORDING, and that is deliberate. +The two deployments word the identical refusal differently ([§3.2](#32-a-failed-statement-answers-http-200)), so a test that pinned either sentence would pass on one and fail on the other. + +A statement the server rejected and a credential that expired mid-session both RAISE through the provider's own mapping. +Neither becomes a refusal part: nobody answered about the object, and rendering a transport symptom as the object's own refusal would state it as a fact about the object. + +An object that is not there RAISES too, naming the last path segment. +Absence and unreadability are different facts. + +#### No escaper, no schema bind, one round trip + +Both binds are PARAMETERS, so no identifier is interpolated and this read needs no identifier escaper. + +The `type` value comes from the KIND and never from what the name happens to match, and that is behavioural rather than stylistic. +Measured live: a TRIGGER may share a name with a TABLE, so `SELECT sql FROM sqlite_schema WHERE name = 'badges'` answers TWO rows with the table's first, and a read that resolved the type from the name would hand a reader the table's DDL under the trigger's address. +[`02-libsql-object-fixture.sql`](../../docker/sqlite-init/02-libsql-object-fixture.sql) holds that object so the rule is exercised rather than asserted by statement shape. + +Unqualified `sqlite_schema` resolves to `main.sqlite_schema` and this provider declares no container level, so there is no schema bind, exactly as the index and trigger listings have none. + +ONE ROUND TRIP and ONE PART. +A libSQL object has exactly one text, so there is no batch to assemble and no second request to pay for, and nothing here splits into a specification and a body the way an Oracle or a MariaDB package does. --- diff --git a/docs/providers/mongodb.md b/docs/providers/mongodb.md index 2f54ef60..a4240c2b 100644 --- a/docs/providers/mongodb.md +++ b/docs/providers/mongodb.md @@ -330,10 +330,12 @@ than trusted. One container level, the database, and two kinds: -| Kind | Role | Source | Path | -|---|---|---|---| -| `collection` | relation, `acceptsRowWrites` | `listCollections`, every `type` that is not `view` | `[database, collection]` | -| `view` | relation | `listCollections`, `type: "view"` | `[database, view]` | +| Kind | Role | Read from | Path | Declares `hasSource` | +|---|---|---|---|---| +| `collection` | relation, `acceptsRowWrites` | `listCollections`, every `type` that is not `view` | `[database, collection]` | No | +| `view` | relation | `listCollections`, `type: "view"` | `[database, view]` | Yes, `json` | + +The `hasSource` column is [§6 Object source](#object-source-789). `acceptsRowWrites` on `collection` is the **per-kind** half and is deliberately not conjoined with this provider's engine-wide `supportsInlineRowEdit: false` ([§9](#9-capabilities--labels)). That flag @@ -445,8 +447,8 @@ either would need `collStats` or `estimatedDocumentCount` **per collection**, on and this folder is the one a person opens to see what is there. `describeObject` is where a single object's detail is paid for. -A view's `options.viewOn` and `options.pipeline` arrive on the same `listCollections` call that -classified it, so Phase 2's Source tab needs no second read. Phase 1 renders neither. +A view's `options` arrives on the same `listCollections` call that classified it, so the Source tab +below needs no second read. `describeObject` itself renders none of it. #### What `describeObjects` answers, and the one half this engine cannot bulk-read @@ -551,6 +553,153 @@ order for them is the JSON one, so a provider sorting the wrong way would pass b two **different** orders across two fresh containers holding the same fixture, so ordering is the provider's own guarantee either way rather than something inherited from the server. +### Object source (#789) + +`readObjectSource(path, kind, limit?)` answers one object's definition text. +Everything here was measured on 2026-09-13 against a live **MongoDB 8.2.12** holding the committed +fixture, [`docker/mongodb-init/01-object-fixture.js`](../../docker/mongodb-init/01-object-fixture.js), +applied through the mount by the recipe in [§11](#11-testing). + +#### Per kind + +| Kind | `hasSource` | Statement | What the text IS | Monaco language | +|---|---|---|---|---| +| `view` | Yes | the `listCollections` call this provider already makes, reading the row's `options` | `form: complete`, `origin: rendered`: the whole definition, printed as JSON **by this product** | `json` | +| `collection` | No | not read | see [the absence](#why-collection-declares-nothing) | not applicable | + +There is no second statement and no second round trip: `listCollections` answers the definition on +the same row that classifies the object, so the source read looks at exactly the set `countObjects` +and `listObjects` look at. The driver takes the name as a **value**, so there is no identifier +escaper here and nothing to quote. + +`origin` is `rendered` and not `stored`, and that is the honest arm rather than a modest one. +MongoDB stores no statement for a view: `options` is a BSON document, and the JSON a reader sees is +printed by this product. Calling it `stored` would show a reconstruction as an original. + +#### The whole `options` document, not two fields of it + +The first design of this read rendered `viewOn` and `pipeline` alone. **Measured, that is +incomplete.** A view created with a collation answers `options` carrying `collation` beside the +other two, expanded by the server from the two fields the fixture asked for to ten: + +``` +db.createCollection("dark_settings", { viewOn: "settings", pipeline: [...], collation: { locale: "tr", strength: 2 } }) +``` + +so the row comes back with `locale`, `caseLevel`, `caseFirst`, `strength`, `numericOrdering`, +`alternate`, `maxVariable`, `normalization`, `backwards` and `version`. Rendering two fields would +drop all of it while still calling the text `complete`. The whole `options` document is rendered +instead, which is also exactly what `createCollection` was given. For an ordinary view `options` +holds `viewOn` and `pipeline` and nothing else, so the common case is unchanged. +`configstore.dark_settings` in the fixture is the view that carries the collation. + +#### Extended JSON, because `JSON.stringify` loses a value in silence + +A pipeline may hold BSON values, and `JSON.stringify` renders a regular expression as `{}`. +Measured on the fixture's `configstore.dark_settings`, whose pipeline holds `/^th/i` and a date: + +| Value | `JSON.stringify` | `BSON.EJSON.stringify`, relaxed | +|---|---|---| +| `/^th/i` | `{}`, the pattern gone with no error anywhere | `{ "$regularExpression": { "pattern": "^th", "options": "i" } }` | +| `new Date("2026-01-01T00:00:00Z")` | `"2026-01-01T00:00:00.000Z"` | `{ "$date": "2026-01-01T00:00:00Z" }` | + +So the read uses `BSON.EJSON.stringify` in **relaxed** mode. Relaxed rather than canonical because +canonical prints every integer as `$numberInt`, which would make an ordinary pipeline unreadable to +buy type fidelity a view definition does not turn on. For a pipeline holding no BSON value the two +renderings are byte-identical, which is why `app.active_customers` alone cannot tell them apart and +`configstore.dark_settings` exists. + +#### The refusal is per DATABASE, not per object + +This is unusual in this fleet and it follows from the read: both kinds come from **one** +`listCollections`, so a caller who cannot run it cannot read any object in that database rather +than this one. Measured with a role holding `read` on `configstore` only, asking `app`: + +``` +not authorized on app to execute command { listCollections: 1, filter: {}, cursor: {}, +nameOnly: false, authorizedCollections: false, lsid: { ... }, $db: "app" } +``` + +That sentence is carried **unprefixed** into the part's `unavailable`, exactly as `countObjects` +carries it into `{ unavailable }`. The fixture creates the principal, so this is re-runnable rather +than a number in a report: + +```bash +# The refusal, and then the control that makes it a fact about privilege rather than the connection +mongosh -u libredb_nolist -p libredb_nolist --authenticationDatabase admin \ + --eval 'db.getSiblingDB("app").getCollectionInfos()' # not authorized on app +mongosh -u libredb_nolist -p libredb_nolist --authenticationDatabase admin \ + --eval 'db.getSiblingDB("configstore").getCollectionInfos()' # reads normally +``` + +The `lsid` in the sentence is the session id and differs per connection, which is why the suite pins +a representative sentence and asserts the provider passes the server's own words through untouched +rather than pinning the UUID. It is not routed through this provider's error mapping, which +would put this product's words in front of the server's. + +There is a **second refusal and its sentence is OURS rather than the server's**, declared here +rather than left for a reader to discover. When the read succeeds and the row it answered carries no +`viewOn` and `pipeline`, MongoDB has said nothing there is anything to carry, so the part reads: + +``` +The listCollections row MongoDB answered for in carries no "viewOn" and +"pipeline", so this has no definition to show +``` + +The reduced row that would produce it is real: measured, `listCollections` answers name and type +alone for `{ nameOnly: true, authorizedCollections: true }`, which is how a caller holding +collection-level privileges rather than a database-level `read` sees anything at all. This provider +sends **neither flag**, so on a MongoDB server that arm is unreachable and the integration suite is +what drives it. It exists because a catalog row is a DOCUMENT: a misspelt field name reads as +`undefined` rather than failing to compile, and an unguarded render would put `{}` in a reader's +editor as though it were the definition. + +#### A transport failure raises, and is never printed as the engine's refusal + +A refusal carries the sentence **the server said**. When nobody answered at all there is no such +sentence, so the read raises a `ConnectionError` naming the object and the database rather than +answering a document. Measured against **mongodb 7.6.0** and MongoDB 8.2.12, from a container +created for the measurement: + +| What happened | `error.name` | Prototype chain | Message | Treated as | +|---|---|---|---|---| +| the server refused the command | `MongoServerError` | `MongoServerError < MongoError < Error` | `not authorized on app to execute command { listCollections: 1, ... }` (`code: 13`, `codeName: Unauthorized`) | a refusal part | +| nothing listening on the port | `MongoServerSelectionError` | `MongoServerSelectionError < MongoSystemError < MongoError < Error` | `connect ECONNREFUSED 127.0.0.1:27999` | raises | +| unroutable host | `MongoServerSelectionError` | as above | `Socket 'connect' timed out after 1502ms (connectTimeoutMS: 1500)` | raises | +| client closed underneath the read | `MongoNotConnectedError` | `MongoNotConnectedError < MongoAPIError < MongoDriverError < MongoError < Error` | `Client must be connected before running operations` | raises | + +The test is the **name** rather than `instanceof`, because the integration suite replaces the whole +driver module and an `instanceof` against its export would be `instanceof undefined` there. The same +rule is written on the Redis read, which keys on `ReplyError`. + +This is scoped to the source read. `countObjects` still reports **any** failed `listCollections` as +`{ unavailable }` on every kind, which is the Phase 1 behaviour of the whole fleet rather than +something measured here; a badge is a weaker surface than an editor pane, and changing it is a +fleet-wide decision rather than one provider's. + +#### The language comes from the declaration, and a declaration missing it raises + +The part's `language` is `view`'s declared `sourceLanguage` and is never defaulted. A kind that +declared `hasSource` and no `sourceLanguage` would raise here rather than be answered `json`, so a +Source tab can never open in a Monaco mode no declaration asked for. + +#### Absence raises + +A view simply **not being in the listing** is absence on this engine. There is no error to carry, so +`readObjectSource(["app", "no_such_view"], "view")` raises a `QueryError` naming the segment rather +than answering a refusal part, which would invent an engine sentence that was never said. The kind +decides the match exactly as it does in `describeObject`, so asking for a view by the name of a +collection is a miss rather than a collection rendered as a view. + +#### Why `collection` declares nothing + +Of the three absences, this is the second: MongoDB publishes something, and this product judges it +is not a definition. A collection's `options` is a **property sheet**, a validator, a capped size or a +time series spec, rather than a definition anybody authored, and measured on 8.2.12 an **ordinary +collection's `options` is `{}`**, so a Source tab on that kind would open on nothing for the common +case. A tab that can never fill is worse than no tab. What a collection's options do carry is +reachable through `describeObject`, which is where an object's properties belong. + --- ## 7. Monitoring & health @@ -860,6 +1009,8 @@ docker exec mongosh -u -p --quiet --file /tmp/01-o # app.active_customers a view on app.customers # by_thing the SAME index name on two collections # configstore.settings a database whose name starts with "config" +# configstore.dark_settings a view with a collation and BSON in its pipeline +# libredb_nolist a user with `read` on configstore and nothing else # oddnames.{x"a, x-a, x\a} names that sort differently under JSON escaping ``` diff --git a/docs/providers/mssql.md b/docs/providers/mssql.md index 1530b8a5..c7adbde7 100644 --- a/docs/providers/mssql.md +++ b/docs/providers/mssql.md @@ -723,6 +723,162 @@ it. Measured on the fixture: `app.orders` reports `customers`, and `reporting.da one column on SQL Server (`CREATE TABLE t ()` is a syntax error), so no rows means the object is not there, and `describeObject` raises rather than rendering a dropped table as a table with no columns. +#### Object source (#789) + +`readObjectSource(path, kind, limit?)` answers one object's definition text, out of +`sys.sql_modules`. +Four of the seven declared kinds answer; three declare nothing, and that is a documented fact +rather than a gap. + +| Kind | `hasSource` | `sourceLanguage` | Parts | `form` / `origin` | +|---|---|---|---|---| +| `view` | Yes | `sql` | 1, `Definition` | `complete` / `stored` | +| `procedure` | Yes | `sql` | 1, `Definition` | `complete` / `stored` | +| `function` | Yes | `sql` | 1, `Definition` | `complete` / `stored` | +| `trigger` | Yes | `sql` | 1, `Definition` | `complete` / `stored` | +| `table` | No | - | - | the engine publishes no such text | +| `synonym` | No | - | - | the engine publishes no such text | +| `sequence` | No | - | - | the engine publishes no such text | + +The three that declare nothing are the FIRST of the two absences the design distinguishes: SQL +Server publishes no definition text for them at all. +Only the `sys.objects` types `P`, `RF`, `V`, `TR`, `FN`, `IF`, `TF` and `R` have a SQL module, and a +table, a synonym and a sequence are none of them: `sys.synonyms.base_object_name` and +`sys.sequences` hold PROPERTIES (a target name, a start value, an increment), not text. +Those properties reach the tree through `describeObject` instead. + +**The statement.** +One read, three-part named at the path's own catalog, with the schema and the object name BOUND: + +```sql +SELECT sm.definition AS definition, + CASE WHEN sm.object_id IS NULL THEN 0 ELSE 1 END AS has_module, + (SELECT MAX(CONVERT(INT, c.encrypted)) FROM [].sys.syscomments c WHERE c.id = o.object_id) AS is_encrypted +FROM [].sys.objects o +JOIN [].sys.schemas s ON s.schema_id = o.schema_id +LEFT JOIN [].sys.sql_modules sm ON sm.object_id = o.object_id +WHERE o.is_ms_shipped = 0 AND o.type IN () AND s.name = @schema AND o.name = @name +``` + +A trigger takes the same shape against `sys.triggers`, outer-joined to `sys.objects` and +`sys.schemas` only to reach its parent's schema, because `sys.objects` holds no DATABASE-scoped DDL +trigger at all. +A DDL trigger is addressed `[database, name]` and the statement then asks for `ps.name IS NULL` +instead of binding a schema. +The trigger is the ONLY kind whose schema is optional: every other statement above spells +`s.name = @schema`, so the read refuses by name when the declaration carries no schema level rather +than issuing a statement with `@schema` unbound, which SQL Server answers with Msg 137, "Must +declare the scalar variable". + +`OBJECT_DEFINITION(object_id)` is REJECTED and the reason is the same one the detail reads give: +it resolves an id in the CURRENT database and cannot be three-part named, and this is the one engine +in the fleet whose catalog level is part of every path. + +**`sys.syscomments` and NOT `OBJECTPROPERTY`, which REFUTES the row this task was given.** +The plan named `OBJECTPROPERTY(object_id, 'IsEncrypted')` for the encryption flag. +MEASURED on SQL Server 2022 RTM-CU26 (16.0.4265.3): that function resolves its object id in the +CONNECTED database whatever database a three-part name addresses. +From a session in `libredb_objects` reading `libredb_objects_two`, it answered NULL or `0`, and never +the right answer, because the id resolves against the CONNECTED database: an id that names an +encrypted module in the addressed database names something else there, or nothing. +Which of the two comes back depends on what that database holds at that id and is therefore +object-creation-order dependent, so the reproducible fact is the one that matters: `NULL` and `0` +both route to the "no VIEW DEFINITION" refusal, and an encrypted module is never reported as +encrypted. +`OBJECTPROPERTY(OBJECT_ID('db.schema.name'), 'IsEncrypted')` does not rescue it: the id resolves and +the answer is still `0`. +Verified end to end through this provider: connected to `libredb_objects_two` and reading +`libredb_objects.app.order_summary_secret`, the shipped statement reports the module as encrypted +and the same read with `OBJECTPROPERTY` in its place reports it as "no VIEW DEFINITION" instead. +Reproduce it with the fixture and the two connections; nothing else in the fixture is needed. + +`sys.syscomments` is a compatibility view carrying a deprecation notice, and it is read for the +`encrypted` BIT alone, never for its `text`: a 5045-character module is ONE +`sys.sql_modules.definition` and TWO `sys.syscomments` rows, which is also why the flag is +aggregated with `MAX` rather than joined. + +**Three causes of one NULL, and the read separates them.** +A caller without `VIEW DEFINITION` gets a ROW WITH A NULL definition, never "no row", so the read +cannot treat a NULL as an absence. +The four outcomes, in the order the provider decides them: + +| Row | Answer | +|---|---| +| `has_module = 0` | REFUSAL: "SQL Server holds no SQL module for this object: sys.sql_modules has no row for it, which is what a CLR or an extended module answers." | +| `definition` NULL and `is_encrypted = 1` | REFUSAL: "This module was created WITH ENCRYPTION: sys.sql_modules.definition is NULL for every caller and SQL Server keeps no readable text for it." | +| `definition` NULL and `is_encrypted` NULL or 0 | REFUSAL: "SQL Server answered a module row with no definition text and publishes no encryption flag for this object, which is what a caller without VIEW DEFINITION on it is shown." | +| `definition` blank | REFUSAL: "SQL Server answered an empty module text for this object, which is not a definition." | +| otherwise | the text | + +The module-less test comes first because a module-less object also has a NULL definition, so asking +about the definition first would report every CLR procedure as a privilege problem. + +THE CONTROL THAT MAKES THE SECOND AND THIRD ROWS A MEASUREMENT rather than a belief: with +`VIEW DEFINITION` granted on exactly two of four objects to the fixture's `src_probe` login, that +login's own read answered a text and `is_encrypted = 0` for the granted plain module, +`is_encrypted = 1` for the granted encrypted one, and NULL for the two it still lacked - all four +in one result set. +So the flag is visible exactly where `VIEW DEFINITION` is, per OBJECT, which is what tells "this is +encrypted" apart from "you cannot see this". + +**These four sentences are OURS, not the engine's, and that is a deliberate exception.** +The repository's rule is that a refusal carries the server's own sentence unprefixed. +There is nothing to carry here: the read SUCCEEDS and answers a NULL, so SQL Server raises nothing. +The only sentences the engine has are `sp_helptext`'s, and they cannot tell these causes apart for +the caller who needs them told apart: measured, a login without `VIEW DEFINITION` gets the identical +`Msg 15197 ... There is no text for object ''` for a plain module and for an encrypted one, +while a privileged caller gets a PRINT with no message number at all, +`The text for object '' is encrypted.`, and an absent object answers `Msg 15009`. +Carrying the engine's words would collapse exactly the distinction this read exists to keep, so each +sentence names the catalog view and the column the decision came from instead. + +**Two refusals that degrade, disclosed rather than left to be found.** + +- An ENCRYPTED DDL trigger reports the third sentence rather than the second. A database-scoped + trigger has no `sys.objects` row and therefore no `sys.syscomments` row either, readable or + encrypted, so no encryption flag exists for one. A readable DDL trigger is unaffected: its + definition is there and the text arm answers first (`ddl_audit` is 325 characters on the fixture). +- An ENCRYPTED module read by a caller without `VIEW DEFINITION` also reports the third sentence, + which is the honest answer: that caller has not been shown the flag and cannot establish + encryption at all. + +**`sql` and not `tsql`.** +MEASURED in #789: `tsql` is not among the 89 language ids the installed monaco-editor 0.56.0 bundle +registers, and an unregistered id degrades to plain text silently. +So a T-SQL definition renders under the generic `sql` grammar, and T-SQL-only spellings +(`OUTER APPLY`, `MERGE ... OUTPUT`, `@variable`) draw as plain identifiers. +That is a compromise, recorded here rather than hidden. + +**`complete` and `stored`, both measured.** +`sys.sql_modules.definition` is a statement that runs as given, and it is the AUTHOR'S bytes rather +than a reconstruction: on the fixture every module carries its batch's leading newline, and +`app.order_total`'s definition begins `-- FN: a scalar function.`, the comment line that preceded +its `CREATE`. +That is the same fact the `sp_rename` caveat is about: renaming an object does not rewrite the +stored text, so a renamed module's definition can still name the old name. + +**Escaping.** +The catalog is an IDENTIFIER position with nothing to bind, so it goes through `escapeIdentifier`'s +`]`-doubling - the METHOD, and not a fourth inline copy of it. +The schema and the object name are BINDS and never reach the statement text. + +**Absence RAISES, and never answers a refusal part.** +Zero rows is what a name nothing holds answers AND what a name holding an object of another KIND +answers, because the kind the caller asked under is part of the address: measured, the view +statement answers zero rows for the table `app.orders`, which is a real collision on this engine. + +**What the fixture cannot reach, stated in advance.** +A module-less object of a source-bearing KIND is a CLR or extended module (`PC`, `X`, `FS`, `FT`, +`AF`, `TA`), and registering one needs a compiled assembly and `sp_configure 'clr enabled'`, which +[`docker/mssql-init/01-object-fixture.sql`](../../docker/mssql-init/01-object-fixture.sql) does not +ship. +The row shape that arm reads is the one the LEFT JOIN produces for any `sys.objects` row with no +module beside it, which the fixture DOES produce, measured, for its tables, its synonym and its +sequence: `has_module = 0`, `is_encrypted` NULL. +An empty definition text is not engine-reachable either; every module the server stored here begins +with a newline and a `CREATE`. +Both arms are driven in the suite instead, and both are mutation-tested. + #### `describeObjects()` describes a whole folder in five statements (#789) `describeObjects(container, kind, limit?)` answers columns, indexes and foreign keys for EVERY object diff --git a/docs/providers/mysql.md b/docs/providers/mysql.md index d5e9bc14..8331d5ec 100644 --- a/docs/providers/mysql.md +++ b/docs/providers/mysql.md @@ -73,10 +73,19 @@ distinguishable from a broken read at all. `information_schema`, `PROCESSLIST`, FORMAT=JSON`, schema introspection, sizes and row counts are unaffected. Start the server with `performance_schema=ON` to get the monitoring figures. -**MariaDB declares two object kinds MySQL does not have.** `package` and `sequence` reach the object -browser through `objectKinds`, which on this provider is resolved from the `VERSION()` string rather -than being a constant, see [§7.1](#71-the-object-surface-789). That is the third of the three -behaviours on this page that are this provider's code and not the engine's. +**MariaDB declares two object kinds MySQL does not have.** `package` and `sequence` are declared by +`objectKinds`, which on this provider is resolved from the `VERSION()` string rather than being a +constant, see [§7.1](#71-the-object-surface-789). That is the third of the three behaviours on this +page that are this provider's code and not the engine's. + +Those two folders are **not drawn in the standalone tree today**, and this sentence used to say they +reach the object browser, which was wrong. `POST /api/db/provider-meta` reads capabilities off a +provider it never connects (#457), so the client's copy of the declaration is the MySQL six and the +version-resolved pair never arrives. The declaration itself is correct about the engine and is +therefore kept, source read included (#789): a connected provider answers both kinds, and the API +route reaches them. The gap is the client's copy of the declaration, it is filed in +[`docs/BACKLOG.md`](../BACKLOG.md), and it is a Phase 1 seam rather than anything about definition +text. The one metric that goes the other way is `deadlocks`: it comes from the `Innodb_deadlocks` row of `SHOW STATUS`, which MariaDB publishes and MySQL does not, so it is the single performance figure a @@ -915,6 +924,158 @@ DELIMITER ; CALL bulk26a1.seed(); ``` +#### Object source (#789) + +`readObjectSource(path, kind, limit?)` answers one object's definition text as a document of named +parts. **Every kind either server declares can answer**, which makes this the one provider in the +fleet with no kind that declares nothing: MySQL's six and MariaDB's eight each have a `SHOW CREATE` +form. The Monaco language id is `mysql` on all eight; `mysql` is an id the installed monaco-editor +0.56.0 bundle really registers, unlike `plsql`, `tsql` and `cql`. + +Measured 2026-09-13 on **MySQL 26.7.0** and **MariaDB 12.3.2** against the two committed fixtures. + +| Kind | Statement | Reply column | `form` | `origin` | +|---|---|---|---|---| +| `table` | `SHOW CREATE TABLE` | `Create Table` | `complete` | `regenerated` | +| `view` | `SHOW CREATE VIEW` | `Create View` | `complete` | `regenerated` | +| `procedure` | `SHOW CREATE PROCEDURE` | `Create Procedure` | `complete` | `stored` | +| `function` | `SHOW CREATE FUNCTION` | `Create Function` | `complete` | `stored` | +| `trigger` | `SHOW CREATE TRIGGER` | `SQL Original Statement` | `complete` | `stored` | +| `event` | `SHOW CREATE EVENT` | `Create Event` | `complete` | `stored` | +| `sequence` (MariaDB) | `SHOW CREATE SEQUENCE` | **`Create Table`** | `complete` | `regenerated` | +| `package` (MariaDB) | `SHOW CREATE PACKAGE` **and** `SHOW CREATE PACKAGE BODY`, two parts | `Create Package`, `Create Package Body` | `complete` | `stored` | + +**The reply column is per statement, never per position.** The four routine forms disagree with each +other and with the table forms, and the sequence's is the one a reader would get wrong: it answers +`Table` and `Create Table`, NOT `Sequence` and `Create Sequence`, because a MariaDB sequence is a +table underneath. That is the same fact that puts it in `information_schema.TABLES` with +`TABLE_TYPE = 'SEQUENCE'`. A provider reading the guessable column finds `undefined` and emits a +refusal over a definition the server really returned, which is why every column above is pinned by a +test asserting the READ TEXT rather than only the statement. + +**`origin` is split and it is a measurement.** A procedure, a function, a trigger, an event and a +MariaDB package come back as the author's own bytes, indentation included, so they are `stored`. A +table, a view and a MariaDB sequence are rebuilt from the dictionary: the fixture's +`CREATE TABLE customers (id INT NOT NULL, ...)` comes back as ``CREATE TABLE `customers` (`id` int +NOT NULL, ...) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4``, with backquoting and an `ENGINE=` clause +nobody typed, and `CREATE SEQUENCE invoice_number_seq START WITH 1` comes back carrying `minvalue`, +`maxvalue`, `cache` and `nocycle`. They are `regenerated`, and a reader must never be shown a +reconstruction as an original. + +**A MariaDB package needs no `sql_mode=ORACLE`, and this provider does not set one.** MEASURED on +12.3.2: `SHOW CREATE PACKAGE` and `SHOW CREATE PACKAGE BODY` returned the full text under the image's +default `sql_mode` +(`STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION`), +byte-identical to the same statements after `SET SESSION sql_mode='ORACLE'`. This is written down +because the belief that ORACLE mode is needed is easy to re-derive from the reply itself: the row's +own `sql_mode` COLUMN carries +`PIPES_AS_CONCAT,ANSI_QUOTES,IGNORE_SPACE,ORACLE,...`, which is the mode the package was CREATED +under, a property of the stored object rather than a requirement on its reader. `SET sql_mode = +'ORACLE'` IS still required to `CREATE` a package, which is why the fixture sets it. + +**A package is two statements and one node, specification first.** The order is the engine's own +asymmetry: a body cannot exist without a specification (`CREATE PACKAGE BODY` with no spec answers +`ERROR 1305`), and a specification can exist without a body. So reading the spec FIRST is what tells +a missing body apart from a missing package. A spec that is absent raises; a body that is absent +drops its part and the document carries one. `app.spec_only_pkg` in the MariaDB fixture is that +object. The two part ids and labels are the ones the Oracle provider uses, `spec` / +"Package specification" and `body` / "Package body", so a reader moving between the two engines reads +one vocabulary. + +##### The refusals, and which words are whose + +A caller holding `GRANT EXECUTE ON app.*` and nothing else is the measured refusal case, and it +splits in two. The fixtures create that user as `src_probe`, and it is **not reachable from the +primary connection**: the integration suite drives it by answering the measured errors from the +fixture rather than by connecting as `root`, and a live check needs a second connection as +`src_probe` / `src_probe`. + +| Statement | What that caller gets | +|---|---| +| `SHOW CREATE PROCEDURE` / `FUNCTION` / `PACKAGE` / `PACKAGE BODY` | a ROW whose body column is **NULL** | +| `SHOW CREATE TABLE` | `ERROR 1142 SHOW command denied to user 'src_probe'@'localhost' for table 'orders'` | +| `SHOW CREATE VIEW` | `ERROR 1142 SELECT command denied to user 'src_probe'@'localhost' for table 'order_summary'` (this is the `SHOW VIEW` plus `SELECT` requirement in the server's own words) | +| `SHOW CREATE TRIGGER` | `ERROR 1227 Access denied; you need (at least one of) the TRIGGER privilege(s) for this operation` | +| `SHOW CREATE EVENT` | `ERROR 1044 Access denied for user 'src_probe'@'%' to database 'app'` | +| `SHOW CREATE SEQUENCE` | `ERROR 1142 SHOW command denied ...` | + +Each raised sentence is carried into the part VERBATIM and unprefixed, never through +`mapDatabaseError`, and the reason to keep it verbatim rather than rebuild it is in the table: +MariaDB 12.3.2 qualifies the table name in 1142 and MySQL 26.7.0 does not. + +**The NULL is the one refusal on this engine whose words are OURS**, because the server utters none. +The part says that the row's body column is NULL, that this is how the server reports a definition +the connected user may not read, and that the server supplied no sentence of its own. An empty or +whitespace-only text takes the same arm: an empty definition is not a definition, and it must never +reach an editor buffer as a blank document. This case is the direct refutation of the Phase 1 sketch's +claim that MySQL and MariaDB have "no unreadable case". + +**A caller holding nothing at all never reaches this read.** MEASURED: that caller is told +`ERROR 1305 (42000) PROCEDURE order_archive does not exist`, which is byte-identical to what a +genuinely absent object answers, and it sees no row for the routine in `information_schema.ROUTINES` +either, so the tree never lists the object. It is deliberately not modelled as a refusal. + +##### Absence RAISES, and the sentence is ours + +| Statement, against a name nothing holds | errno and the server's sentence | +|---|---| +| `SHOW CREATE TABLE` / `VIEW` / `SEQUENCE` | 1146 `Table 'app.no_such_table' doesn't exist` | +| `SHOW CREATE PROCEDURE` / `FUNCTION` / `PACKAGE` / `PACKAGE BODY` | 1305 `PROCEDURE no_such_procedure does not exist` | +| `SHOW CREATE TRIGGER` | 1360 `Trigger does not exist` | +| `SHOW CREATE EVENT` | 1539 `Unknown event 'no_such_event'` | +| `SHOW CREATE VIEW app.orders` (a name of another kind) | 1347 `'app.orders' is not VIEW` (MySQL) / `is not of type 'VIEW'` (MariaDB) | +| `SHOW CREATE SEQUENCE app.orders` | 4089 `'app.orders' is not a SEQUENCE` | + +A `QueryError` naming the object and its database is raised, never a document and never a refusal +part. The sentence is OURS here and 1360 is why: `Trigger does not exist` names neither the object +nor the database, so a message that named nothing could not tell a reader which read failed. The +1347 and 4089 rows are a live case rather than a defensive one, because a name really can address +objects of two kinds in one database on this engine (see the namespace measurement above). + +Anything that is neither classified errno RAISES through `mapDatabaseError`, and the narrowness is +the point: a transport failure is nobody answering at all, and rendering "Lost connection to MySQL +server" in the Source pane as this object's own refusal would present a symptom as a fact about the +object. + +##### The escaper, and why a bind is not available + +`SHOW CREATE ...` has NO parameterised form, so this is one of the three engines in the fleet where a +caller-supplied name reaches statement TEXT. The address is built with +`SQLBaseProvider.escapeIdentifier` +([`sql-base.ts`](../../src/lib/db/providers/sql/sql-base.ts)), which doubles the backtick, and +doubling alone is SUFFICIENT here rather than assumed to be. Measured on MariaDB 12.3.2: + +```sql +CREATE TABLE app.`bs_one\` (id INT); +SELECT TABLE_NAME, LENGTH(TABLE_NAME) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = 'app' AND TABLE_NAME LIKE 'bs%'; -- bs_one\ 7 +SHOW CREATE TABLE app.`bs_one\`; -- answers the table +CREATE TABLE app.`tick``y` (id INT); +SHOW CREATE TABLE app.`tick``y`; -- round-trips as `tick``y` +``` + +The backslash is a LITERAL character inside a backtick-quoted identifier and the closing backtick +still closed the identifier, which is the direct contrast with ClickHouse, where a backslash IS an +escape in both quoting forms and the shared escaper is unsafe. Those two objects are NOT in the +mounted fixture, deliberately: adding a table would move the `table` count and the MariaDB +collation-ordering measurement recorded under `describeObjects()` above, neither of which this work +re-measured. The escaper is pinned in the suite by a statement-text assertion instead, and the two +statements above reproduce the engine measurement in a scratch database. + +##### The trigger's parent segment is an address, not a bind + +A trigger's path is `[database, table, trigger]`, and `SHOW CREATE TRIGGER` addresses +`.`. The parent is not in the statement because a trigger name is unique per +DATABASE on this engine (measured, `ER_TRG_ALREADY_EXISTS` above), so it is part of the address the +tree draws rather than part of the read. + +##### MariaDB's `package` and `sequence` declare source and are not reachable from the tree + +Both kinds declare `hasSource`, and neither folder is drawn in the standalone tree today, for the +`provider-meta` reason in [§1.1](#11-mariadb-and-the-other-mysql-protocol-engines). The declaration +is kept because it is true about the ENGINE: a connected provider answers both kinds and the source +route reaches them. Withholding it would be a second wrong declaration rather than a safer one. + #### The fixture, and running it [`docker/mysql-init/01-object-fixture.sql`](../../docker/mysql-init/01-object-fixture.sql) and @@ -929,7 +1090,13 @@ open against, and a cross-database foreign key from `app.orders` into `reporting `DELIMITER` in those files is a CLIENT command and is correct there because the `mysql` client is what runs them. It must never be sent through mysql2, which takes one statement per call and has no notion of it. `SET sql_mode = 'ORACLE'` is REQUIRED for `CREATE PACKAGE` and rewrites the grammar of -everything after it, which is why it is the last thing in the MariaDB file. +everything after it, which is why it is the last thing in the MariaDB file. Reading a package needs +no such mode; see the object source section above. + +Both files also create `src_probe`, a user holding `GRANT EXECUTE ON app.*` and nothing else, which +is the source read's measured refusal case, and the MariaDB file adds `app.spec_only_pkg`, a package +specification with no body, which is the one-part shape of the source document. Neither is reachable +from the primary connection: connect as `src_probe` / `src_probe` to see the refusals. --- diff --git a/docs/providers/opensearch.md b/docs/providers/opensearch.md index 995cf74c..9ac8bd5e 100644 --- a/docs/providers/opensearch.md +++ b/docs/providers/opensearch.md @@ -902,8 +902,10 @@ no alias is listed with a **present**, empty map. - **No view, function, procedure or trigger.** Neither product's SQL surface has `CREATE VIEW`, and OpenSearch's grammar contains no `CREATE` statement of any kind (`CREATE TABLE t (id BIGINT)` answers `SQLFeatureNotSupportedException`, *"Query must start with SELECT, DELETE, SHOW or - DESCRIBE"*). Elasticsearch 9.4 adds an **ES|QL views API as a technical preview**; a preview surface - gets no folder, and Phase 2 is where it is revisited. + DESCRIBE"*). Elasticsearch 9.4 adds an **ES|QL views API as a technical preview**, and #789 Phase 2 + answered the question it left open by leaving it out: a preview surface gets no folder, so there is + no kind and nothing for the source read to read. It would also be a kind only ONE of the two + products this implementation serves could ever hold, and this one is the other. - **No stored script.** Both products have them and neither has a list-all API: `GET /_scripts` is refused outright (*"Invalid index name [_scripts]"*), only get-by-id exists. An object that cannot be enumerated cannot be a tree node - the same call Redis's `EVAL` scripts got. @@ -978,7 +980,11 @@ docker exec libredb-opensearch bash /opt/search-init/01-object-fixture.sh It creates one instance of every declared kind: the index `probe_orders`, the alias `probe_orders_alias`, the ingest pipeline `probe_pipeline`, the composable index templates -`probe_template` and `probe_stream_template`, and the data stream `probe_stream`. +`probe_template` and `probe_stream_template`, and the data stream `probe_stream`. It creates two more +ingest pipelines for the source read (#789), each carrying a claim this doc makes: `probe_json_edges` +holds the values a JSON re-serialisation changes, and `probe pipe/slash` is named so that the +percent-encoding is exercised by an object rather than by an argument. The script's own header says +what each object is for. **The one measured difference between the two products, and it is not in the declaration.** The object surface's five kinds, their roles, their paths and their columns were driven against a live @@ -994,6 +1000,124 @@ also has more to do here for a second measured reason: a stock node ships `.plug `.opensearch-sap-log-types-config` and `top_queries--`, and the last carries no dot at all. See [elasticsearch.md](elasticsearch.md) for the other half of that sentence. +#### Object source (#789) + +`readObjectSource(path, kind, limit?)` answers **one part** holding one object's definition, and two +of the five kinds declare it: `pipeline` and `template`, both `sourceLanguage: "json"`. ONE +declaration constant, `SEARCH_OBJECT_KINDS`, serves both type-ids, so this section and +[elasticsearch.md](elasticsearch.md) describe the same code - and every claim below was measured on +**this** product, on OpenSearch 3.8.0 on 2026-09-13, rather than carried over. + +| Kind | Request | The text | `form` | `origin` | +|---|---|---|---|---| +| `pipeline` | `GET /_ingest/pipeline/` | the value under the `` key | `complete` | `rendered` | +| `template` | `GET /_index_template/` | the `index_template` of the entry whose `name` matches | `complete` | `rendered` | + +**Why `index`, `alias` and `stream` declare nothing.** + +- **`index`.** `GET /` answers settings the **server** wrote - `index.uuid`, `creation_date`, + `version.created`, `provided_name` - so what a Source tab would show is not a definition anybody + could re-apply, and the round trip from that answer back to a `PUT` that recreates the index could + not be established. +- **`alias`.** One alias over N indices has **one definition per index** and a different create shape + (`POST /_aliases` with an actions array), while the tree deliberately deduplicates those N rows to + one object. There is no single text belonging to the row that exists. +- **`stream`.** A data stream's definition **is** the matching index template, which is a different + object in a different folder of the same tree. Showing it here would present another object's + definition as this one's. + +**The definition is unwrapped, and the key is matched EXACTLY.** A pipeline arrives as +`{"": {...}}` and a template as `{"index_templates":[{"name":..., "index_template":{...}}]}`, so +rendering the answer would show a reader a wrapper around the thing they opened. Taking "the only +key" or entry zero instead is worse, and the reason is measured on this product: a `*` in the name is +a **wildcard** on both endpoints, `GET /_ingest/pipeline/probe*` answers HTTP 200 carrying +`probe_pipeline`, `GET /_index_template/probe*` answers **two** entries, and `%2A` is decoded before +the match so encoding does not help. The read therefore takes the value under the exact name asked +for, and a name the answer does not carry is an **absence**. + +**Absence RAISES, and the two endpoints spell it differently.** `GET /_ingest/pipeline/no_such` +answers HTTP **404** with the body `{}` and `GET /_index_template/no_such` answers HTTP **404** with +the **full error envelope** (`resource_not_found_exception`, *"index template matching [no_such] not +found"*, in the core REST layer's snake_case rather than the SQL plugin's Java class names). Both mean +the object is not there, so absence is decided on the **status** for a named object and the read +raises a `QueryError` naming it. That is deliberately not the rule the listings follow: a listing's +404 has a second meaning here more than anywhere, because a stock node with no pipeline answers it, +so a listing may read a 404 as empty only while the body is a payload. + +**Only an answer from the cluster is a refusal.** A denied endpoint, a fault the cluster named, or a +body this client could not read becomes a refusal part carrying the cluster's own sentence, +unprefixed - with one exception, the **denial**, whose sentence the transport composes from the +status because no 401 or 403 body could be captured (see CANNOT below). A dropped socket, an expired +client deadline and a cancellation **raise** instead: nobody answered, and *"connect ECONNREFUSED"* +printed in the Source pane as this object's own refusal has no raise, nothing to retry and nothing +distinguishing it from a real denial. All eight arms of that split are driven in the two integration +suites, and `unsupported` is driven from THIS suite because it is this product's fault table that +carries a name mapping to it (`SQLFeatureNotSupportedException`). + +**An unreadable body is a refusal and never an absence.** Where the answer is HTTP 200 and is not the +shape this client parses - the wrapper is not an object, the value under the name is not an object, +`index_templates` is not an array, an entry carries no `name` - the read reports *"OpenSearch answered +an ingest pipeline definition the client could not read"* (or *"an index template definition"*). +Skipping the entry or reading it as nothing would say *"No OpenSearch template named X"* about a row +the tree is currently showing, which is a claim about the cluster where the truth is a claim about +this client. + +**An EMPTY definition cannot arrive, measured rather than assumed.** Design guarantee 2 asks for a +refusal part where an engine answers empty or whitespace-only text, and neither endpoint here can +produce one: `PUT /_ingest/pipeline/` with `{}` is HTTP **400** (`parse_exception`, *"[processors] +required property is missing"*) and `PUT /_index_template/` with `{}` is HTTP **400** +(`illegal_argument_exception`, *"Required [index_patterns]"*). The smallest definitions either +endpoint stores are `{"processors":[]}` and `{"index_patterns":["..."]}`, both `PUT` 200 and both read +back with those members - and this product answers the minimal template back verbatim where +Elasticsearch adds `"composed_of": []` to it. So the rendered text is always a JSON object with at +least one member. Measured on 3.8.0 on 2026-09-13 and re-runnable with those `PUT`s. + +**A refusal is per ENDPOINT.** These are two separate endpoints and the security plugin grants +privileges per endpoint, so a pipeline read can be denied while a template read answers. + +**Neither refusal can be produced on the compose service: CANNOT.** `DISABLE_SECURITY_PLUGIN` is +`true` there, a bogus `Basic` header is IGNORED and the cluster answers HTTP 200 (measured +2026-09-13), so no 401 or 403 body exists to capture from this container, and enabling the plugin +would change the image configuration every other measurement here rests on. The suite drives the +refusal shape instead, and a denied read carries `"OpenSearch refused the credentials (HTTP 403)"`. + +**The name is percent-encoded.** A pipeline name may hold a space **and** a slash on this product too +(both `PUT` 200), and `GET /_ingest/pipeline/probe%20pipe%2Fslash` answers the object. The escaper is +`encodeURIComponent`, and `docker/search-init/01-object-fixture.sh` creates `probe pipe/slash` so the +escaping is exercised by an object rather than by an argument. A template name may **not** hold a +space, so the pipeline endpoint is the only place such an object can exist. + +**What the renderer does to the cluster's bytes, measured, because `origin: "rendered"` is a promise +about exactly this.** There is no extended-JSON writer for a REST payload, and the definition is a +sub-document of the answer rather than the answer, so the text cannot be the bytes the cluster sent. +It is `JSON.parse` followed by `JSON.stringify` with a two-space indent, and three things change. +Measured on 3.8.0 on 2026-09-13 against the fixture's `probe_json_edges` pipeline: + +| The cluster answered | This product renders | +|---|---| +| `9223372036854775807` | `9223372036854776000` | +| `1.0E30` | `1e+30` | +| a map keyed `zz, 10, 2, aa` | the same map keyed `2, 10, zz, aa` | + +Nothing is **dropped** - every value is present, which is what keeps `form: "complete"` true - but a +long past 2^53 loses precision, an exponent is re-spelled, and integer-like keys are hoisted ahead of +the others by JavaScript's own property order. A definition holding such a value must not be copied +out of the Source pane and `PUT` back unread. + +**One fixture line, two different definitions, and this is the product difference the source read +adds.** `docker/search-init/01-object-fixture.sh` writes `"data_stream": {}` into +`probe_stream_template` on both products, and each expands it its own way: this one answers +`"data_stream":{"timestamp_field":{"name":"@timestamp"}}` and Elasticsearch answers +`"data_stream":{"hidden":false,"allow_custom_routing":false}` (both measured 2026-09-13). So the two +integration suites pin **different** expected texts for that object on purpose. The other measured +difference is the one this section's neighbours already record: this product also refuses `_meta` on +an ingest pipeline outright (*"pipeline [probe_json_edges] doesn't support one or more provided +configuration parameters [_meta]"*, HTTP 400) where Elasticsearch accepts it, which is why the +fixture's integer-like keys live inside a processor's value. + +**The bound is the caller's**, applied through the shared `applySourceBound`, and a text that fits is +never marked. + --- ## 7. Monitoring & health diff --git a/docs/providers/oracle.md b/docs/providers/oracle.md index 86e518db..71b88d64 100644 --- a/docs/providers/oracle.md +++ b/docs/providers/oracle.md @@ -1228,12 +1228,271 @@ END; / ``` +### Object source (#789) + +`readObjectSource(path, kind, limit?)` answers ONE object's definition text as a document of named +parts, each part either readable text or Oracle's own reason there is none. +All NINE declared kinds have one, so this provider has no "declares nothing" list. + +| Kind | Statement | What the text IS | Parts | +|---|---|---|---| +| `table` | `DBMS_METADATA.GET_DDL('TABLE', :name, :owner)` | complete, regenerated | 1, `definition` | +| `view` | `GET_DDL('VIEW', ...)` | complete, regenerated | 1, `definition` | +| `materialized_view` | `GET_DDL('MATERIALIZED_VIEW', ...)` | complete, regenerated | 1, `definition` | +| `synonym` | `GET_DDL('SYNONYM', ...)` | complete, regenerated | 1, `definition` | +| `sequence` | `GET_DDL('SEQUENCE', ...)` | complete, regenerated | 1, `definition` | +| `package` | `GET_DDL('PACKAGE_SPEC', ...)` then `GET_DDL('PACKAGE_BODY', ...)` | complete, regenerated | 1 or 2, `spec` then `body` | +| `procedure` | `GET_DDL('PROCEDURE', ...)` | complete, regenerated | 1, `definition` | +| `function` | `GET_DDL('FUNCTION', ...)` | complete, regenerated | 1, `definition` | +| `trigger` | `GET_DDL('TRIGGER', ...)` | complete, regenerated | 1, `definition` | + +The metadata type comes from the translation table the provider already ships for the count and the +listings, so the two vocabularies cannot drift: `ALL_OBJECTS.OBJECT_TYPE` writes them with spaces +(`MATERIALIZED VIEW`, `PACKAGE BODY`) and `GET_DDL` takes them with underscores +(`MATERIALIZED_VIEW`, `PACKAGE_BODY`). + +That shared table is what makes the two vocabularies drift-proof, and it is also what makes ONE +edit able to break nine reads at once, so the nine spellings above are pinned in +`tests/integration/db/oracle-provider.test.ts` as a LITERAL list captured from the live server +rather than read back out of the provider's own table. +A wrong spelling is not a soft failure: `GET_DDL` answers an unknown `object_type` with +`ORA-31600: invalid input value BOGUS_TYPE for parameter OBJECT_TYPE in function GET_DDL`, so the +Source tab for every object of that kind fails. + +**`form` is `complete` and `origin` is `regenerated`, on every kind, and both are measurements.** +`GET_DDL` answers a statement that runs as given, never a body or a bare SELECT. +It is not the author's bytes either: the fixture's +`CREATE OR REPLACE FUNCTION app.app_order_total(p_id NUMBER)` comes back as +`CREATE OR REPLACE EDITIONABLE FUNCTION "APP"."APP_ORDER_TOTAL" (p_id NUMBER)`, with a keyword the +author never typed and a qualification the author never wrote, so a reader is never shown a +reconstruction as an original. + +**Nothing is interpolated and no identifier escaper is involved.** +The metadata type, the object name and the owner are all three BINDS (`:1`, `:2`, `:3`), so a +caller-supplied name never reaches statement text on this path. +The owner is the segment the DECLARATION assigns to the `schema` container level and the object name +is the LAST path segment; neither is read by a literal index. + +**The Monaco language id is `sql`, and that is a compromise this provider states rather than hides.** +MEASURED on the installed monaco-editor 0.56.0: `plsql` is not among the 89 language ids the bundle +registers, and an unregistered id degrades to plain text SILENTLY, with no throw and nothing +observable. A PL/SQL body therefore renders under the SQL grammar, which highlights the DML and +misses `IS`, `BEGIN`, `EXCEPTION` and the block structure. + +#### A package is TWO parts, and a package with no body is ONE + +`GET_DDL('PACKAGE', ...)` retrieves the specification AND the body together in one CLOB, and this +provider deliberately does not use it. Two round trips are paid instead, for three reasons that one +concatenated CLOB cannot express: + +- a body may be ABSENT, and a concatenation cannot say so. `APP_SPEC_ONLY_PKG` in the fixture is that + state, and it emits ONE part. The missing body is neither a refusal nor an error: nobody ever wrote + it, so there is nothing to refuse. +- a body may be WRAPPED while the specification is not, and one CLOB gives the reader neither + honestly. `APP_WRAPPED_PKG` in the fixture is that state: a readable `spec` part beside a refused + `body` part. +- the two halves carry independent `STATUS` values, which `APP_BROKEN_PKG` exhibits. + +#### A kind declaring source and no `sourceLanguage` RAISES + +`readObjectSource` refuses with +`Oracle declares readable source for the kind "" and no sourceLanguage to render it with`, before +a connection is taken from the pool. There is no fallback to a literal `sql`: an unregistered or absent +Monaco id degrades to plain text with no throw and nothing observable, so a fallback would hide a +deleted declaration behind a Source tab that had quietly stopped highlighting. All nine declared +languages are pinned by the isolated census +(`tests/isolated/object-source-declarations.test.ts`), so the only way to reach this arm is a +declaration somebody removed. + +#### ORA-31603 says "not found in schema" for an object you merely cannot read + +This is the single most important thing to know about this surface, because since #765 the tree lists +EVERY owner, so opening another schema's object is the ordinary path rather than an edge case. + +MEASURED on Oracle XE 21.3.0.0.0 as `APP`, which holds `SELECT` on `REPORTING.REPORT_DAILY` and no +catalog role: + +``` +SQL> SELECT DBMS_METADATA.GET_DDL('TABLE','REPORT_DAILY','REPORTING') FROM DUAL; +ORA-31603: object "REPORT_DAILY" of type TABLE not found in schema "REPORTING" +``` + +The identical error, word for word, comes back for `REPORTING.NO_SUCH_TABLE`, which really does not +exist. Oracle's own message cannot tell the two apart, and shipping it unqualified tells a user their +objects are gone. + +**The code is read off `errorNum`, not off the message.** node-oracledb carries the Oracle error +number as a numeric `errorNum` on the error it rejects with, in both modes: thin assigns it in +`lib/thin/protocol/protocol.js` (`err.errorNum = message.errorInfo.num`) and every prebuilt thick addon +under `build/Release` exports the same property name +(`strings oracledb-6.10.0-linux-x64.node | grep -x errorNum`), both checked against oracledb 6.10.0. +Scanning the MESSAGE for `ORA-31603` would make a failure that merely quotes that text, a wrapped +error or a logged one, look like a missing object, and the second question would then be asked about +the wrong fact. An error carrying a different `errorNum` raises even when its text quotes this code. +An error carrying no numeric `errorNum` at all, which is what a rejection composed outside the driver +looks like, still falls to the text scan, and that arm is asked second. + +So on ORA-31603 the provider asks a SECOND question: + +```sql +SELECT 1 FROM ALL_OBJECTS WHERE OWNER = :1 AND OBJECT_NAME = :2 AND OBJECT_TYPE = :3 +``` + +- a row EXISTS: the object is there and the READ was refused. The part carries an `unavailable` + holding Oracle's own ORA-31603 sentence whole and first, then the fact that settles which of the + two it is. +- no row: the object is genuinely absent to this session, and the read RAISES a `QueryError` naming + the object. It never answers a document and never answers a refusal. + +**`ALL_OBJECTS` by name, and never `DBA_OBJECTS`.** `ALL_OBJECTS` is privilege-filtered, so it answers +"can THIS caller see it", which is the question being asked. `DBA_OBJECTS` would answer "does it exist +anywhere", turning the disambiguation into a cross-schema existence oracle over objects the caller has +no grant on at all, and it needs a catalog role most callers do not hold, so it would also fail for the +very sessions this path exists to serve. + +The type is bound in the DICTIONARY spelling, which is the column `ALL_OBJECTS` publishes. Binding the +metadata spelling would answer NO ROW for every object of the three kinds whose two spellings differ, +turning every privilege refusal on a materialized view or a package body into a false claim of absence. + +**What the refusal drops, and why that is a selection rather than a rewrite.** node-oracledb composes +that error as ten lines: the `ORA-31603` line, then nine `ORA-06512: at "SYS.DBMS_METADATA", line 6781` +frames, then a `Help:` link. The frames are a PL/SQL backtrace of line numbers inside Oracle's own +package and say nothing about the object, and the refusal pane renders the engine's sentence in full, +so leaving them in would bury the disambiguation under nine lines of `SYS` internals. Not one word of +Oracle's is changed, reordered or paraphrased; the `ORA-06512` frames are dropped and everything else, +the help link included, is kept. Only the ORA-31603 path is filtered: every other failure raises +through `mapDatabaseError` with its message untouched. + +#### Wrapped PL/SQL: the detection rule, and why it is a POSITION + +A unit created through `DBMS_DDL.CREATE_WRAPPED` is stored as the encoder's output, and `GET_DDL` +hands that output over with no error at all. A provider that passed it to an editor would show +something that is not a definition and could not say so. + +**THE RULE**, established by probe 7 on Oracle XE 21.3.0.0.0 and documented nowhere Oracle publishes: +a unit's definition text is WRAPPED if and only if the token immediately following the CLOSING DOUBLE +QUOTE of its quoted name in the `DBMS_METADATA` header is the bare keyword `wrapped`, +case-insensitive, AND the next physical line is the wrap format marker, matching `^[a-z][0-9]{6}$` +(`a000000` on 21.3.0.0.0). The pattern is wider than the one literal on purpose: the marker names the +encoder's format version, and pinning `a000000` would report a later Oracle's wrapped unit as plain +text. + +``` + CREATE OR REPLACE EDITIONABLE FUNCTION "APP"."APP_WRAPPED_MULTI" wrapped +a000000 +369 +... +``` + +**The rule was mutation-tested rather than asserted.** The fixture commits four plain, VALID, +COMPILING functions, three of them built to defeat the naive TEXTUAL rule and the fourth the control +for the header position itself, and none of the four may be tidied away: + +| Fixture unit | Built to defeat | First source line | +|---|---|---| +| `APP_FIRST_LINE_WRAPPED` | "the line ends with `wrapped`" | `... RETURN NUMBER IS -- wrapped` | +| `APP_SECOND_LINE_MARKER` | "line 2 is the format marker" | `... RETURN NUMBER IS /*`, then `a000000` | +| `APP_CONJ_DEFEATER` | both halves at once | `... RETURN NUMBER IS /* wrapped`, then `a000000` | +| `APP_ZERO_ARG` | the control: the closest PLAIN shape to a wrapped header | `... "APP_ZERO_ARG" RETURN NUMBER IS` | + +All four fail the predicate, because `GET_DDL` writes the object name inside double quotes and what +follows it is decided by the PARSER: a plain unit admits only `(`, a RETURN clause, `IS` or `AS` in +that position. If a future Oracle ever admits `wrapped` there for a plain unit, the assertion over +these units fails by name. + +**The fifth unit attacks the OTHER conjunct, and it is the one that does not compile.** +All four above defeat the keyword half of the rule; until `APP_MARKERLESS_HEADER` was added, the +MARKER half was asserted by nothing at all, because a real wrapped unit always carries its marker. +MEASURED on Oracle XE 21.3.0.0.0, and it is not what the parser rule above would lead you to expect: +`wrapped` after a function name is ACCEPTED, because it is the wrap keyword. + +```sql +CREATE OR REPLACE FUNCTION app.app_markerless_header wrapped +BEGIN + RETURN 1; +END; +``` + +That leaves one row in `USER_ERRORS`, `PLS-00753: malformed or corrupted wrapped unit`, the object is +created `FUNCTION` / `INVALID`, and `DBMS_METADATA.GET_DDL` answers its source verbatim anyway: the +header carries the keyword and the next line is `BEGIN`. +So the predicate must answer NOT WRAPPED for it, and a reader gets a readable text rather than a +manufactured refusal. +Deleting the marker conjunct from the rule fails exactly the test over this unit. + +**A second, independent signal exists and is deliberately not used.** `ALL_SOURCE` holds a whole +wrapped unit in ONE row with embedded newlines, where a plain unit is one row per line, and that shape +is not forgeable from source text at all. It is not used because it costs a second round trip on every +PL/SQL read and answers nothing for the five kinds that are not PL/SQL. It is written down here so a +future defect in the header predicate has a measured alternative rather than a research problem. + +`EXECUTE ON DBMS_DDL` is already granted to `PUBLIC` on `gvenzl/oracle-xe`, so the fixture needs no +grant to create a wrapped unit. + +#### Other refusals, and what raises instead + +- an EMPTY or whitespace-only definition is a REFUSAL carrying that fact, never a readable part. An + empty definition is not a definition, and an empty editor over one is the hazard this whole surface + exists to remove. +- a CLOB that arrives as something other than a string RAISES. `GET_DDL` answers a CLOB, oracledb + answers a CLOB with a `Lob` stream by default, and serialising one throws + `TypeError: Converting circular structure to JSON`. The fix, `lobFetchTypeHandler`, is a PER-CALL + option and the object surface's ordinary reader passes none, so this read has its own `execute`; a + value that still comes back as a stream is a defect of ours and is reported as one rather than + coerced into an editor. +- a path whose shape this kind cannot take is refused by the same rule `describeObject` uses, which + the two methods share so they cannot come to disagree about one engine. +- a kind the provider declares no source for is refused by name. + +#### Reproducing every one of these + +The whole fixture is applied by the mount, so no command below creates anything: + +The container name and the host port below are a PRIVATE pair, not the defaults, and that is +deliberate: a machine already running an Oracle on 1521, or a container already called `oracle`, is +the ordinary case rather than the exception, and the recipe must not collide with one. +Every measurement in this section was taken on exactly this pair. +Remove only what you created. + +```bash +docker run -d --name src-task07-oracle -e ORACLE_PASSWORD='Password123!' -p 15217:1521 \ + -v "$PWD/docker/oracle-init:/container-entrypoint-initdb.d:ro" gvenzl/oracle-xe +# wait for "DATABASE IS READY TO USE!" in `docker logs src-task07-oracle`, about four minutes +docker exec -i src-task07-oracle sqlplus -s app/'Password123!'@localhost:1521/XEPDB1 +# 15217 is the HOST port, for a connection from the Studio UI; the exec above is already +# inside the container, where the listener is on 1521. +docker rm -f src-task07-oracle # when you are done, and nothing else +``` + +```sql +SET LONG 200000 PAGESIZE 0 LINESIZE 32767 LONGCHUNKSIZE 200000 +SELECT DBMS_METADATA.GET_DDL('FUNCTION','APP_WRAPPED_MULTI','APP') FROM DUAL; -- wrapped +SELECT DBMS_METADATA.GET_DDL('FUNCTION','APP_CONJ_DEFEATER','APP') FROM DUAL; -- plain, and imitates it +SELECT DBMS_METADATA.GET_DDL('FUNCTION','APP_MARKERLESS_HEADER','APP') FROM DUAL; -- keyword, no marker +SELECT LINE, POSITION, TEXT FROM ALL_ERRORS WHERE NAME = 'APP_MARKERLESS_HEADER'; -- PLS-00753 +SELECT DBMS_METADATA.GET_DDL('BOGUS_TYPE','APP_ORDERS','APP') FROM DUAL; -- ORA-31600 +SELECT DBMS_METADATA.GET_DDL('MATERIALIZED VIEW','APP_REVENUE_MV','APP') FROM DUAL; -- ORA-31600 again +SELECT DBMS_METADATA.GET_DDL('PACKAGE_BODY','APP_WRAPPED_PKG','APP') FROM DUAL; -- wrapped body +SELECT DBMS_METADATA.GET_DDL('PACKAGE_BODY','APP_SPEC_ONLY_PKG','APP') FROM DUAL; -- ORA-31603, absent +SELECT DBMS_METADATA.GET_DDL('TABLE','REPORT_DAILY','REPORTING') FROM DUAL; -- ORA-31603, refused +SELECT DBMS_METADATA.GET_DDL('TABLE','NO_SUCH_TABLE','REPORTING') FROM DUAL; -- ORA-31603, absent +SELECT OWNER, OBJECT_NAME, OBJECT_TYPE FROM ALL_OBJECTS WHERE OWNER = 'REPORTING'; +``` + +The last statement is the second question by hand: it answers a row for `REPORT_DAILY` and none for +`NO_SUCH_TABLE`, which is the entire difference between a refusal and a raise. +The two ORA-31600 lines are the other vocabulary's failure mode: the dictionary spelling +`MATERIALIZED VIEW` is as invalid an `object_type` as `BOGUS_TYPE` is, which is why the nine metadata +spellings are pinned in the suite rather than left to a table two consumers read differently. + #### The fixture [`docker/oracle-init/01-object-fixture.sql`](../../docker/oracle-init/01-object-fixture.sql), mounted at `/container-entrypoint-initdb.d` by the `oracle` service in `database-compose.yml`. It creates two owners so the lifted confinement is observable, one object of every declared kind, the -three trigger cases above, and the package whose body does not compile. Connect as `APP` / +three trigger cases above, the package whose body does not compile, and the wrapped-PL/SQL block with +the four plain units that imitate it and the fifth, INVALID one that carries the keyword without the +marker ([Object source](#object-source-789)). Connect as `APP` / `Password123!` on service `XEPDB1`. It also seeds ROWS, two in `APP.APP_CUSTOMERS` and two in `REPORTING.REPORT_DAILY`, and those are diff --git a/docs/providers/postgres.md b/docs/providers/postgres.md index 460a5ac7..e4de89e3 100644 --- a/docs/providers/postgres.md +++ b/docs/providers/postgres.md @@ -329,6 +329,8 @@ knows about it: | `procedure` | `routine` | `pg_proc.prokind = 'p'` | | | `trigger` | `attached` | `pg_trigger` where `NOT tgisinternal` | `attachedTo: 'table'` | +Five of the seven also declare `hasSource` and a `sourceLanguage`, which is what gives a row a Source tab; [§3.1.5](#315-object-source-789) says which, what each definition text IS, and why `table` and `sequence` declare nothing. + `containerLevels` is one level, `schema`. A `catalog` level is not declared: a `pg` pool is opened against one database and nothing in the product can switch it on a live connection, so the level would draw a folder with exactly one child forever. @@ -556,6 +558,107 @@ describeObjects(app, table, limit 10): 10 details, truncated=undefined Every detail path was found in that kind's own `listObjects()` answer, and every column list matched `describeObject()` for the same table column for column. +### 3.1.5 Object source (#789) + +`readObjectSource(path, kind, limit?)` answers one object's definition text. +Five of the seven declared kinds have one, and each declares `hasSource: true` with `sourceLanguage: "pgsql"`. + +| Kind | Statement | What the text is | `form` | `origin` | +|---|---|---|---|---| +| `view` | `pg_get_viewdef(c.oid, false)` over `pg_class` joined to `pg_namespace`, `relkind = 'v'` | the bare `SELECT`, with no `CREATE` around it | `partial` | `regenerated` | +| `materialized_view` | the same statement with `relkind = 'm'` | the bare `SELECT` | `partial` | `regenerated` | +| `function` | `pg_get_functiondef(p.oid)` over `pg_proc`, `prokind = 'f'` | a runnable `CREATE OR REPLACE FUNCTION` | `complete` | `regenerated` | +| `procedure` | the same statement with `prokind = 'p'` | a runnable `CREATE OR REPLACE PROCEDURE` | `complete` | `regenerated` | +| `trigger` | `pg_get_triggerdef(t.oid, false)` over `pg_trigger` joined to `pg_class` and `pg_namespace`, `NOT tgisinternal` | a runnable `CREATE TRIGGER` | `complete` | `regenerated` | + +**`table` and `sequence` declare nothing, and it is the first of the two absences: the engine publishes no such text at all.** +There is no `pg_get_tabledef` and no `pg_get_sequencedef`, and `pg_catalog.pg_sequences` publishes a sequence's properties (`start_value`, `increment_by`, `cache_size`) rather than any statement. +A kind an engine cannot answer for is absent from the declaration and is never declared and then refused, so those two rows offer no Source action at all. + +**`origin` is `regenerated` on all five, in PostgreSQL's own words.** +The documentation calls this family's output "a decompiled reconstruction, not the original text of the command", so none of these five is the text anybody typed: a comment, the original whitespace and the original casing are all gone. +`view` and `materialized_view` are additionally `partial` because `pg_get_viewdef` answers the `SELECT` and nothing else, which is what the reader is told rather than left to discover. + +**The pretty flag is `false` on every call.** PostgreSQL documents that "the default format is more likely to be interpreted the same way by future versions of PostgreSQL; so avoid using pretty-printed output for dump purposes", and this text may be submitted back. + +**PostgreSQL has NO privilege-driven refusal for object source. This is MEASURED and it is a CANNOT, not an omission.** +On PostgreSQL 18.4 (Debian 18.4-1.pgdg13+1), a role holding no `USAGE` on schema `app`, no `EXECUTE` and no `SELECT` read the COMPLETE text of `app.order_total(integer)`, `app.order_summary` and `orders_stamp_updated_at`, in the same session where `SELECT app.order_total(1)` was refused with `permission denied for schema app`. +The `pg_get_*` family applies no privilege check at all. +That role is `src_probe` in `docker/postgres-init/03-object-fixture.sql` and it is there so the measurement can be re-run rather than believed. + +**The consequence is an implementation rule, and it is the reason these statements pass an oid.** +The two failures that DO exist are name RESOLUTION and ABSENCE. +`pg_get_viewdef('app.order_summary'::regclass, false)` raises `ERROR: 42501: permission denied for schema app` for that same role, because the `regclass` and `regprocedure` casts resolve the NAME and name resolution needs `USAGE` on the schema. +So a provider that casts manufactures a refusal the engine never made, on an object the tree has already listed. +Every statement above joins `pg_namespace` on the schema NAME, which any caller may read, and hands the catalog's own `oid` to the function. + +**Absence RAISES; it is never a refusal part.** +No row, a NULL definition and a whitespace-only definition are one fact here: the catalog holds no such object at that address. +Measured: `pg_get_viewdef` answers NULL for an oid that is not a view. +PostgreSQL utters no sentence for that, so a refusal part would carry OUR silence dressed as the server's answer, and an empty part would put an empty editor over a definition nobody read. +The raise is a `QueryError` naming the object's own segment. + +**Two refusal arms exist for a wire-compatible FORK missing a catalog surface, and NEITHER FORK REACHES THEM.** +This was measured on both forks on 2026-09-13 and the earlier wording here, "so both arms are reachable", was wrong. The arms fire on SQLSTATE `42883` (the function is absent) and `42703` (`pg_proc.prokind` is absent), which are the codes PostgreSQL 18.4 answers and the shapes the suite pins. + +| Fork | `pg_get_viewdef` | `pg_get_functiondef` | `pg_proc.prokind` | `pg_get_triggerdef` | What the provider does | +|---|---|---|---|---|---| +| CockroachDB v26.2.5 | works | works | present | works | reads the source. Neither refusal arm is needed | +| Materialize v26.40.0 | works on a plain view, **NULL on a materialized view** | `XX000` | `XX000` | `XX000` | RAISES on all four, because `XX000` is neither of the two codes and a NULL definition is read as an absence | + +Re-run it, one container at a time, with the exact statements this file ships: + +``` +docker run -d --name -p :26257 cockroachdb/cockroach:v26.2.5 start-single-node --insecure --accept-sql-without-tls +docker run -d --name -p :6875 materialize/materialized:v26.40.0 +psql "postgresql://root@127.0.0.1:/defaultdb?sslmode=disable" # CockroachDB +psql "postgresql://materialize@127.0.0.1:/materialize?sslmode=disable" # Materialize +``` + +then `\set VERBOSITY verbose` and run each statement from `viewSourceSql()`, `SOURCE_ROUTINE_SQL` and `SOURCE_TRIGGER_SQL`. + +CockroachDB v26.2.5 answers every one of them, including `pg_get_functiondef` on a `CREATE FUNCTION` and `p.prokind = 'f'`, so nothing there is refused and nothing there is missing. + +Materialize v26.40.0 answers `XX000` for each absent surface, verbatim +`function "pg_catalog.pg_get_functiondef" does not exist` and `column "p.prokind" does not exist`. `XX000` is `internal_error`, not `undefined_function` or `undefined_column`, so `isMissingCatalogSurface()` does not match and the read RAISES rather than answering a refusal part. The SQLSTATE is the server's and not the harness's: in the same session `SELECT 1/0` answers `22012` and `SELEC 1` answers `42601`, while an unknown table answers `XX000` too. + +A third Materialize fact, and it is the sharper one: `pg_get_viewdef` returns **NULL** for a `MATERIALIZED VIEW` (`relkind = 'm'`) while returning the definition for a plain view in the same schema. A NULL definition is read here as an absence, so a materialized view that exists is reported as an object the catalog does not hold. + +Widening the arms to `XX000` is NOT an obvious fix and is deliberately not done here: `XX000` is Materialize's generic internal error, so keying on it would turn a real internal failure into this object's refusal, which is the exact confusion the refusal-versus-absence rule exists to prevent. That is a code decision, recorded here rather than taken, and this section is the record: the measurement above is the whole of it, re-runnable with the commands beside it. +Each is reported as the part's `unavailable`, the server's own sentence unprefixed and NOT passed through `mapDatabaseError()`, for the reason the count refusal gives one section up: the mapper's prefix would put this product's words in front of the server's. +Measured, and it is why the plain spelling is load-bearing rather than cosmetic: `mapDatabaseError()` returns both sentences above unchanged, and rewrites a message containing "permission denied" into `Authentication failed: ...`. +Everything else RAISES, including a transport failure, because nobody answering is not the server answering "no", and "Connection terminated unexpectedly" rendered as this object's own refusal is a symptom presented as a fact about the object. + +**No identifier escaper, and that is a property of the statements rather than a decision.** +All five are fully parameterised: the schema, the object's own segment and, for a trigger, its table are binds, and `prokind` is a bind too. +No caller-supplied name ever reaches statement text on this engine, which is the reason these are preferred over any `SHOW`-shaped alternative. + +**Verified end to end against the seeded `postgres:18`**, through the provider itself, connected as `src_probe`, which holds nothing: + +``` +view app.order_summary 1 part partial regenerated pgsql 606 chars +materialized_view app.revenue_by_month 1 part partial regenerated pgsql 161 chars +function app.order_total(integer) 1 part complete regenerated pgsql 228 chars +procedure app.touch_order(integer) 1 part complete regenerated pgsql 185 chars +trigger app.orders.orders_stamp_updated_at 1 part complete regenerated pgsql 119 chars +limit 30 on the function: 30 chars, truncated={"limit":30, + "reason":"the source read was bounded at 30 characters by its caller"} +app.no_such_view (view): raises PostgreSQL holds no view called "no_such_view" in schema "app" +app.invoice_number_seq (sequence): raises PostgreSQL publishes no definition text for the kind "sequence" +``` + +Every object `listObjects()` named under each of the five kinds was then read: 4/4 views, 1/1 materialized view, 2/2 functions, 1/1 procedure, 1/1 trigger, nine of nine, all readable text and no refusal. +A role holding no privilege of any kind read all nine. + +**A routine is read by the identity its own listing wrote.** +The last path segment of a routine is `proname` plus the argument TYPE list, and the source statement compares the SAME expression, `ROUTINE_IDENTITY_EXPR`, which the listing builds the segment with. +One writer for both, because two copies are two chances for the read to answer "no such routine" for an object the tree had just listed. +`pg_get_function_identity_arguments()` is not used, for the reason [§3.1.4](#314-what-the-object-surface-declares-and-which-catalog-answers-for-it) gives: it renders parameter names. + +**`pgsql` is a real Monaco language id and is no compromise here.** +It is among the ids the installed `monaco-editor` 0.56.0 registers, unlike `plsql` and `tsql`, which Oracle and SQL Server have to render under `sql`. +A PL/pgSQL body inside a `$function$` dollar-quoted string is highlighted as PostgreSQL SQL rather than as a procedural language, which is the closest this bundle can come. + ### 3.2 Schema SQL hoisted to module scope The object surface's statements are module-level `const`s, not inline template literals inside the diff --git a/docs/providers/redis.md b/docs/providers/redis.md index 4aa7e04b..c40cb117 100644 --- a/docs/providers/redis.md +++ b/docs/providers/redis.md @@ -837,6 +837,99 @@ The UTF-8-byte versus UTF-16-code-unit divergence Task 26a-2 measured on five SQ has nothing to bite on here for the same reason: there is no server-side sort to disagree with. +#### Object source (#789) + +`readObjectSource(path, kind, limit?)` answers ONE kind and the **declaration** says which. `function` +declares `hasSource` and `sourceLanguage: "lua"`; `keyspace` declares neither, because a key prefix is +a grouping this server derived from a bounded `SCAN` and nobody wrote a definition for it. That is the +`tablesAreDerivedGroupings` refusal carried into the object model rather than left behind with the +flag's old reader. The refusal is read off the declaration and never off the kind id, so a kind this +engine does not declare at all takes the same path and raises with the same sentence. + +The command is `FUNCTION LIST LIBRARYNAME WITHCODE`, sent once. It is server-scoped and takes +no database: measured, one `FUNCTION LOAD` is visible from every numbered database and `SELECT` does +not change what it answers. + +**The path SHAPE is checked here, by the same function and the same sentence `describeObject` uses.** +`assertObjectPathShape` derives the accepted length and the labels in its message from +`declaredLevels`, so a path is refused with `A Redis "function" path is [database, name], received []` +rather than reaching the command. Both methods need it because neither is reached only through the +HTTP route: they are published through `@libredb/studio` and called by the embedded host seam and by +the conformance helper, and none of those sees the route's own bound. Measured before the check +existed: an empty path made the name `undefined` and ioredis threw +`undefined is not an object (evaluating 'arg.toUpperCase')` out of its command encoder, which is this +provider's defect arriving as the driver's. + +**A kind declaring `hasSource` and no `sourceLanguage` RAISES** with +`Redis declares readable source for the kind "function" and no sourceLanguage to render it with`, +before the round trip. There is no fallback to a literal `lua`: an unregistered or absent Monaco id +degrades to plain text with no throw and nothing observable, so a fallback would hide a deleted +declaration behind a Source tab that had quietly stopped highlighting. The isolated census +(`tests/isolated/object-source-declarations.test.ts`) pins every declared language, so the only way to +reach this arm is a declaration somebody removed. + +| Field | Value | Why | +|---|---|---| +| `id` | `definition` | one part, always: a library has one Lua text | +| `label` | `Definition` | rendered as-is | +| `language` | the kind's declared `sourceLanguage`, which is `lua` | `lua` IS a Monaco language id the installed 0.56.0 bundle registers, unlike `plsql`, `tsql` and `cql` | +| `form` | `complete` | the text runs as given: it is what `FUNCTION LOAD` was handed | +| `origin` | `stored` | the author's own bytes. Measured on Redis 8.10.0: `WITHCODE` answers the shebang line and the body exactly as they were loaded, with no reformatting | + +**The selection is BYTE-EQUAL, and that is the whole of the parser's reason to exist.** Measured on +Redis 8.10.0 against the committed fixture: + +``` +$ redis-cli FUNCTION LIST # the dictionary is CASE-SENSITIVE +library_name +libredb_probe +library_name +LIBREDB_PROBE +$ redis-cli FUNCTION LIST LIBRARYNAME libredb_probe # the argument is a CASE-INSENSITIVE glob +library_name +libredb_probe +library_name +LIBREDB_PROBE +``` + +One lookup for either name answers BOTH, so a reader taking `reply[0]` would hand back the other +library's Lua as this object's definition. The entry whose `library_name` is byte-equal to the last +path segment is the one read, and its `library_code` is found by walking the key/value pairs rather +than by position, the same rule the listing's parser records: the nested `functions` value is itself a +list of key/value lists, and RESP3 answers a map where there is no order at all. + +**An absent library RAISES.** Measured on Redis 8.10.0, +`FUNCTION LIST LIBRARYNAME no_such_library WITHCODE` answers an **empty array** and not an error, so +emptiness is absence here and a provider that returned a document would invent one. An entry that +matches by name and carries no `library_code`, or an empty one, takes the same arm: an empty text +would put an empty editor over a definition that was never read. + +**A refused read is the server's own sentence, unprefixed**, carried as the part's `unavailable` and +never as a text. Measured as the ACL user the fixture creates: + +``` +$ redis-cli --user libredb_nofunction --pass nofunction FUNCTION LIST LIBRARYNAME libredb_probe WITHCODE +NOPERM User libredb_nofunction has no permissions to run the 'function|list' command +``` + +KeyDB, DragonflyDB and Garnet have no `FUNCTION` command at all and each refuses in its own words (the +table in [§6.1](#61-the-object-surface-789) has them), so this path is reachable on three of the four +Redis-wire relatives this type id serves. + +**Only the server's own error reply is a refusal. A transport failure RAISES.** +Measured against ioredis 5.11.1 and Redis 8.10.0: an ACL denial and an unknown command both reject with +a `redis-errors` `ReplyError`, whose `name` is `ReplyError`, while a dropped socket rejects with a plain +`Error` named `Error`, reading `Connection is closed.` with the offline queue on and +`Stream isn't writeable and enableOfflineQueue options is false` with it off. +A read that catches both would show `Connection is closed.` in the Source pane as this object's own +refusal, with no raise, no retry affordance and nothing in the document telling it apart from a real +`NOPERM`, so the provider raises a `ConnectionError` naming the library instead. + +**A caller's bound** cuts one part's text and reports itself through the one sentence every engine +uses, `the source read was bounded at characters by its caller`. An exact answer is never marked. +The bound counts UTF-16 code units, so a bound landing between the two halves of a surrogate pair drops +the pair rather than emitting a lone surrogate; the mark still names the number the caller asked for. + #### Reads go to the CONTAINER's database, never the session's Every object read opens its own short-lived connection with `db` set, rather than issuing `SELECT` on @@ -933,7 +1026,7 @@ no control offers it. | `declaresForeignKeys` | `false` — Redis has no constraints at all, and the "tables" here are key prefixes this provider grouped rather than objects anyone declared | | `tablesAreDerivedGroupings` | `true` — the object surface SCANs a bounded slice of the keyspace and groups the real key names it found by their prefix, so a `user:*` row is this server's own summary and not a key any command can be given. The agent layer states this to a plan run, in one sentence, so a grounded run does not draft a command against a grouping. In the object tree it is what withholds Profile from a `keyspace` row ([§6.1](#61-the-object-surface-789)) | | `containerLevels` | one level, `schema`, labelled Database ([§6.1](#61-the-object-surface-789)) | -| `objectKinds` | `keyspace` (relation) and `function` (routine, `hasSource`, Lua). Three further candidates are absent rather than declared and zero ([§6.1](#61-the-object-surface-789)) | +| `objectKinds` | `keyspace` (relation) and `function` (routine, `hasSource`, `sourceLanguage: "lua"`). `function` is the only kind in this engine with a definition text, read through `FUNCTION LIST ... WITHCODE` ([§6.1](#61-the-object-surface-789)). Three further candidates are absent rather than declared and zero | | `supportsMaintenance` | `true` | | `maintenanceOperations` | `['analyze']` | | `supportsConnectionString` | `false` | @@ -1083,7 +1176,12 @@ docker exec -i libredb-redis redis-cli --no-raw < docker/redis-init/01-object-fi `redis-cli` reading from stdin uses ONE connection for the whole file, which is what makes the `SELECT 3` in the middle of it work; a per-line `redis-cli` loop would silently write every key into database 0. The file is idempotent: it `DEL`s the keys it is about to write and loads the function -library with `FUNCTION LOAD REPLACE`. +libraries with `FUNCTION LOAD REPLACE`. + +**The file carries no comments, and that is a constraint rather than a style.** Measured on +Redis 8.10.0: `redis-cli` reading from stdin does NOT skip a `#` line, it sends it as a command, and +the server answers ``ERR unknown command '#'``. Every explanation of what the fixture holds therefore +lives in the table below rather than beside the line. What it builds, and why each part is there: @@ -1093,6 +1191,8 @@ What it builds, and why each part is there: | db 0 | mixed value types under one prefix (string, hash, list) | the sampled `type` column is `string/hash`-shaped rather than uniform | | db 3 | `report:daily` | a key that exists in ONE database and nowhere else, so a provider reading the SESSION's database instead of the CONTAINER's is distinguishable from a correct one | | db 0 | function library `libredb_probe`, two registered functions | the `function` kind has an object, and `FUNCTION LIST WITHCODE` has source to answer | +| db 0 | a SECOND library `LIBREDB_PROBE`, differing from the first ONLY in case | `LIBRARYNAME` is a case-INSENSITIVE glob over a case-SENSITIVE dictionary, so one lookup answers both and a source read taking `reply[0]` shows the wrong library ([§6.1](#61-the-object-surface-789)) | +| server | ACL user `libredb_nofunction`, password `nofunction`, `-function` | a live principal for the source read's refusal pane | To measure a cluster-mode container, which is the only deployment where the database count is not 16: diff --git a/docs/providers/sqlite.md b/docs/providers/sqlite.md index f67987fc..b889ddf5 100644 --- a/docs/providers/sqlite.md +++ b/docs/providers/sqlite.md @@ -544,16 +544,105 @@ apart. Both drivers this provider selects between are far above that floor (`bun #### The fixture, and running it -`tests/integration/db/sqlite-provider.test.ts` builds the fixture by DDL against a real `:memory:` -database, so every assertion is measured against the engine rather than a mock. It holds one of every -declared kind, all four `table_list.type` values, a generated column, a composite primary key, an -`AUTOINCREMENT` table, an expression index, an `INSTEAD OF` trigger on a view, a `sqliteXledger` -table, and `orders` in all three of `main`, `temp` and an ATTACHed database. +The DDL is [`docker/sqlite-init/01-object-fixture.sql`](../../docker/sqlite-init/01-object-fixture.sql). +`tests/integration/db/sqlite-provider.test.ts` reads that file and replays it into a real `:memory:` +database, so every assertion is measured against the engine rather than a mock, and every object it +reasons about is created by the file rather than by a literal inside the test. +It holds one of every declared kind, all four `table_list.type` values, a generated column, a +composite primary key, an `AUTOINCREMENT` table, an expression index, an `INSTEAD OF` trigger on a +view, a trigger whose name is also a table's, a `sqliteXledger` table, and `orders` in all three of +`main`, `temp` and an ATTACHed database. +Counts in `main`: `table 6, view 1, index 2, trigger 3`. + +There is no `docker compose` service for SQLite and there never will be, because the engine is a +file. The build script is what an init directory is for every other engine: it replays the same +statements into a database FILE that can be opened in Studio. ```bash bun test tests/integration/db/sqlite-provider.test.ts +bun docker/sqlite-init/build-fixture.ts # ./.sqlite-fixture/object-fixture.sqlite +bun docker/sqlite-init/build-fixture.ts /tmp/demo.sqlite # anywhere else ``` +A `CREATE TRIGGER` body carries its own semicolons, so the file is split by +`readFixtureStatements()` in [`build-fixture.ts`](../../docker/sqlite-init/build-fixture.ts), which +ends a trigger statement only at the `;` after its `END`. A `split(";")` hands the engine a truncated +body and a bare `END`, and it refuses both. + +--- + +### 6.2 Object source (#789) + +One statement answers every kind, and it is the simplest source story in the fleet. + +```sql +SELECT s.sql AS sql + FROM sqlite_schema AS s + WHERE s.type = ? + AND s.name = ? +``` + +| Kind | `sqlite_schema.type` | `form` | `origin` | Monaco language | +| --- | --- | --- | --- | --- | +| `table` | `table` | `complete` | `stored` | `sql` | +| `view` | `view` | `complete` | `stored` | `sql` | +| `index` | `index` | `complete` | `stored` | `sql` | +| `trigger` | `trigger` | `complete` | `stored` | `sql` | + +No kind declares nothing: all four have a definition text and all four publish it. +A `VIRTUAL` table is typed `table` in `sqlite_schema`, so the `table` kind covers the FTS5 object the listing takes from `PRAGMA table_list`. + +#### What the text IS, and the one caveat on `stored` + +`form` is `complete` on every kind: each of these is a statement that runs as given, never a body or a bare `SELECT`. + +`origin` is `stored`, and on this engine that is a real distinction rather than a formality. +`sqlite_schema.sql` holds the text the AUTHOR submitted, so the newlines and the inner spacing of a multi-line `CREATE TABLE` come back exactly as typed. +That is what the Source tab's caption exists to say: a reader must never be shown a reconstruction as an original, and if every engine reported `regenerated` the distinction would be decoration. + +THE CAVEAT, measured on SQLite 3.53.2 through `bun:sqlite`, and it is why this engine is the one where `stored` needs a sentence of its own: + +| What was run | What `sqlite_schema.sql` then holds | +| --- | --- | +| `CREATE TABLE orders ( id INTEGER PRIMARY KEY , note TEXT ) -- trailing comment` | `CREATE TABLE orders ( id INTEGER PRIMARY KEY , note TEXT )`. The `CREATE TABLE ` prefix is normalized and everything after the closing parenthesis, a trailing comment included, is dropped | +| `ALTER TABLE orders RENAME TO invoices` | `CREATE TABLE "invoices" ( id INTEGER PRIMARY KEY , note TEXT )`. The engine REWRITES the stored text and quotes the new name | +| `ALTER TABLE invoices ADD COLUMN extra TEXT` | `... , note TEXT , extra TEXT)`. The new column is appended to the stored text | + +So the bytes are the author's own bytes up to the last schema change. +That is still a different fact from a statement rebuilt out of a catalog, which is why the arm stays `stored`, and the reader is told which one they are holding. + +#### There is no refusal, and that is a CANNOT rather than an omission + +`sqlite_schema.sql` is NULL for exactly one shape: an index SQLite created for itself, `sqlite_autoindex__`. +Every listing and every count this provider answers carries `name NOT LIKE 'sqlite\_%' ESCAPE '\'` ([§6.1](#names-sqlite-reserves-for-itself)), so no path the object tree can produce addresses such a row. +There is therefore no privilege refusal, no encryption refusal and no wrapped-text case on this engine: SQLite has no privilege system at all, and a file the process can open is a file the process can read whole. + +The provider still turns a NULL, an absent column or a whitespace-only text into a REFUSAL part rather than an empty definition, because an empty editor over a definition is the one failure this surface exists to prevent. +THOSE ARE THREE DIFFERENT FACTS AND THEY GET THREE DIFFERENT SENTENCES, because a refusal stating a cause that is false for the shape in front of it sends its reader somewhere there is nothing to find. +A stored NULL says the engine keeps NULL there only for an index it created for itself. +A whitespace-only text says the column holds no non-whitespace character, and claims no cause at all. +A reply carrying no `sqlite_schema.sql` column says exactly that, and says it is a fact about the read and not about the object, because that shape is this provider asking for a column the reply does not carry and can be nothing else. +The sentences are OURS and not the engine's, which is the exception to the rule that a refusal carries the engine's own words: SQLite supplies none for any of the three, it simply stores NULL. +The MySQL provider writes its own sentence for the same shape and for the same reason. + +An object that is not there RAISES, naming the last path segment. +Absence and unreadability are different facts, and a document is never answered for an object nothing found. + +#### No escaper, and no schema bind + +Both binds are PARAMETERS, so no identifier is ever interpolated into this statement and this read needs no identifier escaper at all. +The `type` value comes from the KIND and never from what the name happens to match, and that is behavioural rather than stylistic: measured on SQLite 3.53.2, a TRIGGER may share a name with a TABLE (`CREATE TRIGGER audit_log ... ON audit_log` is accepted) while `CREATE INDEX audit_log` answers `there is already a table named audit_log` and `CREATE VIEW audit_log` answers `table audit_log already exists`. +`SELECT sql FROM sqlite_schema WHERE name = 'audit_log'` therefore answers TWO rows with the table's first, and a read that resolved the type from the name would hand a reader the table's DDL under the trigger's address. +[`docker/sqlite-init/01-object-fixture.sql`](../../docker/sqlite-init/01-object-fixture.sql) holds that object so the rule is exercised rather than asserted. + +Unqualified `sqlite_schema` resolves to `main.sqlite_schema` even with a database ATTACHed, measured, and `temp` objects live in the separate `sqlite_temp_schema`. +This provider declares no container level, so `main` is the only database it addresses and the source read needs no schema bind, exactly as the index and trigger listings do not ([§6.1](#main-temp-and-attach-what-is-in-scope-and-why)). + +#### One part, always + +A SQLite object has exactly one text, so the document carries one part, `Definition`. +Nothing here has an Oracle package's specification-and-body split. + --- ## 7. Monitoring & health diff --git a/docs/providers/trino.md b/docs/providers/trino.md index 4f0bc6f0..3525f883 100644 --- a/docs/providers/trino.md +++ b/docs/providers/trino.md @@ -757,8 +757,14 @@ shape the implementation. It takes a schema and has no catalog form columns are named `Function`, `Return Type`, `Argument Types`, `Function Type`, `Deterministic` and `Description`, with spaces, and no alias can rename them; and it cannot be wrapped in a subquery — `SELECT * FROM (SHOW FUNCTIONS FROM memory.app)` is a syntax error. There is no relation to read -instead: `information_schema` has no routine catalog on this engine, and `system.jdbc.procedures` -answers zero rows for a schema holding three functions. +instead: `information_schema` has no routine catalog on this engine, and `system.jdbc.procedures` is +empty. `SELECT count(*) FROM system.jdbc.procedures` answers `0` over the whole table, re-measured on +476 on 2026-09-13 against a cluster with this repository's fixture applied, where +`SHOW FUNCTIONS FROM memory.app` answered a row for every function that fixture creates. The +emptiness of the WHOLE TABLE is what is stated here rather than a row count for one schema: a +per-schema count is a digit that goes stale the moment the fixture gains a function, which is exactly +what happened to the sentence this one replaces, and a claim about the whole table counts nothing +(#789). So a **catalog-level** function count is `{ unavailable }` carrying that reason, and a catalog-level listing is refused with the same sentence, rather than fanning `SHOW FUNCTIONS` out over every schema @@ -887,29 +893,62 @@ through the provider itself on trinodb/trino:476: | Container | table | view | materialized_view | function | |---|---|---|---|---| | `memory` | 2 | 1 | 0 | unavailable | -| `memory.app` | 2 | 1 | 0 | 3 | +| `memory.app` | 2 | 1 | 0 | 7 | | `tpch.tiny` | 8 | 0 | 0 | 0 | -`memory.app` holds `orders` and `customers`, the view `customer_names`, and the three functions -`plus_one(bigint)`, `plus_one(double)` and `label(bigint, varchar)`. The overloaded pair is the -fixture that makes the argument-type path segment observable rather than theoretical, and the server -returns `orders` before `customers`, which is what makes the provider's own sort observable rather -than incidental. +`memory.app` holds `orders` and `customers`, the view `customer_names`, and seven functions. Not one +of the seven is padding, and four of them exist only because the **source read** (#789) below needs +an object that defeats a shortcut: + +| Function | What it is there for | +|---|---| +| `plus_one(bigint)`, `plus_one(double)` | The overloaded pair. A bare name would give two objects one address, which is why a path segment carries its argument types at all. | +| `label(bigint, varchar)` | Differs in arity as well as in type, so a segment carrying only the first argument type would still be wrong. | +| `hard(decimal(10,2), array(varchar), row("a" bigint,"b" varchar))` | The overload whose two renderings DIFFER. See the source section. | +| `answer()` | The empty argument list. | +| `we(ird(bigint)` | A function NAME holding an open parenthesis, so the first `(` in the segment belongs to the name. | +| `rowparen(row("a)b" bigint,"c" varchar))` | A ROW field name holding a CLOSE parenthesis, which is what makes the source read's quote-aware scans load-bearing. | + +The server returns `orders` before `customers`, which is what makes the provider's own sort +observable rather than incidental. ##### `materialized_view` is 0 on this cluster, and that is the true answer **The compose cluster configures no Iceberg catalog**, so it can hold no materialized view at all: the kind is an engine-level concept but only some connectors implement it, and on 476 only a -Hive-metastore-backed Iceberg catalog will CREATE one. The Iceberg JDBC and REST catalog types both -answer `createMaterializedView is not supported for Iceberg JDBC catalogs`, so "configure Iceberg and -you get materialized views" is wrong. - -An Iceberg catalog here would mean a metastore service, a warehouse volume and a second image in -`database-compose.yml` for one object kind, which is a bigger change than the object surface owns. A -live probe against this cluster will therefore see `materialized_view: 0` in every container. **That -is the engine answering honestly, not a broken fixture**: the kind stays declared because the engine -has the concept and `system.metadata.materialized_views` is an engine-level catalog, and #789's -`KindCount` keeps "this engine has no such concept" and "this container holds none" apart. +Hive-metastore-backed Iceberg catalog will CREATE one. + +**The JDBC catalog was PROBED for #789 rather than ruled out on paper, and it was refused with the +catalog fully working.** That distinction is the whole value of the measurement: "the JDBC route does +not work" and "the JDBC route works and only this one statement is refused" call for different next +attempts. Measured on 2026-09-13 against trinodb/trino:476 with an Iceberg JDBC catalog on a +PostgreSQL 18: + +1. Trino 476 never initialises the JDBC catalog's own tables. Against an empty database every + statement fails `Cannot check and eventually update SQL schema`, and the PostgreSQL log names the + cause: `ERROR: relation "iceberg_tables" does not exist` for + `ALTER TABLE iceberg_tables ADD COLUMN iceberg_type VARCHAR(5)`. Creating the two V1 tables + (`iceberg_tables`, `iceberg_namespace_properties`) by hand clears it. +2. `fs.hadoop.enabled=true` is then required for a `file://` warehouse. `fs.native-local.enabled` + plus `local.location` is not a substitute: the coordinator refuses to START with + `Invalid configuration property local.location: file does not exist: file:/data/warehouse` for a + directory that exists and is writable inside the container. +3. With both in place the catalog is live. `CREATE SCHEMA iceberg.warehouse` answers `CREATE SCHEMA`, + `CREATE TABLE iceberg.warehouse.orders (id bigint, total double)` answers `CREATE TABLE`, and + `INSERT INTO iceberg.warehouse.orders VALUES (1, 10.0), (2, 20.0)` answers `INSERT: 2 rows`. +4. And then `CREATE MATERIALIZED VIEW iceberg.warehouse.order_totals AS SELECT id, total FROM + iceberg.warehouse.orders` answers + **`createMaterializedView is not supported for Iceberg JDBC catalogs`**. + +So "configure Iceberg and you get materialized views" is wrong, and it is wrong for a reason a reader +can now check rather than take on trust. The REST catalog type answers the same for REST. + +An Iceberg catalog here would therefore mean a METASTORE SERVICE, a warehouse volume and a second +image in `database-compose.yml` for one object kind, which is a bigger change than the object surface +owns. A live probe against this cluster will therefore see `materialized_view: 0` in every container. +**That is the engine answering honestly, not a broken fixture**: the kind stays declared because the +engine has the concept and `system.metadata.materialized_views` is an engine-level catalog, and +#789's `KindCount` keeps "this engine has no such concept" and "this container holds none" apart. ##### Reproducing the Iceberg measurements @@ -959,6 +998,173 @@ For the catalog-isolation finding, add a second catalog file pointing at a metas there, `hive.metastore.uri=thrift://nosuchhost:9083`, and read `SELECT * FROM system.metadata.materialized_views` unfiltered. +### Object source (#789) + +All four declared kinds gain `hasSource`, all four with `sourceLanguage: "sql"`, and **no kind here +declares nothing**: Trino holds no object without a definition text, because it holds no trigger, no +stored procedure and no index at all. + +Every row below was measured on trinodb/trino:476 on 2026-09-13, against the `memory` catalog +`database-compose.yml` configures plus an Iceberg catalog on an Apache Hive 4.0.1 standalone +metastore built exactly as the block above describes. + +| kind | statement | reply column | form | origin | +|---|---|---|---|---| +| `table` | `SHOW CREATE TABLE ..` | `Create Table` | `complete` | `regenerated` | +| `view` | `SHOW CREATE VIEW ..` | `Create View` | `complete` | `regenerated` | +| `materialized_view` | `SHOW CREATE MATERIALIZED VIEW ..` | `Create Materialized View` | `complete` | `regenerated` | +| `function` | `SHOW CREATE FUNCTION ..` | `Create Function`, **one row per overload** | `complete` | `regenerated` | + +The reply column is also the **part's label**, rendered as the engine spells it. `SHOW CREATE` is a +statement rather than a projection, so these names cannot be aliased and they are the engine's own +word for the text. Binding the label to the same constant the read keys on also makes a wrong reply +column visible: a misspelled column reads as `undefined`, turns a readable definition into a refusal, +and a refusal passes a conformance walk and every count assertion in silence. + +#### Every text is `regenerated`, and no view claims otherwise + +Trino keeps no copy of the statement anybody typed. The view is the clearest proof: the fixture +creates `customer_names` as + +```sql +CREATE OR REPLACE VIEW memory.app.customer_names AS + SELECT id, name FROM memory.app.customers; +``` + +and the source read answers + +``` +CREATE VIEW memory.app.customer_names SECURITY DEFINER AS +SELECT + id +, name +FROM + memory.app.customers +``` + +with a security clause the author never wrote and the projection reformatted. Each of the four is +`complete` rather than `partial`, because each is a statement that runs as given rather than a body +or a bare `SELECT`. + +#### A Hive-native view returns a MACHINE TRANSLATION, and this provider cannot tell you so + +This is a limit of the surface, and it is stated here rather than carried on the wire, because there +is nothing truthful to put there. + +A **Hive-native** view, one that Hive itself created and that is reached through a `hive` connector, +is not a Trino view. What `SHOW CREATE VIEW` answers for it is a machine translation into Trino SQL +of a statement nobody ever wrote in Trino SQL. **Nothing in the reply distinguishes it from a view +Trino created itself**, so this provider does not claim the difference: `origin` is `regenerated` for +every view and never `stored`, which is true of both cases, and a reader who needs to know which one +they are looking at has to know it from the catalog they opened. + +When the translation FAILS, the engine says so, and that sentence is carried as a **refusal part** +rather than raised: + +``` +Failed to translate Hive view '': +``` + +The whole sentence reaches the reader untouched, so the view's name and the parser's reason are the +engine's words rather than this product's. Three things about it are declared rather than implied: + +- **It is NOT MEASURED on this cluster, and that is said in advance rather than reported around.** + Reaching a Hive-native view needs a `hive` connector catalog holding a view Hive created, which + `database-compose.yml` does not configure and which no statement this provider can send will + produce. +- **It is matched on the engine's stable fault NAME, `HIVE_VIEW_TRANSLATION_ERROR`, and not on the + wording of the message.** The first implementation matched the message's fixed prefix, and that is + a bet on a shape this engine does not keep uniform. Of the failure replies captured verbatim from + 476 in this repository, `line 1:1: mismatched input 'SELEKT'.` and + `line 1:1: Table 'memory.app.no_such_table' does not exist` carry the source location the analyzer + attached, while `This connector does not support creating tables`, thrown by a connector rather + than by the analyzer, is bare. Which shape a message takes is a property of where the throw came + from, and for the one branch that cannot be reached on any cluster this repository can start, that + property is unmeasurable: a location prefix would silently turn the declared refusal back into a + raise. `errorName` is on the wire on every failed statement and does not move when a release + rewords a sentence. +- **It is matched on the FAULT and not on the kind.** Only a `view` can produce one today, but a + branch keyed on the kind would have to be edited again the day another can. + +Every other failure is RAISED, so an object that is not there is never reported as one whose +definition cannot be read. Measured on 476, those sentences name the object: + +``` +line 1:1: Table 'memory.app.no_such_table' does not exist +line 1:1: Relation 'memory.app.customer_names' is a view, not a table +line 1:1: Relation 'memory.app.customer_names' is a view, not a materialized view +``` + +#### A function is TWO reads, because its two renderings are not the same text + +`SHOW CREATE FUNCTION` takes a **bare name** and answers **one row per overload**, and it carries no +`Argument Types` column of its own. A path segment addresses one overload (`plus_one(bigint)`), so +the row belonging to it has to be found, and `rows[0]` is measurably wrong: on 476 +`SHOW CREATE FUNCTION memory.app.plus_one` answers the **double** overload first. + +**The match is made on `SHOW FUNCTIONS`'s own `Function` and `Argument Types` pair**, which is the +statement that minted the segment in the first place. The read asks `SHOW FUNCTIONS FROM +.` first and looks for the row whose `name(argumentTypes)` **reconstructs** the path +segment. Nothing parses the segment, and that is deliberate: the segment is not unambiguously +parseable, because the fixture holds a function called `we(ird` whose segment `we(ird(bigint)` has its +first parenthesis inside the name. A segment no row reconstructs is absence and it raises naming the +segment, because the engine's own sentence there is the bare `Function not found`, which names +neither the function nor the schema. + +Then the create row is chosen by comparing **signatures**, and a raw string comparison matches +nothing. Measured for the fixture's `hard`: + +| Source | Rendering | +|---|---| +| `SHOW FUNCTIONS` `Argument Types` | `decimal(10,2), array(varchar), row("a" bigint,"b" varchar)` | +| `SHOW CREATE FUNCTION` parameter list | `amount decimal(10, 2), tags array(varchar), r ROW(a bigint, b varchar)` | + +Three differences in one signature: a space inside `decimal(10, 2)`, `ROW` in upper case against +`row`, and field names quoted on one side and bare on the other. So both sides are reduced to the +form the two renderings agree on: the parameter NAME dropped, then the case, the whitespace and the +double quotes removed, split at TOP-LEVEL commas only so `decimal(10,2)` stays one argument. + +**What that form costs, said plainly.** Removing the whitespace also removes the boundary between a +ROW field's name and its type, so `row(a bigint)` and a hypothetical `row(ab igint)` reduce to the +same string. The second is not a type Trino will parse, so no pair of real signatures collides. + +**What a parameter name may and may not hold**, measured on 476 on 2026-09-13, because it is what +decides how much scanning the comparison needs: + +| Shape | Answer | +|---|---| +| A quoted name for a reserved word, `"order" bigint` | Accepted, and renders quoted | +| A parameter name holding a SPACE, `"my arg" bigint` | Refused at creation: `Internal error` | +| A parameter name holding a COMMA, `"a,b" bigint` | Refused at creation: `Internal error` | +| A parameter name holding a PARENTHESIS, `"we(ird" bigint` | Refused at creation: `Internal error` | +| A ROW FIELD name holding a CLOSE PARENTHESIS, `row("a)b" bigint, c varchar)` | **Accepted**, and round-trips through both renderings | + +The last row is why the two scans are quote aware, and the fixture holds `rowparen` so that is an +object rather than an argument: a scan for the parameter list's matching `)` that ignored quoting +would stop at the `)` inside `"a)b"`. The first four rows are why the parameter NAME is dropped by +taking everything after the first space, with no quote-aware branch: a quoted name always ends +before a space, so the simple rule reads every name Trino will accept, and the quote-aware branch +that was written here first was deleted after a mutation proved it changed no answer. + +#### The escaper + +The three-part name is an **identifier position** and this transport has no parameter channel at all: +it sends a statement as text. Every segment therefore goes through `quoteIdentifier` in +[`trino/objects.ts`](../../src/lib/db/providers/sql/trino/objects.ts), the same function the rest of +the object surface uses, which wraps the segment in double quotes and **doubles** any double quote +inside it. A table called `ev"il` is addressed as `"memory"."app"."ev""il"`. + +Doubling is the correct and complete escape here, which is not true of every engine in this repo: +`docs/providers/clickhouse.md` records that ClickHouse also processes a BACKSLASH inside a quoted +identifier, so doubling the quote alone is unsafe there. Trino's quoted identifier has no backslash +escape, so there is one character to protect and it is doubled. + +#### Which kinds declare nothing, and why the list is empty + +None. Every kind Trino declares has a definition text and gains `hasSource`. The kinds a reader might +expect to find missing here are not declared at all: Trino has no trigger, no stored procedure and no +index anywhere in its model, so there is no folder for them and no source question to answer. + --- ## 7. Monitoring & health diff --git a/src/app/api/db/objects/source/route.ts b/src/app/api/db/objects/source/route.ts new file mode 100644 index 00000000..9cab2781 --- /dev/null +++ b/src/app/api/db/objects/source/route.ts @@ -0,0 +1,48 @@ +import { NextRequest } from "next/server"; +import { + boundSourceDocument, + handleObjectRequest, + requireObjectPath, + requireSourceReader, + requireString, +} from "@/lib/api/object-route"; +import { SOURCE_CHARACTER_LIMIT } from "@/lib/db/object-kinds"; + +export const dynamic = "force-dynamic"; + +/** + * One object's definition text, as a document of named parts (#789 Phase 2). + * + * The seventh route under this prefix and the first one that can answer nothing: every other + * object method is required on `DatabaseProvider`, while `readObjectSource` is optional because + * two engines in the fleet hold no kind with a definition text anywhere. `requireSourceReader` is + * where that gap becomes a refusal a caller can read, in one branch of two conjuncts. + * + * `kind` is required in the body, not optional and not inferred, for the reason + * `describe/route.ts` gives and one more the research measured: on MySQL, MariaDB and DuckDB one + * name addresses more than one object of different kinds in one container, so a path alone reads + * the wrong object. + * + * No depth check, for the reason `describe/route.ts` gives: this is an object path, not a + * container path, and how deep a kind nests is a per-kind fact the provider's declaration carries. + * Two engines have a kind at mixed depth. + * + * `limit` is NOT accepted from the caller in Phase 2 and the route always passes + * `SOURCE_CHARACTER_LIMIT`. The argument exists on the provider method because that is where a + * bound belongs and because the conformance helper drives the bounded arm with a small number. An + * unused request field would be a second way to reach one behaviour. The route then applies the + * same bound to the ANSWER rather than trusting it: a number passed to an implementation is a + * request and not a bound, and the sixteen providers that implement the method are the population + * that reaches this handler. CORRECTED after review: the first version of this sentence also named + * the embedded seam's host, and MEASURED, a host cannot reach here at all, because the provider + * comes from a closed `switch (connection.type)` in `src/lib/db/factory.ts` and the embedded shell + * has no API routes. + */ +export async function POST(req: NextRequest) { + return handleObjectRequest(req, "api/db/objects/source", async (provider, body) => { + const path = requireObjectPath(body); + const kind = requireString(body, "kind"); + const read = requireSourceReader(provider, kind); + return boundSourceDocument(await read(path, kind, SOURCE_CHARACTER_LIMIT), SOURCE_CHARACTER_LIMIT); + }); +} diff --git a/src/components/QueryEditor.tsx b/src/components/QueryEditor.tsx index 4cf26093..525b607a 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -13,6 +13,7 @@ import { registerMongoDBCompletionProvider } from "@/lib/editor/mongodb-completi import { registerLibreDBLanguage } from "@/lib/editor/libredb-language"; import { registerRedisLanguage } from "@/lib/editor/redis-language"; import { configureMonacoLoader } from "@/lib/editor/monaco-loader"; +import { defineStudioThemes, STUDIO_THEME_DARK, STUDIO_THEME_LIGHT } from "@/lib/editor/monaco-theme"; import { useEffectiveTheme } from "@/hooks/use-effective-theme"; import { useMonacoInstance } from "@/hooks/use-monaco-instance"; import { logger } from "@/lib/logger"; @@ -116,9 +117,10 @@ export const QueryEditor = forwardRef( const editorRef = useRef(null); const [hasSelection, setHasSelection] = useState(false); - // Both themes are defined in `beforeMount`; this only picks which is applied. + // Both themes are defined in `beforeMount`, from `@/lib/editor/monaco-theme`; this only picks + // which is applied. // Monaco re-reads the `theme` prop on change, so the switch needs no remount. - const editorTheme = useEffectiveTheme() === "light" ? "db-light" : "db-dark"; + const editorTheme = useEffectiveTheme() === "light" ? STUDIO_THEME_LIGHT : STUDIO_THEME_DARK; // Explain capability gate, shared by the toolbar button and the context-menu action. const canExplain = Boolean(onExplain) && Boolean(capabilities?.supportsExplain); @@ -429,65 +431,9 @@ export const QueryEditor = forwardRef( }; } - monacoInstance.editor.defineTheme("db-dark", { - base: "vs-dark", - inherit: true, - rules: [ - { token: "keyword", foreground: "569cd6", fontStyle: "bold" }, - { token: "function", foreground: "dcdcaa" }, - { token: "string", foreground: "ce9178" }, - { token: "number", foreground: "b5cea8" }, - { token: "comment", foreground: "6a9955" }, - { token: "operator", foreground: "d4d4d4" }, - { token: "identifier", foreground: "9cdcfe" }, - ], - colors: { - "editor.background": "#050505", - "editor.foreground": "#d4d4d4", - "editorCursor.foreground": "#569cd6", - "editor.lineHighlightBackground": "#111111", - "editorLineNumber.foreground": "#333333", - "editorLineNumber.activeForeground": "#666666", - "editor.selectionBackground": "#264f78", - "editor.inactiveSelectionBackground": "#3a3d41", - "editorIndentGuide.background": "#1a1a1a", - "editorIndentGuide.activeBackground": "#333333", - }, - }); - - /* - * Monaco paints its own canvas and knows nothing about the CSS token layer, - * so the editor is the one surface that needs the palette written twice. - * Same syntax hues either side — they are chosen for contrast against the - * CODE, not against the chrome — with only the ground and the guides moved. - * `editor.background` mirrors `--studio-canvas` in both themes so the pane - * sits flush with the shell it lives in. - */ - monacoInstance.editor.defineTheme("db-light", { - base: "vs", - inherit: true, - rules: [ - { token: "keyword", foreground: "0000ff", fontStyle: "bold" }, - { token: "function", foreground: "795e26" }, - { token: "string", foreground: "a31515" }, - { token: "number", foreground: "098658" }, - { token: "comment", foreground: "008000" }, - { token: "operator", foreground: "3f3f46" }, - { token: "identifier", foreground: "001080" }, - ], - colors: { - "editor.background": "#f4f4f5", - "editor.foreground": "#27272a", - "editorCursor.foreground": "#0000ff", - "editor.lineHighlightBackground": "#e4e4e7", - "editorLineNumber.foreground": "#a1a1aa", - "editorLineNumber.activeForeground": "#52525b", - "editor.selectionBackground": "#add6ff", - "editor.inactiveSelectionBackground": "#e5ebf1", - "editorIndentGuide.background": "#e4e4e7", - "editorIndentGuide.activeBackground": "#a1a1aa", - }, - }); + // Both themes come from one owner so this mount and the read-only source viewer + // paint identically (#789). + defineStudioThemes(monacoInstance); }; // SQL completion provider diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx index 336737d3..a78512ae 100644 --- a/src/components/Studio.tsx +++ b/src/components/Studio.tsx @@ -7,7 +7,7 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import { Sidebar, ConnectionsList } from "@/components/sidebar"; import { type TreeRowActionHandlers } from "@/components/object-tree"; import { objectAtPath } from "@/lib/db/detailed-object"; -import { objectPathQuery } from "@/lib/db/object-path"; +import { objectPathLabel, objectPathQuery } from "@/lib/db/object-path"; import { MobileNav } from "@/components/MobileNav"; import { SchemaExplorer } from "@/components/schema-explorer"; import { ConnectionModal } from "@/components/ConnectionModal"; @@ -30,7 +30,8 @@ import { import { AgentRail } from "@/components/agent/AgentRail"; import { DatabaseConnection, SavedQuery } from "@/lib/types"; import type { DatabaseObject } from "@/lib/db/types"; -import { relationKindIds } from "@/lib/db/object-kinds"; +import { findKind, kindHasSource, relationKindIds } from "@/lib/db/object-kinds"; +import { ObjectSourceView, type ObjectSourcePatch } from "@/components/object-source"; import { ChunkBoundary, ViewLoading } from "@/components/LazyView"; import { lazyRetry } from "@/lib/lazy"; import { editorLanguageForTabType, resolveTabType } from "@/lib/editor/tab-language"; @@ -131,6 +132,97 @@ export default function Studio() { const [objectRefreshToken, setObjectRefreshToken] = useState(0); const objectsChanged = useCallback(() => setObjectRefreshToken((previous) => previous + 1), []); + /** + * What the source viewer writes back onto the tab it is mounted in (#789 Phase 2). + * + * MERGED BY SPREAD, and an explicitly-undefined key in the patch is therefore a CLEAR + * rather than a no-op: that is how the stale banner's re-read control works, sending + * `{ document: undefined, failure: undefined, readAtToken: undefined }` to put the tab back + * into the state the viewer reads from. + * + * STABLE across renders, which is a requirement rather than an optimisation: the viewer's + * read effect lists `onChange` among its dependencies, so a fresh identity every render + * re-runs it, and while its own address guard would still refuse to re-issue, a shell that + * re-rendered on every answer and re-ran the effect on every render is one guard away from + * hammering the route. The dependencies are `setTabs`, which `useState` guarantees is + * stable, and the active tab id, which changes only when the reader changes tabs. + * + * Addressed by ID and not by `currentTab`, because an answer can land after the reader has + * switched tabs: the patch belongs to the tab that asked for it, so the read a reader + * started before switching away is there when they switch back. + */ + /** Present exactly when the active tab is a Source tab, and it is what the pane branches on. */ + const sourceTab = tabMgr.currentTab.source; + + /** + * What every statement entry point OUTSIDE the editor pane is handed while a Source tab is + * active (#789 Phase 2, round 1 finding 1, completed in round 2). + * + * The pane below branches around the toolbar AND the editor together, so a Source tab draws + * no Run button. That covers the desktop editor and NOTHING else, and the population is + * larger than it looks, because "outside the editor pane" includes two surfaces that are + * rendered outside the branch rather than merely mounted elsewhere. Every one of these + * addresses `currentTab` and every one was live over a Source tab. The list is exhaustive as + * of round 2, taken by reading each `updateCurrentTab` and each execute call in this file + * rather than from the five the first round happened to name: + * + * - the command palette's "Run Query", and its saved-query and history loaders; + * - the mobile header's RUN and its EXPLAIN, which is the same execution by another name; + * - the BOTTOM PANEL's `onLoadQuery`. `BottomPanel` is rendered below the editor pane and + * outside its branch, and `BottomPanel.tsx` wires that one prop to both `QueryHistory`'s + * and `SavedQueries`' `onSelectQuery`. That makes it the plainest desktop gesture in the + * class: open a Source tab, open History in the bottom panel, click a past query. + * - the AGENT RAIL's `onApplyStatement` and its `onRunStatement`. The second is the only + * entry point here that both WRITES and EXECUTES, so over a Source tab it ran a statement + * while the pane showed a read-only definition and nothing on screen said what ran. + * + * MEASURED before this existed: `updateCurrentTab({ query })` leaves a Source tab a Source + * tab, so a statement loaded from the palette landed on a tab whose pane shows a read-only + * definition and displays no query at all, and Run then executed it. With nothing loaded the + * same Run executed the empty string, because `use-query-execution` has no empty-query guard + * and `queryEditorRef.current` is null while `QueryEditor` is unmounted. The write is not + * transient either: `use-tab-manager`'s SAVE effect persists `query` per tab, so the + * statement outlived the session on a tab that never showed it. + * + * What this predicate deliberately does NOT gate, so the next reader does not reopen it. The + * class is an entry point that addresses the ACTIVE TAB'S STATEMENT: it reads the tab's query + * to run it, or writes a statement into the tab. Three groups fall outside that and each stays + * live over a Source tab on purpose: + * + * - `CreateTableModal`, `DataImportModal` and `TestDataGenerator` call `executeQuery(sql)` with + * THEIR OWN statement, aimed at an object the reader picked in the tree, and the tab is only + * where the answer lands. `executeQuery` with an override never writes `query`, so nothing is + * put on the tab, and `BottomPanel` draws the result below the definition. Gating these would + * take away a working action because an unrelated tab happens to be open. + * - `QuerySafetyDialog`'s Proceed and the unlimited-rows dialog's Load All continue an execution + * that is already under way, so they are reachable only through something already allowed. + * - the mobile header's `onClearQuery` writes the EMPTY string, which is the value a Source tab + * already holds: `openSourceTab` appends with `query: ""` and, with the entry points above + * closed, nothing can put a statement there. A gate here would be a line no mutation could + * kill, and on a workspace persisted by an older build it would preserve the stale statement + * this predicate exists to keep out. + * + * A no-op rather than an absent control, because every component that draws these takes them + * as REQUIRED props and none of those files is this task's to change. That is the smaller + * half of the answer: the item should not be drawn at all, on the same argument the pane + * already makes, and hiding it is filed for the shells that own those files (#789). What is + * closed here is the half that matters, which is that nothing runs and nothing is written + * onto a tab that cannot show it. + */ + const runsTheActiveTab = sourceTab === undefined; + + const { setTabs, activeTabId } = tabMgr; + const onSourceChange = useCallback( + (patch: ObjectSourcePatch) => { + setTabs((previous) => + previous.map((tab) => + tab.id === activeTabId && tab.source !== undefined ? { ...tab, source: { ...tab.source, ...patch } } : tab, + ), + ); + }, + [setTabs, activeTabId], + ); + // 5. Query Execution const queryExec = useQueryExecution({ activeConnection: conn.activeConnection, @@ -460,8 +552,24 @@ export default function Studio() { * server cannot resolve. */ const onObjectClick = (object: DatabaseObject) => { - if (metadata === null || !relationKindIds(metadata.capabilities).includes(object.kind)) return; - onTableClick(object.path); + if (metadata === null) return; + if (relationKindIds(metadata.capabilities).includes(object.kind)) { + onTableClick(object.path); + return; + } + /* + * A NON-RELATION row whose kind declares source opens its Source tab (#789 Phase 2). + * + * One gesture, one behaviour per row, never two on one row. A relation that ALSO has + * source, a PostgreSQL view or a SQLite table, took the branch above and keeps its data + * preview; its definition is one menu item away. The alternative, activating both, would + * open two tabs from one press, and the alternative to THIS arm is the state Phase 1 left + * every routine, trigger and package in: a row that does nothing at all on click, on + * Enter and on Space. + * + * The gate is the DECLARATION and never the kind id, exactly as the branch above is. + */ + if (kindHasSource(metadata.capabilities, object.kind)) tabMgr.openSourceTab(object); }; /** @@ -489,6 +597,7 @@ export default function Studio() { onGenerateTestData: (object) => setTestDataPath(object.path), onOpenMaintenance: isAdmin ? (object) => openMaintenance("tables", object.path) : undefined, onCreateObject: () => setIsCreateTableModalOpen(true), + onViewSource: (object) => tabMgr.openSourceTab(object), }; const requestDeleteConnection = (id: string) => { @@ -532,7 +641,10 @@ export default function Studio() { onSheetOpenChange={setIsAgentSheetOpen} prefill={agentPrefill.request} connectionType={conn.activeConnection?.type ?? null} - onApplyStatement={(sql) => tabMgr.updateCurrentTab({ query: sql })} + onApplyStatement={(sql) => { + if (!runsTheActiveTab) return; + tabMgr.updateCurrentTab({ query: sql }); + }} /* The handover a run's answer can record (§2.1): the statement goes into the editor AND is run there. Through the hook's own entry point @@ -549,6 +661,7 @@ export default function Studio() { server executes is the ledger's, not this component's copy of it. */ onRunStatement={(sql, runId) => { + if (!runsTheActiveTab) return; tabMgr.updateCurrentTab({ query: sql }); void queryExec.executeHandedOverStatement(runId, sql); }} @@ -619,14 +732,20 @@ export default function Studio() { onLogout={handleLogout} onSaveQuery={() => setIsSaveQueryModalOpen(true)} onClearQuery={() => tabMgr.updateCurrentTab({ query: "" })} - onExecuteQuery={() => queryExec.executeQuery()} + onExecuteQuery={() => { + if (!runsTheActiveTab) return; + queryExec.executeQuery(); + }} onCancelQuery={() => queryExec.cancelQuery()} {...transactionHandlers} onToggleEditing={onToggleEditing} onImport={() => setIsImportModalOpen(true)} onExplain={ metadata?.capabilities.supportsExplain - ? () => queryExec.executeQuery(undefined, undefined, true) + ? () => { + if (!runsTheActiveTab) return; + queryExec.executeQuery(undefined, undefined, true); + } : undefined } // Absent while the runtime is off, so the header carries no control @@ -746,37 +865,94 @@ export default function Studio() {
- setIsSaveQueryModalOpen(true)} - onExecuteQuery={() => queryExec.executeQuery()} - onCancelQuery={() => queryExec.cancelQuery()} - {...transactionHandlers} - onToggleEditing={onToggleEditing} - onImport={() => setIsImportModalOpen(true)} - /> + {/* + One branch around the toolbar AND the editor together, so a Source + tab shows no Run button rather than a disabled one: there is nothing + on a definition to run, and a control that is present and refuses is + a worse answer than a control that is not there (#789 Phase 2). + + THE CONNECTION IS NO LONGER PART OF THIS BRANCH, and that conjunct + was the third door onto the same hazard (#789 fix round 1). It read + `|| conn.activeConnection === null` on a docblock arguing the state + was admitted by the type and not reached by the product. It is + reached: a Source tab outlives the connection that opened it, so a + person who deletes the active connection with one open got a tab + labelled `Source: ` over an EMPTY, EDITABLE buffer with a live + Run button, which is the composition this whole surface exists to + prevent. The viewer takes a nullable connection and refuses in its + own grammar, so the pane stays a pane and asks the route nothing. + */} + {sourceTab === undefined ? ( + <> + setIsSaveQueryModalOpen(true)} + onExecuteQuery={() => queryExec.executeQuery()} + onCancelQuery={() => queryExec.cancelQuery()} + {...transactionHandlers} + onToggleEditing={onToggleEditing} + onImport={() => setIsImportModalOpen(true)} + /> -
- tabMgr.updateTabById(tabMgr.currentTab.id, { query: val })} - onExplain={ - metadata?.capabilities.supportsExplain - ? () => queryExec.executeQuery(undefined, undefined, true) - : undefined - } - language={editorLanguageForTabType(tabMgr.currentTab.type)} - databaseType={conn.activeConnection?.type} - schemaContext={conn.schemaContext} - capabilities={metadata?.capabilities} - /> -
+
+ tabMgr.updateTabById(tabMgr.currentTab.id, { query: val })} + onExplain={ + metadata?.capabilities.supportsExplain + ? () => queryExec.executeQuery(undefined, undefined, true) + : undefined + } + language={editorLanguageForTabType(tabMgr.currentTab.type)} + databaseType={conn.activeConnection?.type} + schemaContext={conn.schemaContext} + capabilities={metadata?.capabilities} + /> +
+ + ) : ( +
+ +
+ )}
@@ -810,7 +986,10 @@ export default function Studio() { onCellChange={editing.handleCellChange} onApplyChanges={editing.handleApplyChanges} onDiscardChanges={editing.handleDiscardChanges} - onLoadQuery={(q) => tabMgr.updateCurrentTab({ query: q })} + onLoadQuery={(q) => { + if (!runsTheActiveTab) return; + tabMgr.updateCurrentTab({ query: q }); + }} onLoadMore={ tabMgr.currentTab.result?.pagination?.hasMore ? queryExec.handleLoadMore : undefined } @@ -1020,12 +1199,17 @@ export default function Studio() { onSelectConnection={conn.setActiveConnection} onTableClick={onTableClick} onAddConnection={() => setIsConnectionModalOpen(true)} - onExecuteQuery={() => queryExec.executeQuery()} + onExecuteQuery={() => { + if (!runsTheActiveTab) return; + queryExec.executeQuery(); + }} onLoadSavedQuery={(q) => { + if (!runsTheActiveTab) return; tabMgr.updateCurrentTab({ query: q }); queryExec.setBottomPanelMode("results"); }} onLoadHistoryQuery={(q) => { + if (!runsTheActiveTab) return; tabMgr.updateCurrentTab({ query: q }); queryExec.setBottomPanelMode("results"); }} diff --git a/src/components/object-source/ObjectSourceView.tsx b/src/components/object-source/ObjectSourceView.tsx new file mode 100644 index 00000000..78336701 --- /dev/null +++ b/src/components/object-source/ObjectSourceView.tsx @@ -0,0 +1,531 @@ +"use client"; + +import Editor from "@monaco-editor/react"; +import { FileWarning, LoaderCircle, RefreshCw, TriangleAlert } from "lucide-react"; +import React, { useCallback, useEffect, useId, useMemo, useRef } from "react"; +import { httpSourceReader, isSourceDocumentShape, type ObjectSourceReader } from "./source-reader"; +import { sourceCaption } from "./source-caption"; +import { Button } from "@/components/ui/button"; +import { isSourcePartUnavailable } from "@/lib/db/object-kinds"; +import { pathKey } from "@/lib/db/object-path"; +import type { ObjectSourceDocument, ObjectSourcePart } from "@/lib/db/types"; +import { configureMonacoLoader } from "@/lib/editor/monaco-loader"; +import { defineStudioThemes, STUDIO_THEME_DARK, STUDIO_THEME_LIGHT } from "@/lib/editor/monaco-theme"; +import { useEffectiveTheme } from "@/hooks/use-effective-theme"; +import type { DatabaseConnection } from "@/lib/types"; + +// Serve Monaco from our own origin rather than @monaco-editor/react's jsdelivr default. +// Called at module scope HERE as well as in `QueryEditor`, because it must run before the +// FIRST mount and a Source tab can be the first editor a session opens: a restored tab set +// whose active tab is a Source tab paints this component with no query editor ever mounted. +// `loader.config` is idempotent, so the second call rewrites the same path with the same value. +configureMonacoLoader(); + +/** + * What a shell hands back to the tab when this viewer learns something (#789). + * + * The whole read lives in ONE place, this component, and the result is written back through + * `onChange` so the tab keeps it across a tab switch and an unmount. Every field is optional + * and a shell MERGES BY SPREAD, which is what makes an explicitly-`undefined` field a CLEAR: + * the stale banner's control sends `{ document: undefined, failure: undefined, readAtToken: + * undefined }` and the three keys are present on purpose, because an omitted key would leave + * the stale document in place and the re-read would never be issued. + */ +export interface ObjectSourcePatch { + readonly document?: ObjectSourceDocument; + readonly failure?: string; + readonly activePartId?: string; + readonly readAtToken?: number; +} + +export interface ObjectSourceViewProps { + /** + * The connection this definition was read from, and `null` when the shell has none. + * + * NULLABLE deliberately, and it is what closes the third door onto the empty-editor hazard + * (#789). Both shells used to branch `sourceTab === undefined || activeConnection === null` + * and mount the query toolbar plus the query editor for the second half, purely because this + * prop could not take a null. A Source tab open when the last connection went away then came + * back labelled `Source: ` over an EMPTY, EDITABLE buffer with a live Run button, which + * is the composition this whole surface exists to prevent. The state is REACHED and not only + * admitted by the type: `use-connection-adapter.ts` auto-selects whenever the host's list is + * non-empty, so a null active connection means the host handed an empty array, which is what + * a host does when a person deletes the last connection in the host's own UI. + */ + readonly connection: DatabaseConnection | null; + readonly path: readonly string[]; + readonly kind: string; + /** The kind's own label from the declaration. The viewer never derives one from the id. */ + readonly kindLabel: string; + /** The object's display label. `DatabaseObject.name`, which is NOT the last path segment. */ + readonly displayName: string; + readonly document?: ObjectSourceDocument; + readonly failure?: string; + readonly activePartId?: string; + /** The session's catalog-change counter. The shell owns it; 0 where a shell has none. */ + readonly refreshToken: number; + /** The counter's value when this document was read. Absent until a read lands. */ + readonly readAtToken?: number; + /** Absent means the standalone route. The embedded shell passes the host's reader. */ + readonly reader?: ObjectSourceReader; + /** MUST be stable across renders, or the read effect re-issues for ever. */ + readonly onChange: (patch: ObjectSourcePatch) => void; +} + +/** The sentence for a body neither shell can draw, which is OUR fact and not the engine's. */ +const UNRENDERABLE = "The source read answered with a body this viewer cannot render."; + +/** The sentence for a well-formed definition that names a DIFFERENT object. Also our fact. */ +const MISMATCHED = "The source read answered with a definition for another object."; + +/** The sentence for a pane whose connection is gone. Also our fact, and the shell's own state. */ +const DISCONNECTED = "This connection is no longer open, so this definition cannot be read here."; + +/** + * Whether this document is a definition of THIS pane's object (#789). + * + * A source document carries the address it answers for, and every provider in the fleet writes + * it as `path: [...path]` beside the `kind` it was asked for, so a document naming anything + * else came from a HOST that answered the wrong question or from a shell holding one state + * slot for two objects. + * + * THE NUMBER THAT STOOD HERE COUNTED A DIFFERENT POPULATION, and it is corrected rather than + * deleted. It said 55 sites, which is how often `path: [...path]` occurs under `src/lib/db` + * altogether. MEASURED on this tree: 24 of those build a source DOCUMENT, counted by + * `grep -rn -A6 'path: \[\.\.\.path\]' src/lib/db | grep -cE '\bparts\b'`, and the other + * 31 are `describeObject` returns of the shape `{ path: [...path], columns, indexes, + * foreignKeys }`, which carry no `kind` and are not this claim's subject. The claim itself is + * unchanged and holds at all 24. What actually holds a provider to it is the conformance + * helper, which compares a document's `path` and `kind` against the request it was built from, + * not the count. Without this check such a document renders under the asked-for + * name, in the header and on the tab, with nothing on screen saying so: the same fault the + * `search` and `mongodb` providers were fixed for one level down. + * + * The KIND is half the address. Standing ruling 3 records it as measured that one name can be a + * table and a routine in one MySQL database, so the path alone does not identify an object. + * + * `pathKey` and never `JSON.stringify`, per standing ruling 5g. + */ +function namesThisObject(document: ObjectSourceDocument, path: readonly string[], kind: string): boolean { + return document.kind === kind && pathKey(document.path) === pathKey(path); +} + +/** + * The active part, and the fallback that makes the switcher's selection total. + * + * `activePartId` is remembered on the tab and the document is re-read from the engine, so the + * two can disagree: a provider that renames a part between two reads, or a restored tab whose + * remembered id belonged to an earlier shape. Falling back to the first part is what stops that + * disagreement rendering as nothing at all, which is the empty-versus-unreadable collapse this + * whole surface exists to prevent, one level in. + * + * The first part is addressed as a construction and never as a positional read of a path: + * `parts` is a non-empty tuple, so `parts[0]` is total by the type. + */ +function activePart(document: ObjectSourceDocument, activePartId: string | undefined): ObjectSourcePart { + return document.parts.find((part) => part.id === activePartId) ?? document.parts[0]; +} + +/** + * The read-only viewer for one object's definition (#789). + * + * NOT `QueryEditor`, and the four reasons are measured rather than stylistic: that component + * hardcodes `readOnly: false` with no prop to change it, installs a Run action and a Cmd+Enter + * binding unconditionally on mount, renders an execute toolbar, and takes a closed four-member + * `language` union reachable only through `resolveTabType`, which `CLAUDE.md` forbids + * extending. A definition opened in it would offer to EXECUTE itself. + * + * `readOnly: true` is NOT a security boundary and this component does not pretend otherwise. + * MEASURED on `@monaco-editor/react` 4.7.0: it blocks USER edits only, and the `value` effect + * still calls `setValue` programmatically, so anything holding the editor handle can write to + * the model. The boundary in Phase 2 is that no write path exists at all: no Run action is + * installed, no key binding is added, and nothing reachable from here can execute a statement. + * + * THE ONE RULE THIS SURFACE EXISTS FOR: an unreadable source never opens an empty editor. An + * empty editor reads as "there is no source", and a user who types over it deletes the object, + * which is measured in DBeaver's own source. It is closed twice here. The TYPE gives a refused + * part no `text` key. The COMPONENT renders a DIFFERENT element for a refusal and for a failed + * read, so there is no editor on screen to type into even if a later change made one writable. + * The renderer does not rely on the type for this, because the union does NOT make a part + * carrying both `text` and `unavailable` a compile error: TypeScript's excess-property check on + * a union admits any property declared on any member, so such a part narrows to the refusal. + * `isSourceDocumentShape` refuses that part on BOTH entries: the read effect checks what a + * reader answered, and the render checks what the `document` PROP carries, because a tab's + * document survives a reload through `localStorage` and comes back as parsed JSON that no + * compiler ever saw. Round 1 checked only the read effect, and a restored document with an + * empty `text` then mounted an editor holding `""` over an object that HAS a definition. + * + * Nothing rendered here reads a kind id or a database type id. The kind's label arrives as a + * prop from the declaration, the part's label is the engine's own word, and the language + * travels on the part. + */ +export function ObjectSourceView(props: ObjectSourceViewProps): React.JSX.Element { + const { connection, path, kind, document: sourceDocument, failure, refreshToken, reader, onChange } = props; + const theme = useEffectiveTheme(); + const baseId = useId(); + + /** + * The read's identity, and the reason it is a STRING rather than the props themselves. + * + * `path` is an array prop and `connection` is an object prop, so both are a fresh identity on + * every render of the shell. An effect keyed on either re-runs on every render, and an effect + * that cancels its in-flight read in a cleanup would then cancel it for ever and the document + * would never land. Keying on the address, plus a ref recording the address already asked, + * means a re-render with identical props issues nothing and a genuinely new object issues one. + * + * `pathKey` and never `JSON.stringify(path)`, per standing ruling 5g: the key separator is a + * control character no engine admits inside an identifier, so `["a.b"]` and `["a", "b"]` + * cannot collide, while JSON escaping rewrites exotic names. + */ + const address = `${connection?.id ?? ""}/${pathKey(path)}/${kind}`; + /** + * EVERY address this instance has issued a read for, and not one address (#789). + * + * A single ref held the LAST address and every answer whose address no longer matched it was + * thrown away. Dropping an answer on UNMOUNT is intended and is documented below; dropping one + * because the SAME PANE moved from object A to object B is the opposite, and both shells reach + * it, because a reader switching between two Source tabs re-renders one mounted viewer with a + * new address rather than mounting a second one. A's answer was discarded in silence, its tab + * went back to "nothing read", and the next visit paid for a second round trip. + * + * A set, so an answer is kept when THIS instance asked for it, whatever it is showing now. The + * answer is written through the `onChange` captured when the read was ISSUED, and in both + * shells that callback names the tab that asked. What stops a shell holding ONE state slot for + * two objects from drawing A's definition under B's name is `namesThisObject` above, which is + * a check on the document rather than a race the reader cannot see. + */ + const asked = useRef>(new Set()); + const needsRead = sourceDocument === undefined && failure === undefined; + + useEffect(() => { + if (!needsRead || connection === null) { + // This address has been answered, so a later CLEAR (the stale banner's control) issues a + // fresh read rather than finding the address already asked. + // + // A NULL CONNECTION stops here for the same reason a failure does: there is nothing to + // read with. Without it the default reader would post to `/api/db/objects/source` naming + // a connection the shell no longer holds, and in the embedded package that route does not + // exist at all. + asked.current.delete(address); + return; + } + if (asked.current.has(address)) return; + asked.current.add(address); + /* + * The counter's value AT THE MOMENT THE READ WAS ISSUED, not when it landed. A DDL that + * runs while this read is in flight cannot be attributed to either side of it, so recording + * the earlier value marks the tab stale and offers a re-read, which is the honest half of + * the repository's absence grammar: the client knows a DDL ran and does not know whether + * this object changed. + */ + const tokenAtRead = refreshToken; + /* + * No cleanup, no mounted guard and NO DROP, deliberately. A viewer unmounted by a tab + * switch still writes its answer through `onChange`, and that is wanted rather than + * tolerated: the patch lands on the tab's own state, so the read a user started before + * switching away is there when they switch back instead of being issued a second time. + * The same reasoning covers the pane that moved from one object to another without + * unmounting, which the single-address ref used to throw away. + */ + void (reader ?? httpSourceReader)(connection, path, kind).then( + (answer) => { + if (!isSourceDocumentShape(answer)) { + onChange({ failure: UNRENDERABLE, readAtToken: tokenAtRead }); + return; + } + if (!namesThisObject(answer, path, kind)) { + onChange({ failure: MISMATCHED, readAtToken: tokenAtRead }); + return; + } + /* + * NO `activePartId` in this patch, and the omission is the fix rather than an oversight + * (#789, Task 23). The shell merges by spread, so leaving the key out KEEPS whatever the + * tab already remembered, and `activePart` above makes that total by falling back to the + * first part when the new document holds no part of that id. + * + * Writing `answer.parts[0].id` here instead was measured in a real browser against Oracle + * XE 21.3.0.0.0: reading a package BODY, running a CREATE OR REPLACE to mark the tab + * stale, then pressing "Read again" silently put the reader back on the SPECIFICATION. + * That write duplicated the fallback it sat above and could only ever lose a selection. + */ + onChange({ document: answer, readAtToken: tokenAtRead }); + }, + (error: unknown) => { + onChange({ + failure: error instanceof Error ? error.message : String(error), + readAtToken: tokenAtRead, + }); + }, + ); + // `path` and `connection` are read through `address`; `reader` and `onChange` are documented + // as stable, and a change in either is answered by the address guard rather than a re-issue. + }, [address, needsRead, refreshToken, connection, path, kind, reader, onChange]); + + /** + * The SECOND entry, and the one round 1 left unguarded (#789). + * + * A document that arrives already present never passes through the read effect, so nothing + * checked it. That entry is real rather than theoretical: `use-tab-manager.ts` restores the + * tab set from `localStorage` with a `JSON.parse` guarded only by `Array.isArray`, so an + * older shape, a truncated write or a hand-edited entry reaches this component with + * `needsRead === false`. Measured on the round-1 component: a part with `text: ""` mounted + * the editor with the value `""`, a part carrying both keys rendered the refusal pane over a + * real definition, and `origin: "typed"` captioned "undefined Complete as shown.". + * + * A refused document is reported in the FAILURE grammar rather than thrown away silently, + * and it is never re-read: the document is present, so `needsRead` is false and a re-read + * would loop on the same bad value. The stale banner's control is the way back. + */ + const renderableDocument = useMemo( + () => + sourceDocument !== undefined && + isSourceDocumentShape(sourceDocument) && + namesThisObject(sourceDocument, path, kind) + ? sourceDocument + : undefined, + [sourceDocument, path, kind], + ); + /* + * The two refusals are DIFFERENT sentences, because they are different facts: a body this + * viewer cannot render is malformed, and a definition for another object is well formed and + * about something else. One sentence for both would tell a reader nothing about which. + */ + const shownFailure = + failure ?? + (sourceDocument !== undefined && renderableDocument === undefined + ? isSourceDocumentShape(sourceDocument) + ? MISMATCHED + : UNRENDERABLE + : undefined) ?? + /* + * LAST, so it never overwrites a fact about a read that really happened, and conditioned on + * having nothing to show rather than on the connection alone: a definition already in hand + * was read from the engine a moment ago and stays on screen, which is the same decision the + * host-withdrawal arm makes one level up in `StudioWorkspace`. What it must not become is an + * editable buffer, and a read-only editor holding the definition is not one. + */ + (renderableDocument === undefined && connection === null ? DISCONNECTED : undefined); + + const reread = useCallback(() => { + onChange({ document: undefined, failure: undefined, readAtToken: undefined }); + }, [onChange]); + + const part = useMemo( + () => (renderableDocument === undefined ? undefined : activePart(renderableDocument, props.activePartId)), + [renderableDocument, props.activePartId], + ); + + /* + * A read lands with `readAtToken` set, so an absent one is "nothing has been read yet" and + * never "read at token zero". Zero is a real token: it is what every shell that counts no + * DDL passes for the whole session. + */ + const stale = props.readAtToken !== undefined && props.readAtToken !== refreshToken; + const parts = renderableDocument?.parts ?? []; + const showSwitcher = parts.length > 1; + const tabId = (index: number) => `${baseId}-tab-${index}`; + const panelId = (index: number) => `${baseId}-panel-${index}`; + const activeIndex = parts.findIndex((candidate) => candidate === part); + + /** + * The KEYBOARD half of the WAI-ARIA tabs pattern, which round 1 omitted entirely. + * + * Both tab buttons sat at the implicit tabindex 0, so a keyboard user tabbing through a + * two-part Oracle package landed on every part button in turn instead of entering the + * tablist once and arrowing within it, and no arrow key did anything. `jsx-a11y` has NO rule + * for roving tabindex or for arrow-key navigation, measured: `bun run lint` reported zero + * errors over the version that had neither, so the lint gate cannot stand in for this. + * + * `StudioTabBar.tsx` is the repository's own spelling of the same pattern and this mirrors + * it, including focus following activation: without that, the next arrow key would be + * delivered to the tab that just lost the selection. The focus move addresses the button by + * its part id and never by a position in the node list. + */ + const onTabKeyDown = (event: React.KeyboardEvent, index: number) => { + const wanted = + event.key === "ArrowRight" + ? index + 1 + : event.key === "ArrowLeft" + ? index - 1 + : event.key === "Home" + ? 0 + : event.key === "End" + ? parts.length - 1 + : undefined; + if (wanted === undefined) return; + event.preventDefault(); + const target = parts[(wanted + parts.length) % parts.length]; + onChange({ activePartId: target.id }); + /* + * Matched through `dataset` and never through a built selector, the way `ObjectTree.tsx` + * already matches a row id. A part id is the ENGINE's word: the first spelling interpolated + * it into `[role="tab"][data-part-id="..."]`, and MEASURED on happy-dom 20, a part id of + * `"char"(integer)`, which standing ruling 2 records as a real PostgreSQL routine identity, + * raised `DOMException: ... is not a valid selector` out of this handler and the arrow key + * did nothing, while the click path kept working because it carries the id as a VALUE. There + * is no `CSS.escape` in every runtime this renders in, so the id never becomes syntax. + */ + const buttons = event.currentTarget + .closest('[role="tablist"]') + ?.querySelectorAll('[role="tab"]'); + Array.from(buttons ?? []) + .find((candidate) => candidate.dataset.partId === target.id) + ?.focus(); + }; + + return ( +
+
+ + {props.displayName} + + + {props.kindLabel} + +
+ + {stale && ( +
+
+ )} + + {shownFailure !== undefined ? ( +
+
+ ) : part === undefined ? ( +
+
+ ) : ( + <> + {showSwitcher && ( +
+ {parts.map((candidate, index) => ( + + ))} +
+ )} +
+ {isSourcePartUnavailable(part) ? ( + /* + * A DIFFERENT component, not an editor with an empty buffer. This is the + * composition DBeaver gets wrong, measured in its source: an unreadable + * definition reaches a writable editor holding one comment line. + */ +
+
+ ) : ( + <> +

+ {sourceCaption(part.form, part.origin)} +

+ {part.truncated !== undefined && ( +
+
+ )} +
+ +
+ + )} +
+ + )} +
+ ); +} diff --git a/src/components/object-source/index.ts b/src/components/object-source/index.ts new file mode 100644 index 00000000..bbf8607c --- /dev/null +++ b/src/components/object-source/index.ts @@ -0,0 +1,20 @@ +/** + * What a SHELL imports from the object source package, and nothing else. + * + * The folder's own modules import each other by path and the tests import the unit under test + * by path, so a re-export here earns its place only by having a consumer OUTSIDE this + * directory: both shells mount `ObjectSourceView`, declare their tab state with + * `ObjectSourcePatch`, and the embedded shell builds an `ObjectSourceReader` from its host's + * method while the standalone one falls through to `httpSourceReader`. + * + * `sourceCaption`, `httpSourceReader` and `ObjectSourceViewProps` are deliberately absent, and + * the last two were REMOVED after the required `knip` check named them. Each is imported by + * path inside this folder, or by a test that names the module it lives in, and none has a + * consumer that reaches it through this file: the standalone shell mounts `ObjectSourceView` + * without passing a reader and the viewer falls through to `httpSourceReader` internally, so + * the fall-through is not an outside edge. `object-tree/index.ts` records what happens + * otherwise: knip named eighteen re-exported lines there as reaching nobody, and a barrel that + * re-exports everything cannot be read as a statement about what the outside uses (#789). + */ +export { ObjectSourceView, type ObjectSourcePatch } from "./ObjectSourceView"; +export { isSourceDocumentShape, type ObjectSourceReader } from "./source-reader"; diff --git a/src/components/object-source/source-caption.ts b/src/components/object-source/source-caption.ts new file mode 100644 index 00000000..80b748e9 --- /dev/null +++ b/src/components/object-source/source-caption.ts @@ -0,0 +1,42 @@ +import type { ObjectSourceForm, ObjectSourceOrigin } from "@/lib/db/types"; + +/** + * The one sentence that says what a definition on screen IS (#789). + * + * NOT decoration. Two measured readings go wrong without it, and both are silent: a + * PostgreSQL `pg_get_viewdef` answer is a bare SELECT with no `CREATE VIEW` in front of it and + * reads as a complete statement a user could copy and run, and a DuckDB macro body is the + * engine's regeneration from its catalog rather than the bytes anybody typed, which reads as + * the user's own text. The caption is what separates those from an SQL Server module, which is + * genuinely the author's stored bytes run as given. + * + * A PURE exported function, not JSX and not a hook, and that is standing ruling 5b's + * prescription rather than a style choice: happy-dom returns ZEROS for layout and a test that + * reaches this copy through a rendered element asserts less than it appears to. All six + * compositions are pinned in `tests/unit/components/object-source-caption.test.ts` with no DOM + * at all. + * + * TWO INDEPENDENT AXES and therefore two records rather than one six-cell table. `origin` says + * where the bytes came from and `form` says whether they run as given, and no engine couples + * the two: PostgreSQL produces `regenerated` in both forms (a view is `partial`, a function is + * `complete`), and Couchbase produces `partial` from a `rendered` origin. A keyed table would + * be six literals to keep in step for a fact that is three plus two. + */ + +/** Where the bytes came from. One sentence per arm of `ObjectSourceOrigin`, all three used. */ +const ORIGIN_SENTENCE: Readonly> = Object.freeze({ + stored: "Stored by the engine as it was submitted.", + regenerated: "Rebuilt by the engine from its catalog.", + rendered: "A structured definition, rendered here as JSON.", +}); + +/** Whether the bytes run as given. One clause per arm of `ObjectSourceForm`, both used. */ +const FORM_CLAUSE: Readonly> = Object.freeze({ + complete: "Complete as shown.", + partial: "This is the body only, not a complete statement.", +}); + +/** The caption for one part, composed origin first and form second. */ +export function sourceCaption(form: ObjectSourceForm, origin: ObjectSourceOrigin): string { + return `${ORIGIN_SENTENCE[origin]} ${FORM_CLAUSE[form]}`; +} diff --git a/src/components/object-source/source-reader.ts b/src/components/object-source/source-reader.ts new file mode 100644 index 00000000..0d7d8efc --- /dev/null +++ b/src/components/object-source/source-reader.ts @@ -0,0 +1,155 @@ +import { appFetch } from "@/lib/config/base-path"; +import { buildConnectionPayload } from "@/hooks/use-connection-payload"; +import { SOURCE_CHARACTER_LIMIT, SOURCE_PART_LIMIT } from "@/lib/db/object-kinds"; +import type { ObjectSourceDocument, ObjectSourceForm, ObjectSourceOrigin } from "@/lib/db/types"; +import type { DatabaseConnection } from "@/lib/types"; + +/** + * Who answers a source read, and what a renderer is allowed to believe about the answer (#789). + * + * This is its OWN seam and not a fourth member of the tree's `ObjectReadRequest`, which is a + * measured distinction rather than a preference. That type is paired with a `ReadSlot` whose + * three kinds each land in a `TreeCache` map, and `isRenderableShape` dispatches on the slot + * and not on the route; a source document is not an array, it caches nothing, and the surface + * that wants it is a TAB holding no handle on the tree's private source at all. Adding a fourth + * member would also oblige the embedded adapter's exhaustive switch to carry an arm for a state + * the design says cannot occur, which is the deleted 501 in a new place under a coverage gate. + * + * The return is `unknown` on purpose, for both shells rather than for the embedded one alone: a + * route's body and a host callback's return value are both ordinary values this component is + * about to dereference, and only one of them has a type declaration. + */ +export type ObjectSourceReader = ( + connection: DatabaseConnection, + path: readonly string[], + kind: string, +) => Promise; + +/** + * The default source: this application's own route. + * + * `buildConnectionPayload` sends a managed seed by id and anything else in full, which is how + * every other db route is called and the only way a connection the server has never heard of + * can be read at all. + */ +export const httpSourceReader: ObjectSourceReader = async (connection, path, kind) => { + const response = await appFetch("/api/db/objects/source", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...buildConnectionPayload(connection), path, kind }), + }); + // A route that answered with no body at all still answered something worth showing, so the + // status stands in for the sentence rather than the read being reported as a parse error. + const body = (await response.json().catch(() => ({}))) as { error?: string }; + if (!response.ok) { + throw new Error(body.error ?? `The source read failed with HTTP ${response.status}`); + } + return body; +}; + +const FORMS: readonly string[] = ["complete", "partial"] satisfies readonly ObjectSourceForm[]; +const ORIGINS: readonly string[] = ["stored", "regenerated", "rendered"] satisfies readonly ObjectSourceOrigin[]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** A string that carries a fact, rather than one that is present and says nothing. */ +function isFilledString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +/** + * The truncation mark, checked because the banner DEREFERENCES `reason` and prints it. + * + * A mark whose reason is missing would draw an empty warning banner above a text, which is a + * second spelling of the collapse this whole design exists to prevent: an attention state with + * nothing in it reads as decoration. + * + * THE REASON IS BOUNDED BY THE SAME NUMBER AS A TEXT, and it is the one host-supplied rendered + * string round 1's bound missed (#789 fix round 1). That round bounded the text and the refusal + * sentence on the rule "it is a text this component renders", and `ObjectSourceView` renders + * `part.truncated.reason` verbatim into the warning banner from the same unbounded host path. + * MEASURED before this line: a part carrying a five-million-character reason passed this + * predicate, so the whole of it reached a `
` on the one seam that has no route in front of + * it. An overrun is a failed read here for the same reason it is for a text: this seam cannot + * cut a sentence honestly, so it says the body is one it cannot render. + */ +function isTruncationShape(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.limit === "number" && + isFilledString(value.reason) && + value.reason.length <= SOURCE_CHARACTER_LIMIT + ); +} + +function isPartShape(part: unknown): boolean { + if (!isRecord(part)) return false; + if (!isFilledString(part.id)) return false; + if (!isFilledString(part.label)) return false; + // Both keys at once is the collapse, and it is checked BEFORE either arm is examined, + // because each arm on its own would accept the part. + if (Object.hasOwn(part, "unavailable") && Object.hasOwn(part, "text")) return false; + if (Object.hasOwn(part, "unavailable")) { + return isFilledString(part.unavailable) && part.unavailable.length <= SOURCE_CHARACTER_LIMIT; + } + if (!isFilledString(part.text)) return false; + if (part.text.length > SOURCE_CHARACTER_LIMIT) return false; + if (!isFilledString(part.language)) return false; + if (!FORMS.includes(part.form as string)) return false; + if (!ORIGINS.includes(part.origin as string)) return false; + if (Object.hasOwn(part, "truncated") && !isTruncationShape(part.truncated)) return false; + return true; +} + +/** + * The client's shape check, and the LIVE home of the invariants the compiler cannot hold. + * + * A predicate rather than a boolean, unlike `isRenderableShape`, so the caller narrows instead + * of casting. Written here because the embedded shell's document comes from a HOST: ordinary + * JavaScript whose declared return type is not a runtime guarantee. It is live for the + * standalone route too, where the body is JSON nobody typed. + * + * Four of these checks are not about malformed data at all, they are about two facts + * collapsing into one: + * - a part carrying BOTH keys narrows to the refusal and drops the text in silence, and + * MEASURED against tsc 6.0.3 our own compiler admits that literal, because TypeScript's + * excess-property check on a union accepts any property declared on any member of it; + * - a refusal with an empty sentence draws our headline over a blank line, which is the + * empty-versus-unreadable collapse this whole design exists to prevent, one level in; + * - an empty text is not a definition, and an editor holding one is the DBeaver shape, + * measured in its source: an unreadable definition in a WRITABLE editor holding one line; + * - a truncation mark with no reason is a warning banner with nothing in it. + * + * THE TWO BOUNDS ARE CHECKED HERE TOO, and that is the half the first round left open (#789). + * The route applies `SOURCE_CHARACTER_LIMIT` and `SOURCE_PART_LIMIT` to every answer it + * serialises, and the EMBEDDED shell has no route at all: its document comes from a host + * function, so without these two lines a host could hand the shell tens of megabytes per part + * and any number of parts, and the only thing between that and Monaco was this predicate. The + * refusal SENTENCE is bounded by the same number as a text, because it is a text this component + * renders and the route carries it through untouched, and so is a truncation mark's REASON, which + * is the third such string and the one that rule missed the first time (see `isTruncationShape`). + * + * AN OVERRUN IS A FAILED READ, never a silent truncation, and that is a decision rather than a + * shortcut: `truncated` is a claim about WHERE the cut was made and by whom, and this seam + * cannot make it honestly. It does not know whether the host already cut the text, so a mark + * composed here would either restate the host's bound as ours or hide that two cuts happened. + * The viewer's failure grammar says what it can say, which is that the read answered a body it + * cannot render. + * + * Two parts sharing one id is rejected for a different reason, and it is the switcher's: + * `activePartId` addresses a part by id, so two parts under one id make the selection + * unresolvable and a click on the second tab select the first. + */ +export function isSourceDocumentShape(value: unknown): value is ObjectSourceDocument { + if (!isRecord(value)) return false; + if (!Array.isArray(value.path) || !value.path.every((segment) => typeof segment === "string")) return false; + if (typeof value.kind !== "string") return false; + if (!Array.isArray(value.parts) || value.parts.length === 0) return false; + if (value.parts.length > SOURCE_PART_LIMIT) return false; + if (!value.parts.every(isPartShape)) return false; + const ids = new Set((value.parts as Record[]).map((part) => part.id as string)); + if (ids.size !== value.parts.length) return false; + return true; +} diff --git a/src/components/object-tree/TreeRow.tsx b/src/components/object-tree/TreeRow.tsx index aad67721..9cae2559 100644 --- a/src/components/object-tree/TreeRow.tsx +++ b/src/components/object-tree/TreeRow.tsx @@ -233,10 +233,11 @@ export function TreeRow({ {/* The visible way in (Task 33). `hasActions` is the SAME answer the right click asks and the same one `aria-haspopup` above announces, so a row that offers nothing shows - no trigger and the two entry points cannot drift apart. Every action is gated on - `role === "relation"` today, which is why a routine, a trigger and a sequence have - none; Phase 3's source editing gives routines actions, and this trigger then appears - on them with no change here. + no trigger and the two entry points cannot drift apart. Most actions are gated on + `role === "relation"`, and View Source is the one that is not: #789 gates it on the + kind's own `hasSource` declaration, so a routine, a trigger and a sequence now show + this trigger wherever their engine declares a definition text for them, with no + change here. A kind that declares neither still shows nothing. */} {hasActions === true && ( )} diff --git a/src/exports/types.ts b/src/exports/types.ts index cd29e3e6..17c5c8d3 100644 --- a/src/exports/types.ts +++ b/src/exports/types.ts @@ -49,4 +49,8 @@ export type { KindCount, ObjectDetail, ObjectDetailBatch, + ObjectSourceForm, + ObjectSourceOrigin, + ObjectSourcePart, + ObjectSourceDocument, } from "../lib/db/types"; diff --git a/src/hooks/use-tab-manager.ts b/src/hooks/use-tab-manager.ts index e95ec14a..59969d68 100644 --- a/src/hooks/use-tab-manager.ts +++ b/src/hooks/use-tab-manager.ts @@ -2,10 +2,11 @@ import { useState, useCallback, useEffect, useMemo } from "react"; import type { DatabaseConnection, QueryTab } from "@/lib/types"; +import type { DatabaseObject } from "@/lib/db/types"; import type { DetailedObject } from "@/lib/db/detailed-object"; import type { ProviderMetadata } from "@/hooks/use-provider-metadata"; import { generateTableQuery, generateSelectQuery, objectSegment } from "@/lib/query-generators"; -import { pathKey } from "@/lib/db/object-path"; +import { objectPathLabel, pathKey } from "@/lib/db/object-path"; import { resolveTabType } from "@/lib/editor/tab-language"; import { logger } from "@/lib/logger"; import { newLocalId } from "@/lib/ids"; @@ -19,6 +20,22 @@ const DEFAULT_TAB: QueryTab = { type: "sql", }; +/** The tab a Source read is mounted in: an ADDRESS, an empty query and no result (#789). */ +function sourceTab(id: string, object: DatabaseObject): QueryTab { + return { + id, + // The QUALIFIED path and not the object's label: two containers may hold a routine of the + // same name, and the tab strip is the only place a reader can tell two open Source tabs + // apart. + name: `Source: ${objectPathLabel(object.path)}`, + query: "", + result: null, + isExecuting: false, + type: "sql", + source: { path: object.path, kind: object.kind }, + }; +} + const WORKSPACE_STORAGE_PREFIX = "libredb_workspace_tabs_v1"; interface PersistedTabState { @@ -26,6 +43,55 @@ interface PersistedTabState { name: string; query: string; type: QueryTab["type"]; + /** + * A Source tab's ADDRESS, and never one character of its definition (#789 Phase 2). + * + * `SourceTabState` also carries the document, the failure sentence, the active part and the + * read token; none of the four is written here, and the reason is arithmetic rather than + * taste. This record is one `JSON.stringify` of the WHOLE workspace, written by the + * `setItem` below with no `try`/`catch` around it, against an origin quota of about 5 MiB + * that ten other collections in this application already share. A definition the user did + * not type is unbounded from the shell's point of view - the route bounds one part at a + * million characters - so persisting it is a `QuotaExceededError` waiting for a large enough + * object, and the symptom would not be a broken Source tab: an uncaught throw in that timer + * stops tab persistence for EVERYTHING. + * + * So a restored Source tab carries the address alone and RE-READS, which costs one request + * per restored tab and is the same read the tab issued when it was opened. The viewer + * already treats "no document and no failure" as its cue to read, so nothing else is needed + * to make it happen. + */ + source?: { path: readonly string[]; kind: string }; +} + +/** + * Is what came back out of `JSON.parse` an ADDRESS, rather than something shaped like one? + * + * This is the first persisted field anything DEREFERENCES, and that is the whole reason it + * needs a check the other four do not (#789 Phase 2, round 1 finding 2). `id`, `name`, + * `query` and `type` are strings that get rendered; a truncated or hand-edited one is a wrong + * label. `source` is branched on by the editor pane and its `path` is read by the viewer's + * `pathKey(path)` on the first line of its body, so a record carrying `source: {}` renders a + * pane that throws "undefined is not an object (evaluating 'path.join')". MEASURED before this + * function existed: that throw happens during a mount rather than inside the LOAD effect's + * `try`, nothing here catches it, there is no error boundary around the pane, and the whole + * shell white-screens with the reader unable to reach the tab strip to close the tab. + * + * `source: null` was survivable only by accident: it threw on `tab.source.path` INSIDE the + * effect's `try`, so the fallback ran and every other tab in the workspace was lost with it. + * Both shapes are now dropped key by key, so a bad address costs its own tab's source arm and + * nothing else. An unreadable stored value is not a state to recover into: the entry says the + * tab is a Source tab and cannot say for which object, and the honest answer is the ordinary + * empty tab the record's other four fields already describe. + * + * The elements of `path` are deliberately NOT walked. A non-string segment reaches the route, + * which validates the whole request shape server-side and answers its own sentence, and the + * viewer renders that sentence: one refusal in the pane beats a second vocabulary here. + */ +function isStoredSourceAddress(value: unknown): value is { path: readonly string[]; kind: string } { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { path?: unknown; kind?: unknown }; + return Array.isArray(candidate.path) && typeof candidate.kind === "string"; } interface PersistedWorkspaceState { @@ -90,6 +156,13 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks type: tab.type, result: null, isExecuting: false, + // The address only, and an absent OR malformed key stays absent: every record written + // before this field existed is a tab that is not a Source tab, and there is no + // migration because "no source" is exactly what those records mean. The three + // read-state fields are deliberately not restored, so the viewer issues the read + // (#789). See `isStoredSourceAddress` for why this one field is checked and the other + // four are not. + ...(isStoredSourceAddress(tab.source) ? { source: { path: tab.source.path, kind: tab.source.kind } } : {}), })); const hasActiveTab = restoredTabs.some((tab) => tab.id === parsed.activeTabId); @@ -133,6 +206,8 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks name: tab.name, query: tab.query, type: tab.type, + // Two fields of `SourceTabState` and never the other four: see `PersistedTabState`. + ...(tab.source === undefined ? {} : { source: { path: tab.source.path, kind: tab.source.kind } }), })), }; storage.setItem(workspaceKey, JSON.stringify(serialized)); @@ -260,6 +335,80 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks [metadata, schema], ); + /** + * Open one object's DEFINITION in a read-only Source tab, or focus the one already open + * against that object (#789 Phase 2). + * + * The tab carries the ADDRESS and nothing else. It holds no document, no failure and no + * read token, and that absence is the instruction: the viewer reads when it is handed + * neither, so a freshly opened tab and a tab restored from storage take the same path. + * + * The match is `pathKey(path)` plus the KIND, and both halves are load-bearing. + * `pathKey` rather than a join or a `JSON.stringify` per standing ruling 5g: its separator + * is a control character no engine admits inside an identifier, so `["a.b"]` and + * `["a", "b"]` cannot collide, while JSON escaping rewrites exotic names. And the kind, + * because one name addresses more than one object of different kinds in one container on + * MySQL, MariaDB and DuckDB, measured in this epic, so matching on the path alone would + * focus the procedure's tab for a reader who asked for the table's definition. + * + * `type` is the neutral `"sql"`. Nothing reads it on a Source tab: the tab bar's icon and + * the editor pane both branch on `source` being present, and the definition's own Monaco + * language travels on the PART the provider built rather than on the tab. + */ + const openSourceTab = useCallback( + (object: DatabaseObject) => { + const key = pathKey(object.path); + const matchesAddress = (tab: QueryTab): boolean => + tab.source !== undefined && tab.source.kind === object.kind && pathKey(tab.source.path) === key; + /* + * A Source tab already open for this address is FOCUSED rather than minted again, and + * the read from `tabs` here is the committed list, which is what carries the id of a + * tab restored from `localStorage` with an id this function did not choose. + */ + const open = tabs.find(matchesAddress); + if (open !== undefined) { + setActiveTabId(open.id); + return; + } + /* + * The id is DERIVED FROM THE ADDRESS rather than minted, and that is what makes two + * opens inside one React batch safe (#789 Phase 2, round 1 finding 3). + * + * The defect: the dedup above reads the `tabs` of the render that built this callback, + * so two calls in ONE batch both miss and, with a minted id, both append. The tab strip + * then held two tabs with identical names, the first orphaned and read by nothing. Two + * separate DOM events flush between them, which is why no gesture in this shell reaches + * it; a host callback calling the embedded adapter twice does. + * + * MEASURED, and it is why the dedup was not simply moved inside the updater, which is + * the obvious fix: `setActiveTabId` runs when the event runs, while the updater runs at + * the next render, so an id resolved inside the updater is not yet known at the moment + * the active tab is set. With that shape the strip held one tab and `activeTabId` named + * the second call's unused id, so `currentTab` fell back to `tabs[0]` and the reader + * pressing View Source twice landed on the query tab. + * + * Deriving the id closes both halves at once: both calls in the batch compute the same + * id, so the second finds the first's append inside the updater and neither the strip + * nor the active id can disagree. Uniqueness is not weakened: the address of a Source + * tab is unique by construction, because this is the only function that mints one and + * it refuses to mint a second for an address already open. + * + * ENCODED, and that is not decoration. Every other tab id in this shell is random + * alphanumeric, and `StudioTabBar` moves focus with + * `querySelector('[role="tab"][data-tab-id=""]')`, so putting an object NAME inside + * an id puts it inside a CSS selector. MEASURED: an Oracle-shaped routine segment, + * `"char"(integer)`, made that selector invalid and the arrow key threw a DOMException + * that took the whole strip down. `encodeURIComponent` leaves only characters an + * attribute selector accepts, and it is applied to each part separately so the two + * cannot run together: an encoded kind cannot contain the separator. + */ + const newId = `source:${encodeURIComponent(object.kind)}:${encodeURIComponent(key)}`; + setTabs((prev) => (prev.some(matchesAddress) ? prev : [...prev, sourceTab(newId, object)])); + setActiveTabId(newId); + }, + [tabs], + ); + return { tabs, setTabs, @@ -276,5 +425,6 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks updateTabById, handleTableClick, handleGenerateSelect, + openSourceTab, }; } diff --git a/src/lib/api/object-route.ts b/src/lib/api/object-route.ts index b8d9565c..be297fe2 100644 --- a/src/lib/api/object-route.ts +++ b/src/lib/api/object-route.ts @@ -3,7 +3,16 @@ import { getOrCreateProvider } from "@/lib/db"; import { createErrorResponse } from "@/lib/api/errors"; import { resolveConnection } from "@/lib/seed/resolve-connection"; import { guardRoute } from "@/lib/api/require-session"; -import { containerDepth, declaredKinds, findKind } from "@/lib/db/object-kinds"; +import { + SOURCE_PART_LIMIT, + applySourceBound, + containerDepth, + declaredKinds, + findKind, + isSourcePartUnavailable, + kindHasSource, + sourceBoundTruncationReason, +} from "@/lib/db/object-kinds"; import { INVENTORY_LIMIT, INVENTORY_PAIR_LIMIT, PAIR_TRUNCATION_REASON } from "@/lib/db/inventory-bounds"; import type { DatabaseConnection, @@ -11,14 +20,18 @@ import type { DatabaseProvider, ObjectDetail, ObjectKindSpec, + ObjectSourceDocument, + ObjectSourcePart, } from "@/lib/db/types"; /** - * Shared request handling for the six object-tree routes under /api/db/objects (#789). + * Shared request handling for the seven object routes under /api/db/objects (#789). * - * One handler rather than six copies, on the precedent of `src/lib/api/schema-route.ts`: the - * guard-then-parse ordering below is a security property, and six copies of it would be six - * chances for one of them to drift back to parsing first. + * One handler rather than seven copies: the guard-then-parse ordering below is a security + * property, and seven copies of it would be seven chances for one of them to drift back to + * parsing first. The seventh, the source read, was built on this handler rather than beside it + * and inherited auth-before-parse, rate limiting, connection resolution and error mapping with no + * new line of any of them. * * `route` is the same string the caller passes for error-response context, so `POST /${route}` * reuses it rather than threading a second, guard-specific string through every call site. @@ -209,6 +222,140 @@ export function resolveKinds(provider: DatabaseProvider, requested?: readonly st }); } +/** + * The provider's source reader for one kind, or a 400 saying it has none (#789 Phase 2). + * + * ONE branch with TWO conjuncts, on purpose. The first is reachable on every engine: a kind that + * declares no `hasSource` is an ordinary thing to ask for, because a caller can hold a stale menu + * or a path it built itself. The second is reachable through a provider that declares the kind and + * omits the method, which `readObjectSource` being optional makes representable and only this + * check makes visible. Folding them into two `if`s would give the second one a line whose only + * purpose is a state the first already excluded, which is the shape the deleted 501 arm had. + * + * It is a helper here rather than a `throw` in the route for the reason every 400 in this module + * is: `ObjectRouteError` is module-private, and keeping it private is what keeps the status + * vocabulary in one file rather than letting each route mint its own. + * + * 400 and not 404 or 501, following `resolveKinds`: answering nothing reads as a claim about the + * DATA when the truth is a claim about the ENGINE. + * + * The returned function is BOUND to the provider, because it is read off the instance as a value + * and a provider method that reaches its own pool through `this` would otherwise be called with + * no receiver. + */ +export function requireSourceReader( + provider: DatabaseProvider, + kind: string, +): (path: readonly string[], kind: string, limit?: number) => Promise { + const read = provider.readObjectSource; + if (!kindHasSource(provider.getCapabilities(), kind) || read === undefined) { + throw new ObjectRouteError(`${provider.type} declares no readable source for kind "${kind}"`, 400); + } + return read.bind(provider); +} + +/** + * The answered document under the route's OWN bound (#789 Phase 2). + * + * The route ENFORCES rather than trusts, which is the shipped precedent and not a new rule: the + * inventory route applies its own two bounds on top of the bound it hands `describeObjects`. The + * callers behind this one are the sixteen providers that implement `readObjectSource`, and the + * route materialises the whole answer and serialises it in one `NextResponse.json`, so this is the + * one place a memory bound can actually be held. A number merely PASSED to an implementation is a + * request, not a bound. + * + * CORRECTED after review, because the first version of this paragraph named a caller that cannot + * reach it. MEASURED: `handleObjectRequest` takes its provider from `getOrCreateProvider`, which + * resolves through a closed `switch (connection.type)` in `src/lib/db/factory.ts` with no + * registration point for anyone else, and the embedded shell has no API routes at all, so a host + * implementing the workspace source seam reads through its own function and never through this + * module. The bound is here for a PROVIDER defect, which is enough on its own. + * + * A provider that bounded correctly is returned unchanged, which is what makes the walk safe to + * run on every answer. A provider that bounded at its own SMALLER limit is also unchanged, because + * its text already fits. Only a provider that over-answered is sliced, and its own sentence is + * KEPT and joined rather than replaced: a second bound is a second fact. + * + * `parts.length` is bounded too, because the tuple type has no upper bound and the real response + * size is `limit` times the part count. `SOURCE_PART_LIMIT` is four times the largest shape any + * engine in the fleet produces, so no correct provider can reach it and a provider that does is a + * defect rather than a database fact. + * + * The EMPTY document is refused by name rather than left to the destructuring below. `parts` is a + * non-empty tuple in the type and a JavaScript caller is not held to it, and MEASURED before this + * guard existed, `parts: []` reached `"unavailable" in part` on `undefined` and raised + * `TypeError: part is not an Object`, which `createErrorResponse` reports as an unhandled error + * rather than as the caller's mistake it is. + */ +export function boundSourceDocument(document: ObjectSourceDocument, limit: number): ObjectSourceDocument { + if (document.parts.length === 0) { + throw new ObjectRouteError( + "the source read answered a document with no parts, and a source document names at least one", + 400, + ); + } + if (document.parts.length > SOURCE_PART_LIMIT) { + throw new ObjectRouteError( + `the source read answered ${document.parts.length} parts and this route carries at most ${SOURCE_PART_LIMIT}`, + 400, + ); + } + // Destructured rather than mapped, because `parts` is a NON-EMPTY tuple and `Array.prototype.map` + // answers a plain array that no longer satisfies it. + const [first, ...rest] = document.parts; + return { ...document, parts: [boundPart(first, limit), ...rest.map((part) => boundPart(part, limit))] }; +} + +/** + * One part under the bound, and the one malformed shape the bound cannot hold. + * + * The hybrid is refused BEFORE the narrowing, and that order is the whole guard. MEASURED against + * tsc 6.0.3 and recorded on `ObjectSourcePart` itself: a part carrying `unavailable` BESIDE + * `text`, `language`, `form` and `origin` COMPILES with no cast, because the excess-property check + * on a union admits any property declared on ANY member of it. `isSourcePartUnavailable` asks + * `"unavailable" in part`, so such a part narrows to the refusal arm and the line below would + * return it untouched: MEASURED through this function at a 1,000,000 bound, a hybrid carrying + * 2,000,000 characters came back with its text whole, 2,000,141 characters of JSON on the wire, + * while a client narrowing the same way renders a refusal over the definition the engine really + * returned. `assertObjectSurface` refuses the shape for our own providers, and the check runs only + * in the provider suites, so this is where the same refusal reaches a running server (#789). + * + * A 400 in this module's own vocabulary and not a silent repair. Bounding the text would keep the + * memory bound and still ship a part that reads as a refusal over a real definition, which is the + * exact collapse the union exists to prevent. + * + * Below it, a refusal carries no text, so there is nothing to bound and nothing to mark. Reading + * `.text` on one would be a property access on the arm that does not declare it. + */ +function boundPart(part: ObjectSourcePart, limit: number): ObjectSourcePart { + if (isSourcePartUnavailable(part) && Object.hasOwn(part, "text")) { + throw new ObjectRouteError( + "the source read answered a part that carries both a refusal and a text; a refusal and a definition " + + "are different facts and a reader must never be shown one over the other", + 400, + ); + } + if (isSourcePartUnavailable(part) || part.text.length <= limit) return part; + const reason = sourceBoundTruncationReason(limit); + /* + * ONE SLICER for the fleet, and the route was the second one (#789). It cut with a bare + * `part.text.slice(0, limit)` while all sixteen providers cut through `applySourceBound`, + * which drops an orphaned surrogate half: the bound counts UTF-16 CODE UNITS, so it can land + * BETWEEN the two halves of an astral character, and MEASURED through this function, a text + * holding an emoji at exactly the boundary came back ending in `\ud83d`, which is not a + * character and which JSON serialises as a lone escape. + * + * Only `.text` is taken from it. The MARK is composed here, because this route's second bound + * has a fact the helper does not: a provider that already bounded at its own smaller limit + * keeps its own sentence and this one is JOINED to it rather than replacing it. + */ + return { + ...part, + text: applySourceBound(part.text, limit).text, + truncated: { limit, reason: part.truncated === undefined ? reason : `${part.truncated.reason}; ${reason}` }, + }; +} + // The four inventory bounds are `src/lib/db/inventory-bounds.ts`'s, and they are re-exported // here because this route and the agent's grounding walk have to bound one read the same way. // They were declared in both modules until Task 28a gave them one owner (#789). diff --git a/src/lib/api/rate-limit.ts b/src/lib/api/rate-limit.ts index bed18a43..51804d63 100644 --- a/src/lib/api/rate-limit.ts +++ b/src/lib/api/rate-limit.ts @@ -119,14 +119,25 @@ const BUCKETS: Record = { // many model calls of its own, so this bounds how often LLM work is STARTED, never how much it // spends. ai: { maxVar: "RATE_LIMIT_AI_MAX", windowVar: "RATE_LIMIT_AI_WINDOW_SEC", maxDefault: 20, windowDefault: 60 }, - // Shared across every route that reaches a database - query, multi-query, transaction, - // disconnect, cancel, health, maintenance, monitoring, pool-stats, profile, provider-meta, - // schema, schema/list, schema/relations, schema-snapshot, test-connection, admin/fleet-health, - // plus the three storage routes (storage, storage/[collection], storage/migrate): TWENTY routes - // today (grep -rl 'bucket: "query"' src/app/api/ finds eighteen; schema/list and schema/relations - // reach this bucket indirectly, through schema-route.ts's shared handleSchemaRequest). The same - // workload reached through a different endpoint must not get a second budget - re-verify and - // correct this comment again if guardRoute grows a new call site. + // Shared across every route that reaches a database. RE-MEASURED 2026-09-12 (#789 Phase 2), + // because the previous count was stale in both directions: it said TWENTY and named four schema + // routes that no longer exist. `src/lib/api/schema-route.ts`, `db/schema`, `db/schema/list`, + // `db/schema/relations` and `db/schema-snapshot` were all removed with the object surface, and + // the object routes it never mentioned had joined. + // + // TWENTY-THREE handlers today, and there are two ways in, which is why one grep under-counts. + // Directly, sixteen call sites that pass bucket: "query" to guardRoute themselves + // (grep -rl 'bucket: "query"' src/app/api/ finds all sixteen): admin/fleet-health, db/cancel, + // db/disconnect, db/health, db/maintenance, db/monitoring, db/multi-query, db/pool-stats, + // db/profile, db/provider-meta, db/query, db/test-connection, db/transaction, and the three + // storage routes (storage, storage/[collection], storage/migrate). Note db/health: only its POST + // is metered, because the GET is the container health probe and takes no connection. + // Indirectly, the SEVEN object routes under db/objects (containers, counts, list, describe, + // search, inventory, source), which reach this bucket through handleObjectRequest in + // object-route.ts and so carry no bucket literal of their own. + // + // The same workload reached through a different endpoint must not get a second budget - + // re-verify and correct this comment again if guardRoute grows a new call site. // // The storage family joined when AU1 moved it onto the shared 401 (2026-08-22), and that gave it // a limiter it never had. It belongs here rather than in a bucket of its own: under diff --git a/src/lib/db/factory.ts b/src/lib/db/factory.ts index 5a2446e1..36e240ac 100644 --- a/src/lib/db/factory.ts +++ b/src/lib/db/factory.ts @@ -185,7 +185,7 @@ export async function createDatabaseProvider( throw new DatabaseConfigError( // This list is NOT type-checked against the union - a new case above with no // entry here is silent - so it is kept in the same order as the cases and - // tests/unit/db/factory.test.ts pins individual names in it by regex. + // tests/isolated/factory.test.ts pins individual names in it by regex. `Unknown database type: ${connection.type}. Supported types: postgres, mysql, sqlite, duckdb, libsql, oracle, mssql, clickhouse, druid, trino, cassandra, elasticsearch, opensearch, mongodb, couchbase, redis, libredb`, connection.type, ); diff --git a/src/lib/db/object-kinds.ts b/src/lib/db/object-kinds.ts index 68db765f..fa3c6c06 100644 --- a/src/lib/db/object-kinds.ts +++ b/src/lib/db/object-kinds.ts @@ -5,7 +5,8 @@ * is answered here, in one place, so the defaults cannot drift: an absent * `acceptsRowWrites` reads as false in every caller because there is only one caller. */ -import type { KindCount, ObjectKindSpec, ProviderCapabilities } from "@/lib/db/types"; +import { QueryError } from "@/lib/db/errors"; +import type { DatabaseType, KindCount, ObjectKindSpec, ObjectSourcePart, ProviderCapabilities } from "@/lib/db/types"; /** * How many container levels this engine declares, as the tree models them. @@ -129,3 +130,137 @@ export function isCountSampled(count: KindCount): count is { readonly count: num export function callerBoundTruncationReason(limit: number): string { return `the bulk column read was bounded at ${limit} object${limit === 1 ? "" : "s"} by its caller`; } + +/** + * Whether THIS KIND has a readable definition (#789 Phase 2). + * + * Absent and undeclared both read as FALSE, and the name says the scope so a caller cannot + * inline the default. It is NOT conjoined with anything, for the same reason + * `kindAcceptsRowWrites` is not: the per-object question is a different one, and only the READ + * can answer it. Four kinds in the fleet are readable for some of their objects and not + * others, and this answers for the kind. + */ +export function kindHasSource(capabilities: ProviderCapabilities, id: string): boolean { + return findKind(capabilities, id)?.hasSource === true; +} + +/** + * The narrowing predicate for a refused part (#789 Phase 2). + * + * The `readonly` on every member is LOAD-BEARING and measured against TypeScript 6.0.3: a + * predicate written without it narrows the true branch and NOTHING on the false branch, so + * every caller is left holding the whole union with no `.text` on it. The one-property spelling + * `isCountUnavailable` uses does not compile here at all, because `ObjectSourcePart` has three + * required members on the refused arm, and that red build is the safe direction. + */ +export function isSourcePartUnavailable( + part: ObjectSourcePart, +): part is { readonly id: string; readonly label: string; readonly unavailable: string } { + return "unavailable" in part; +} + +/** The default per-part character bound the source route applies when a caller names none. */ +export const SOURCE_CHARACTER_LIMIT = 1_000_000; + +/** + * The most parts one document may carry before the route refuses it. + * + * The tuple type has no upper bound and the shipped maximum is two (an Oracle or MariaDB + * package), but the embedded seam takes its document from a HOST outside our compiler, so the + * real response size is `SOURCE_CHARACTER_LIMIT` times `parts.length` unless something bounds + * the count. Four times the largest shape any engine produces, so no correct provider can + * reach it. + */ +export const SOURCE_PART_LIMIT = 8; + +/** + * The ONE sentence a caller's source bound is reported with (#789 Phase 2). + * + * A function beside `callerBoundTruncationReason` rather than a reuse of it: the two bound + * different things and the existing sentence names objects. One place for the same reason that + * one records, which is that eleven implementers wrote three unrelated phrasings for one event + * before it was written down. + */ +export function sourceBoundTruncationReason(limit: number): string { + return `the source read was bounded at ${limit.toLocaleString("en-US")} characters by its caller`; +} + +/** + * One part's text under a caller's bound, with the mark the bound owes (#789 Phase 2). + * + * Hoisted here rather than written sixteen times, on the evidence that `comparePaths` was + * written four times before anyone owned it. An exact answer is NEVER marked, which is the + * rule `sampledFrom` already follows verbatim, because marking one teaches a reader to + * discount every mark. + */ +export function applySourceBound( + text: string, + limit: number | undefined, +): { readonly text: string; readonly truncated?: { readonly limit: number; readonly reason: string } } { + if (limit === undefined || text.length <= limit) return { text }; + const cut = text.slice(0, limit); + // The bound counts UTF-16 CODE UNITS, so it can land BETWEEN the two halves of a surrogate + // pair, and an astral character is exactly that: a PL/pgSQL body or a Lua library holding an + // emoji, cut at that offset, would end in an unpaired high surrogate. That is not a + // character, JSON serializes it as a lone escape and Monaco draws a replacement glyph, so + // the pair is dropped whole. The last unit of the cut can only BE a high surrogate when its + // low half sits at `limit` in the original, because this arm runs only when the text is + // longer than the bound. `truncated.limit` still names the CALLER's number rather than the + // emitted length: the bound is what was asked for, and reporting anything else describes a + // bound nobody set. + const last = cut.charCodeAt(cut.length - 1); + const kept = last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut; + return { text: kept, truncated: { limit, reason: sourceBoundTruncationReason(limit) } }; +} + +/** + * The entry guard every `readObjectSource` opens with, in ONE place (#789 Phase 2). + * + * Hoisted in the same spirit as `applySourceBound` above it and for the same measured reason + * standing ruling 5h gives for `comparePaths`: this preamble was written NINE times, verbatim, + * across `sqlite`, `libsql`, `clickhouse`, `cassandra`, `trino`, `postgres`, `mssql`, `mysql` + * and `duckdb`, and SonarCloud's duplication report on PR #820 named one of its copies as a + * block repeating across five providers at once. The only thing that ever differed between the + * nine was the engine's display name and its type id, so both are arguments. + * + * THREE SEPARATE FACTS, THREE SEPARATE SENTENCES, and collapsing them would lose a distinction + * a caller acts on. A kind the engine never declared is a caller asking for something that does + * not exist here; a declared kind with no `hasSource` is the engine having no such text at all; + * a source-bearing kind with no `sourceLanguage` is a DECLARATION missing half of itself, and + * it raises rather than defaulting because an unregistered or absent Monaco id degrades to + * plain text with no throw and nothing observable, so a kind that declared source and forgot + * its language would ship a Source tab that had quietly stopped highlighting. The wording of + * all three is carried over unchanged from the nine copies, because the provider suites assert + * on those sentences and a reworded throw would be a behaviour change hiding inside a hoist. + * + * THE ENGINE IS ONE ARGUMENT rather than two adjacent strings: `displayName` and `type` are + * both strings, a positional pair of them can be swapped silently, and an object at the call + * site names each one. There is no display-name registry to read either from: `compatibility.ts` + * holds no such map and `ProviderLabels` carries entity words rather than a product name, so + * inventing one to serve one message would be a larger change than this one. Each provider + * already writes its own name as a literal and passes that literal. + * + * The return narrows `sourceLanguage` to `string`, which is the whole point of the third throw: + * the caller reads `spec.sourceLanguage` with no `??` and no second undefined check. + */ +export function requireSourceKind( + capabilities: ProviderCapabilities, + kind: string, + engine: { readonly displayName: string; readonly type: DatabaseType }, +): ObjectKindSpec & { readonly sourceLanguage: string } { + const spec = findKind(capabilities, kind); + if (spec === undefined) { + throw new QueryError(`${engine.displayName} declares no object kind "${kind}"`, engine.type); + } + if (spec.hasSource !== true) { + throw new QueryError(`${engine.displayName} publishes no definition text for the kind "${kind}"`, engine.type); + } + const { sourceLanguage } = spec; + if (sourceLanguage === undefined) { + throw new QueryError( + `${engine.displayName} declares readable source for the kind "${kind}" and no sourceLanguage to render it with`, + engine.type, + ); + } + return { ...spec, sourceLanguage }; +} diff --git a/src/lib/db/providers/document/couchbase/index.ts b/src/lib/db/providers/document/couchbase/index.ts index 0bd5ddec..dc935dc3 100644 --- a/src/lib/db/providers/document/couchbase/index.ts +++ b/src/lib/db/providers/document/couchbase/index.ts @@ -22,7 +22,13 @@ */ import { BaseDatabaseProvider } from "@/lib/db/base-provider"; -import { callerBoundTruncationReason, containerDepth, declaredKinds, findKind } from "@/lib/db/object-kinds"; +import { + applySourceBound, + callerBoundTruncationReason, + containerDepth, + declaredKinds, + findKind, +} from "@/lib/db/object-kinds"; import { AuthenticationError, ConnectionError, DatabaseConfigError, QueryError, TimeoutError } from "@/lib/db/errors"; import { type ActiveSession, @@ -38,6 +44,8 @@ import { type MaintenanceType, type ObjectDetail, type ObjectDetailBatch, + type ObjectSourceDocument, + type ObjectSourcePart, type PerformanceMetrics, type PreparedQuery, type ProviderCapabilities, @@ -66,8 +74,12 @@ import { COUCHBASE_OBJECT_KINDS, containerRead, type ContainerNameRow, + COUCHBASE_SOURCE_PART_ID, + COUCHBASE_SOURCE_PART_LABEL, type CouchbaseFunctionRow, type CouchbaseObjectRow, + functionAddress, + functionBodyText, FUNCTIONS_SQL, INDEXES_SQL, isInsideContainer, @@ -885,6 +897,141 @@ export class CouchbaseProvider extends BaseDatabaseProvider { return bounded ? { details, truncated: { limit, reason: callerBoundTruncationReason(limit) } } : { details }; } + /** + * One object's definition text (#789 Phase 2). + * + * ONE kind can answer here and the DECLARATION says which, never the kind id: `function` + * declares `hasSource` and `collection` and `index` do not. Both absences are facts about + * the engine rather than gaps. A collection is SCHEMALESS, so there is no definition + * anybody wrote for one. An index is the interesting one: `system:indexes` publishes the + * index's name, its keys, its `using` and its state, and NO field carrying the + * `CREATE INDEX` text, so a Source tab there could only show a statement this product + * composed out of the keys. That is the same fabrication rule the DuckDB macro is captioned + * `partial` to avoid, reached from the other side: there the engine publishes a body and + * the caption says how much of the definition it is, here the engine publishes no body at + * all and the kind declares nothing. + * + * THE READ IS THE CATALOG READ THE LISTING ALREADY MAKES. `FUNCTIONS_SQL` gained + * `f.definition` beside the `f.identity` it always read, so the source read and the listing + * cannot come to look at different sets of functions, and the function's identity reaches + * NO statement: it is matched in code against the rows the listing itself produces, exactly + * as `kindObjects` places them. There is no bind and no interpolated identifier here, so + * this engine's source read has no escaper to get wrong. + * + * A FUNCTION THE CATALOG DOES NOT HOLD RAISES, and on this engine that covers a case worth + * naming, because it is not a bug: `system:functions` FILTERS BY PERMISSION rather than + * refusing. Measured on Server 8.0.2 Community on 2026-09-13, a `bucket_full_access[travel]` + * user reading the whole catalog is answered the two `travel` functions and NOT the global + * one, and a `ro_admin` user is answered zero rows with `"status": "success"`. So a caller + * who may not see a function meets ABSENCE, indistinguishable from a function that was + * never created, and the raise is the honest answer to both. `docs/providers/couchbase.md` + * records it. + * + * A REFUSED STATEMENT RAISES TOO, through `objectRows`, rather than becoming a refusal + * part. The read is namespace-wide and says nothing about this object in particular, and + * `countObjects` already carries the cluster's own sentence for that same read as + * `{ unavailable }` per kind, which is where a whole-surface refusal belongs. This is + * declared in the provider doc rather than left to be inferred. + * + * The bucket and the scope come from the segments the DECLARATION assigns to the `catalog` + * and `schema` levels and the name is `path[path.length - 1]`, never `path[0]`, `path[1]` + * or `path[2]`: standing ruling 5g, pinned in the suite by a declaration that swaps the two + * levels over and by one that pushes the name to a fourth segment. + */ + public async readObjectSource(path: readonly string[], kind: string, limit?: number): Promise { + this.ensureConnected(); + const capabilities = this.getCapabilities(); + const spec = findKind(capabilities, kind); + if (spec?.hasSource !== true) { + throw new QueryError(`Couchbase declares no readable source for the kind "${kind}"`, this.type); + } + // The Monaco language is READ off the declaration and never defaulted. A `?? "sql"` here + // is dead against the shipped declaration and silently wrong the moment it fires: a kind + // that gained `hasSource` without a language would be answered `sql` for something that + // is not SQL++, and the pane would pick a wrong mode with nothing anywhere saying so. + const language = spec.sourceLanguage; + if (language === undefined) { + throw new QueryError( + `Couchbase declares source for the kind "${kind}" and no sourceLanguage, so its text has no language to render in`, + this.type, + ); + } + // The same shape check `describeObject` makes, in the same words, so a caller cannot be + // told two different things about one path. + checkObjectPath(capabilities, spec, path); + const address = functionAddress(capabilities, path); + + const rows = await this.objectRows(FUNCTIONS_SQL); + const row = rows.find((candidate) => { + const identity = resolveFunctionIdentity(candidate); + return ( + identity !== undefined && + identity.bucket === address.bucket && + identity.scope === address.scope && + identity.name === address.name + ); + }); + if (row === undefined) { + throw new QueryError( + `No Couchbase ${kind} named ${address.name} in ${address.bucket}.${address.scope}`, + this.type, + ); + } + + const body = functionBodyText(row); + if (body === undefined) { + // OUR sentence and not the cluster's, declared rather than smuggled: the read SUCCEEDED + // and the row it answered carries no body, so Couchbase said nothing there is anything + // to carry. `docs/providers/couchbase.md` records it as ours and records that this arm + // cannot be driven against Community Edition. + return this.sourceRefusal( + path, + kind, + `The system:functions row Couchbase answered for ${address.name} in ${address.bucket}.${address.scope} ` + + `carries no inline definition text. Couchbase keeps an external JavaScript function's body in a library ` + + `on the evaluator endpoint rather than in this catalog, so there is nothing here to show.`, + ); + } + + const bounded = applySourceBound(body, limit); + return { + path: [...path], + kind, + parts: [ + { + id: COUCHBASE_SOURCE_PART_ID, + label: COUCHBASE_SOURCE_PART_LABEL, + text: bounded.text, + language, + // A BODY and not a statement: no parameter list and no CREATE FUNCTION header. + form: "partial", + // The author's own bytes. `definition.expression` beside it is the engine's + // normalisation, and taking that one would have made this `regenerated`. + origin: "stored", + ...(bounded.truncated === undefined ? {} : { truncated: bounded.truncated }), + }, + ], + }; + } + + /** + * One refusal document, built in ONE place (#789). + * + * The part is an object LITERAL carrying `unavailable` and nothing else, never spread from + * a branch that could also carry a `text`. A part holding both keys COMPILES, because + * TypeScript's excess-property check on a union admits any property declared on any member + * of it, and it narrows to the refusal arm while carrying a real definition, which would + * put a refusal sentence over text the engine returned. + */ + private sourceRefusal(path: readonly string[], kind: string, unavailable: string): ObjectSourceDocument { + const part: ObjectSourcePart = { + id: COUCHBASE_SOURCE_PART_ID, + label: COUCHBASE_SOURCE_PART_LABEL, + unavailable, + }; + return { path: [...path], kind, parts: [part] }; + } + // ========================================================================== // Monitoring (decision 9: every source degrades to empty, never throws) // ========================================================================== diff --git a/src/lib/db/providers/document/couchbase/objects.ts b/src/lib/db/providers/document/couchbase/objects.ts index 70e07af7..f9cd7662 100644 --- a/src/lib/db/providers/document/couchbase/objects.ts +++ b/src/lib/db/providers/document/couchbase/objects.ts @@ -157,7 +157,24 @@ export const COUCHBASE_OBJECT_KINDS: readonly ObjectKindSpec[] = Object.freeze([ // collection is an ordinary `UPSERT`. See `kindAcceptsRowWrites()` in object-kinds.ts. acceptsRowWrites: true, }, - { id: COUCHBASE_KIND_FUNCTION, role: "routine", label: "Function", labelPlural: "Functions" }, + { + id: COUCHBASE_KIND_FUNCTION, + role: "routine", + label: "Function", + labelPlural: "Functions", + // The ONE kind here with a definition text (#789 Phase 2). `system:functions` carries a + // SQL++ user-defined function's body in `definition.text`, so there is something the + // engine published to show. The other two declare nothing, each for its own reason: a + // collection is schemaless and there is nothing to read, and `system:indexes` has NO + // field carrying the CREATE INDEX text, so a Source tab there could only show a + // statement this product composed from the keys. The index KEYS are in + // `describeObject`, which is where a fact the catalog does publish belongs. + hasSource: true, + // SQL++, and `sql` is the closest id the installed monaco-editor 0.56.0 registers. + // There is no `n1ql` and no `sqlpp` in its 89 ids, and an unregistered id degrades to + // plain text with no throw and nothing observable. + sourceLanguage: "sql", + }, { id: COUCHBASE_KIND_INDEX, role: "config", @@ -172,6 +189,16 @@ export const COUCHBASE_OBJECT_KINDS: readonly ObjectKindSpec[] = Object.freeze([ }, ] as const); +/** + * The one part id and label a Couchbase source document carries (#789). + * + * `id` is provider-local: core reads it as an identity WITHIN one document, for the part + * switcher's selection key and nothing else. There is exactly one part here, because a SQL++ + * function has one body; the two-part shape belongs to engines with a separate specification. + */ +export const COUCHBASE_SOURCE_PART_ID = "body"; +export const COUCHBASE_SOURCE_PART_LABEL = "Function body"; + /** The scope the server owns. Its collections (`_mobile`, `_query`) are not a person's. */ const COUCHBASE_SYSTEM_SCOPE = "_system"; @@ -246,8 +273,18 @@ export const INDEXES_SQL = [ * rule a test can drive. The catalog is namespace-wide and small - one row per * user-defined function on the whole cluster - so reading all of it and placing each row * in code costs nothing and keeps the placement rule in one visible place. + * + * `f.definition` is the SOURCE READ (#789 Phase 2), and it rides on the SAME statement the + * listing already sends rather than on a second one. Two consequences, both deliberate: + * `readObjectSource` cannot come to look at a different set of functions from `listObjects` + * and `countObjects`, and the function's identity reaches NO statement at all. It is matched + * in code against the rows the listing itself produces, so there is neither a bind nor an + * interpolated identifier here and therefore no escaper to get wrong. Measured on Server + * 8.0.2 Community, one row's `definition` is + * `{"#language":"inline","expression":"(`price` - ((`price` * `pct`) / 100))",` + * `"parameters":["price","pct"],"text":"price - (price * pct / 100)"}`. */ -export const FUNCTIONS_SQL = "SELECT f.identity AS identity FROM system:functions AS f"; +export const FUNCTIONS_SQL = "SELECT f.identity AS identity, f.definition AS definition FROM system:functions AS f"; // ============================================================================ // Row shapes @@ -270,9 +307,16 @@ export interface CouchbaseObjectRow extends CouchbaseRow { is_primary?: unknown; } -/** One row of `FUNCTIONS_SQL`. `identity` is the only field this provider reads. */ +/** + * One row of `FUNCTIONS_SQL`. + * + * `identity` places the function in the tree and `definition` carries its body (#789). + * Both are `unknown` because a `system:` keyspace omits rather than nulls, and because a + * misspelt field in the projection would otherwise read as a typed value that is not there. + */ export interface CouchbaseFunctionRow extends CouchbaseRow { identity?: unknown; + definition?: unknown; } export interface ContainerNameRow extends CouchbaseRow { @@ -497,6 +541,59 @@ export interface FunctionIdentity { readonly name: string; } +/** + * The bucket, the scope and the name one FUNCTION path addresses (#789 Phase 2). + * + * The mirror of `resolveFunctionIdentity` on the other side of the wire: that one reads + * where a catalog row says it lives, this one reads where a path says it lives, and + * `readObjectSource` matches the two. Both are read BY LEVEL and by last segment, never by + * position: the bucket and the scope come from `containerSegments()`, so a declaration that + * reordered the two levels moves both with it, and the name is `path[path.length - 1]`, so a + * kind addressed at a fourth segment still names the function rather than its base object. + */ +export function functionAddress(capabilities: ProviderCapabilities, path: readonly string[]): FunctionIdentity { + const segments = containerSegments(capabilities, path); + return { + bucket: requiredSegment(segments, "catalog"), + scope: requiredSegment(segments, "schema"), + name: path[path.length - 1], + }; +} + +/** + * One `system:functions` row's BODY, or `undefined` for a row that carries none (#789). + * + * WHAT THE TEXT IS. `definition.text` is the body a person typed, measured: the fixture's + * `CREATE OR REPLACE FUNCTION ... { price - (price * pct / 100) }` answers exactly + * `price - (price * pct / 100)` in `text`, while `definition.expression` beside it answers + * the engine's normalisation `(\`price\` - ((\`price\` * \`pct\`) / 100))`. That difference is + * the whole argument for `origin: "stored"` rather than `"regenerated"`, and it is why the + * read takes `text` and not `expression`. It is a BODY and not a statement, so the part is + * `form: "partial"`: the parameter list is in `definition.parameters` and the + * `CREATE FUNCTION` header is nowhere, and composing the three into one statement would show + * a reader something this product wrote rather than something the engine published. + * + * THE UNDEFINED ARM IS WHAT RECIPE RULE 6 ASKS FOR, and it is also the EXTERNAL function + * case. A catalog row is a JSON document, so a misspelt field reads as `undefined` rather + * than failing to compile, and answering an empty string over it would put a blank editor in + * front of a reader. Couchbase keeps an EXTERNAL JavaScript function's body in a library on + * the evaluator endpoint rather than in this catalog, so its row carries no `text` at all and + * takes this arm. That branch is UNVERIFIABLE on the image this repository runs: Community + * Edition refuses to create such a function ("Functions of type javascript are only supported + * in Enterprise Edition", measured verbatim on Server 8.0.2 Community, 2026-09-13), so it is + * driven by the suite and never by a live cluster. `docs/providers/couchbase.md` says so. + * + * A whitespace-only body takes the same arm, because an empty definition is not a definition. + * It is not producible on 8.0.2 either: `CREATE FUNCTION f() { }` is error 3000, a syntax + * error at the closing brace. + */ +export function functionBodyText(row: CouchbaseFunctionRow): string | undefined { + const definition = asRecord(row.definition); + if (definition === undefined) return undefined; + const body = text(definition.text); + return body === undefined || body.trim() === "" ? undefined : body; +} + export function resolveFunctionIdentity(row: CouchbaseFunctionRow): FunctionIdentity | undefined { const identity = asRecord(row.identity); if (identity === undefined) return undefined; diff --git a/src/lib/db/providers/document/mongodb.ts b/src/lib/db/providers/document/mongodb.ts index d3d4b963..2568cc72 100644 --- a/src/lib/db/providers/document/mongodb.ts +++ b/src/lib/db/providers/document/mongodb.ts @@ -4,6 +4,14 @@ */ import { MongoClient, ObjectId, Binary, Decimal128, type Db, type Document, type MongoClientOptions } from "mongodb"; +// The Extended JSON serializer, reached through a NAMESPACE import rather than named beside +// `MongoClient` above. Measured on bun 1.4.2: `import { BSON } from "mongodb"` fails at load with +// "Export named 'BSON' not found", because the driver's CommonJS entry declares it as the innermost +// term of a chained `exports.A = exports.B = ... = void 0` and then installs it with +// `Object.defineProperty`, which bun's named-export detection does not see. The runtime export is +// real - `require("mongodb").BSON` answers it - so the namespace form reaches the same object with +// no extra dependency (#789). +import * as mongodbDriver from "mongodb"; import { BaseDatabaseProvider } from "../../base-provider"; import { type DatabaseConnection, @@ -34,8 +42,11 @@ import { type ObjectDetail, type ObjectDetailBatch, type ObjectKindSpec, + type ObjectSourceDocument, + type ObjectSourcePart, } from "../../types"; import { + applySourceBound, callerBoundTruncationReason, containerDepth, declaredKinds, @@ -249,6 +260,14 @@ const MONGODB_OBJECT_KINDS: readonly ObjectKindSpec[] = Object.freeze([ labelPlural: "Views", // No `acceptsRowWrites`. A view is read-only and the server says so on the same // call that classifies it: `info.readOnly` is true on every one, measured. + // + // A view IS its definition and this is the one kind here that has one (#789). The + // `options` document on its `listCollections` row is exactly what `createCollection` + // took, so the text this product renders from it is the whole definition rather than a + // summary of one. `json` is a Monaco RICH language the installed editor really + // registers, unlike `plsql`, `tsql` and `cql`, which are not language ids at all. + hasSource: true, + sourceLanguage: "json", }, ] as const); @@ -435,6 +454,37 @@ function refusalReason(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * Whether a driver rejection is the SERVER's own error reply, rather than a transport failure + * (#789 Phase 2). + * + * MEASURED against mongodb 7.6.0 and a MongoDB 8.2.12 container created for the measurement. A + * server error reply rejects with `MongoServerError`: `name` and `constructor.name` are both + * that, the prototype chain is `MongoServerError < MongoError < Error`, and an unauthorized + * `listCollections` arrives that way carrying `code: 13`, `codeName: "Unauthorized"` and the + * "not authorized on to execute command { listCollections: 1, ... }" sentence. A TRANSPORT + * failure rejects with something else entirely: nothing listening on the port gives + * `MongoServerSelectionError` reading "connect ECONNREFUSED 127.0.0.1:27999" + * (`MongoServerSelectionError < MongoSystemError < MongoError < Error`), an unroutable host gives + * the same class reading "Socket 'connect' timed out after 1502ms", and a client closed + * underneath the read gives `MongoNotConnectedError` reading "Client must be connected before + * running operations". + * + * The two are not the same fact and must not arrive as the same document. On a refusal the + * SERVER answered "no" and its sentence is the honest thing to show; on a transport failure + * nobody answered at all, so presenting "connect ECONNREFUSED" as this object's own refusal + * would be a symptom rendered as a fact about the object, with no raise and nothing to tell it + * apart from a real `not authorized`. The same rule is written on the Redis read, which keys on + * `ReplyError`. + * + * The NAME and not `instanceof`: the integration suite replaces the whole driver module with + * `mock.module`, so an `instanceof` against the driver's export would be `instanceof undefined` + * there, and the name is the one fact both the real driver and a double can carry. + */ +function isServerErrorReply(error: unknown): boolean { + return error instanceof Error && error.name === "MongoServerError"; +} + /** * WHICH KIND one `listCollections` row is, or `undefined` for a namespace the server * owns. @@ -478,6 +528,81 @@ function objectsFrom(container: readonly string[], kind: string, infos: readonly return objects.sort((left, right) => comparePaths(left.path, right.path)); } +/** + * The one part id and label a MongoDB source document carries (#789). + * + * One part, always, because one `listCollections` row holds the whole definition: there is + * no MongoDB shape like an Oracle package's specification and body for a second part to be. + */ +const MONGODB_SOURCE_PART_ID = "definition"; +const MONGODB_SOURCE_PART_LABEL = "Definition"; + +/** The indent the rendered definition is printed with. Two, as the rest of this product prints JSON. */ +const MONGODB_SOURCE_INDENT = 2; + +/** + * The path shape one object of one kind takes, checked before anything is read. + * + * DERIVED from the declaration and never from a literal: one segment per declared container + * level, then the object's own name. No kind here declares `attachedTo`, so there is exactly + * one shape. Shared by `describeObject` and `readObjectSource` so the two cannot come to + * disagree about what a path of the wrong length is, and so the sentence a caller reads is + * written once. + */ +function assertObjectPathShape(capabilities: ProviderCapabilities, path: readonly string[], kind: string): void { + const shape = [...declaredLevels(capabilities).map((level) => level.label.toLowerCase()), "name"]; + if (path.length !== shape.length) { + throw new QueryError( + `A MongoDB "${kind}" path is [${shape.join(", ")}], received ${JSON.stringify(path)}`, + "mongodb", + ); + } +} + +/** + * One catalog row's definition, rendered as MongoDB Extended JSON, or `undefined` when the + * row carried none (#789). + * + * WHAT THE TEXT IS. `options` is the document `createCollection` was given, so this is the + * whole definition and not a summary: `form: "complete"`. It is printed BY THIS PRODUCT + * rather than handed back by the server, so `origin: "rendered"` - MongoDB stores no + * statement for a view and there is nothing here anybody typed. + * + * THE WHOLE `options` DOCUMENT AND NOT TWO FIELDS OF IT, which is a measurement rather than + * a preference. On MongoDB 8.2.12 a view created with a collation answers `options` carrying + * `collation` beside `viewOn` and `pipeline`, expanded by the server from the two fields the + * fixture asked for to ten. Rendering only the two would drop it while still claiming + * `complete`. For an ordinary view `options` holds exactly `viewOn` and `pipeline`, so the + * common case is unchanged. `docker/mongodb-init/01-object-fixture.js` builds both. + * + * EXTENDED JSON AND NOT `JSON.stringify`, and this one is a silent-loss measurement. A + * pipeline may hold BSON values, and `JSON.stringify` renders a regular expression as `{}`: + * measured, the fixture's `/^th/i` disappears with no error anywhere, which is a + * reconstruction presented as an original by the exact route `origin` exists to prevent. + * `BSON.EJSON.stringify` in RELAXED mode renders it as `$regularExpression` and a date as + * `$date`, and for a pipeline holding no BSON value the two are byte-identical. Relaxed + * rather than canonical because canonical prints every integer as `$numberInt`, which would + * make an ordinary pipeline unreadable for the type fidelity a view definition does not turn + * on. + * + * THE UNDEFINED ARM IS THE GUARD RECIPE RULE 6 ASKS FOR. A catalog row is a DOCUMENT, so a + * misspelt field name reads as `undefined` rather than failing to compile, and rendering + * `{}` over it would put an empty definition in a reader's editor. Both fields must be + * present and of the right shape or the read reports a refusal instead. The reduced row that + * carries neither is real but this provider cannot ask for it: measured on 8.2.12, a + * `listCollections` answering name and type alone arrives only for + * `{ nameOnly: true, authorizedCollections: true }`, and `collectionInfos()` sends neither + * flag, so on a MongoDB server this arm is unreachable and the suite is what drives it. + */ +function renderedDefinition(info: Document): string | undefined { + const options = info.options; + if (typeof options !== "object" || options === null) return undefined; + const definition = options as Document; + if (typeof definition.viewOn !== "string" || definition.viewOn === "") return undefined; + if (!Array.isArray(definition.pipeline)) return undefined; + return mongodbDriver.BSON.EJSON.stringify(definition, undefined, MONGODB_SOURCE_INDENT, { relaxed: true }); +} + // ============================================================================ // MongoDB Provider // ============================================================================ @@ -1677,17 +1802,9 @@ export class MongoDBProvider extends BaseDatabaseProvider { throw new QueryError(`MongoDB declares no object kind "${kind}"`, "mongodb"); } - // Derived, not counted. One segment per declared container level plus the name. No - // kind here declares `attachedTo`, so there is one shape, and it comes from the - // declaration rather than from a literal written out here. - const levels = declaredLevels(capabilities).map((level) => level.label.toLowerCase()); - const shape = [...levels, "name"]; - if (path.length !== shape.length) { - throw new QueryError( - `A MongoDB "${kind}" path is [${shape.join(", ")}], received ${JSON.stringify(path)}`, - "mongodb", - ); - } + // Derived, not counted. One segment per declared container level plus the name, and + // shared with `readObjectSource` so both refuse the same shape in the same words. + assertObjectPathShape(capabilities, path, kind); // Neither read is positional. The database comes from the segment the DECLARATION // assigns to the `schema` level, and the object's own name is the LAST segment. @@ -1707,6 +1824,147 @@ export class MongoDBProvider extends BaseDatabaseProvider { return this.objectDetailFrom(path, sample, indexes); } + /** + * One object's definition text (#789 Phase 2). + * + * ONE kind can answer here and the DECLARATION says which: `view` declares `hasSource` and + * `collection` does not. That is a product judgement rather than a technical limit and it + * is worth stating, because the engine would answer something either way: a collection's + * `options` is a property sheet (a validator, a capped size, a time series spec) rather + * than a definition anybody authored, and measured on 8.2.12 an ORDINARY collection's + * `options` is `{}`, so a Source tab on that kind would open on nothing for the common + * case. The refusal is read off the declaration and never off the kind id, so a kind this + * engine does not declare at all takes the same path. + * + * THE READ IS THE CATALOG READ EVERY OTHER OBJECT METHOD MAKES. `listCollections` answers + * the definition on the same row that classifies the object, so there is no second + * statement to send and nothing here can look at a different set from the count or the + * listing. + * + * A REFUSAL HERE IS PER DATABASE AND NOT PER OBJECT, which is unusual in this fleet and is + * a consequence of that: both kinds come from ONE command, so a caller who cannot run it + * cannot read any object in the database rather than this one. Measured on 8.2.12, a role + * holding `read` on one database is refused another with + * `not authorized on to execute command { listCollections: 1, ... }`, and that + * sentence is carried unprefixed, exactly as `countObjects` carries it. + * + * A REFUSAL IS ALSO ONLY THE SERVER'S OWN ERROR REPLY. A transport failure RAISES a + * `ConnectionError` naming the object and the database, because it is nobody answering rather + * than the server answering "no", and the two must not arrive as one document. + * `isServerErrorReply` carries the measurement that tells `MongoServerError` from + * `MongoServerSelectionError` and `MongoNotConnectedError`. + * + * THE PART'S LANGUAGE IS READ OFF THE DECLARATION AND NEVER DEFAULTED. A kind declaring + * `hasSource` and no `sourceLanguage` raises here rather than being answered `json`, so design + * guarantee 6.3.4 cannot be satisfied vacuously by a fallback nothing drives. + * + * A VIEW THE CATALOG DOES NOT HOLD RAISES, and it has to: a row simply not being in the + * listing is ABSENCE on this engine, there is no error to carry, and answering a document + * would invent one. The kind decides the match as it does in `describeObject`, so asking + * for a view by the name of a collection is a miss rather than a collection rendered as a + * view. + * + * The database comes from the segment the DECLARATION assigns to the `schema` level and the + * name is `path[path.length - 1]`, never `path[0]` and never `path[1]`: standing ruling 5g, + * pinned in the suite by two swapped-in two-level declarations driven to the database the + * driver was BOUND with. + */ + public async readObjectSource(path: readonly string[], kind: string, limit?: number): Promise { + this.ensureConnected(); + const capabilities = this.getCapabilities(); + const spec = findKind(capabilities, kind); + if (spec?.hasSource !== true) { + throw new QueryError(`MongoDB declares no readable source for the kind "${kind}"`, "mongodb"); + } + // The Monaco language is READ off the declaration and never defaulted. A `?? "json"` here is + // dead against the shipped declaration and silently wrong the moment it fires: a kind that + // gained `hasSource` without a language would be answered `json` for something that is not + // JSON, design guarantee 6.3.4 would hold vacuously, and the pane would pick a wrong mode + // with nothing anywhere saying so. A declaration missing half of itself is a defect in the + // declaration, so it raises exactly as the kind check above it does. + const language = spec.sourceLanguage; + if (language === undefined) { + throw new QueryError( + `MongoDB declares source for the kind "${kind}" and no sourceLanguage, so its text has no language to render in`, + "mongodb", + ); + } + assertObjectPathShape(capabilities, path, kind); + const database = containerSegment(capabilities, path, "schema"); + const name = path[path.length - 1]; + + let infos: Document[]; + try { + infos = await this.collectionInfos(database); + } catch (error) { + // ONLY the server's own error reply is a refusal. A transport failure is nobody answering + // at all, and answering a document for it would put "connect ECONNREFUSED" in the Source + // pane as this object's own refusal, with no raise, no destructive state, no retry + // affordance and nothing distinguishing it from a real `not authorized`. + // `isServerErrorReply` carries the measurement that tells the two shapes apart. + if (!isServerErrorReply(error)) { + throw new ConnectionError( + `Failed to read the MongoDB ${kind} ${JSON.stringify(name)} in ${database}: ${refusalReason(error)}`, + "mongodb", + ); + } + return this.sourceRefusal(path, kind, refusalReason(error)); + } + + const info = infos.find((candidate) => readText(candidate.name) === name && mongoObjectKind(candidate) === kind); + if (info === undefined) { + throw new QueryError(`No MongoDB ${kind} named ${name} in ${database}`, "mongodb"); + } + + const rendered = renderedDefinition(info); + if (rendered === undefined) { + // OUR sentence and not the server's, and that is declared rather than smuggled: the + // read SUCCEEDED and the row it answered carried no definition, so MongoDB said + // nothing there is anything to carry. `docs/providers/mongodb.md` records it as ours. + return this.sourceRefusal( + path, + kind, + `The listCollections row MongoDB answered for ${name} in ${database} carries no "viewOn" and ` + + `"pipeline", so this ${kind} has no definition to show`, + ); + } + + const bounded = applySourceBound(rendered, limit); + return { + path: [...path], + kind, + parts: [ + { + id: MONGODB_SOURCE_PART_ID, + label: MONGODB_SOURCE_PART_LABEL, + text: bounded.text, + language, + // The whole `options` document, so nothing of the definition is left out. + form: "complete", + // PRINTED BY THIS PRODUCT. MongoDB stores no statement for a view, so calling this + // `stored` would show a reader a rendering as an original. + origin: "rendered", + ...(bounded.truncated === undefined ? {} : { truncated: bounded.truncated }), + }, + ], + }; + } + + /** + * One refusal document, built in ONE place (#789). + * + * The part is written as an object LITERAL carrying `unavailable` and nothing else, never + * spread from a branch that could also carry a `text`. A part holding both keys COMPILES, + * because TypeScript's excess-property check on a union admits any property declared on any + * member of it, and it narrows to the refusal arm while carrying a real definition, which + * would put a refusal sentence over text the engine returned. Four instances of that shape + * were found in this epic before it was written down. + */ + private sourceRefusal(path: readonly string[], kind: string, unavailable: string): ObjectSourceDocument { + const part: ObjectSourcePart = { id: MONGODB_SOURCE_PART_ID, label: MONGODB_SOURCE_PART_LABEL, unavailable }; + return { path: [...path], kind, parts: [part] }; + } + /** * One sampled object turned into one `ObjectDetail`, shared by the single and the bulk * read. diff --git a/src/lib/db/providers/embedded/libredb.ts b/src/lib/db/providers/embedded/libredb.ts index f1fbab09..3cf214fe 100644 --- a/src/lib/db/providers/embedded/libredb.ts +++ b/src/lib/db/providers/embedded/libredb.ts @@ -174,8 +174,14 @@ export const LIBREDB_TABLE_STATS_TRUNCATED = `LibreDB keeps no row counter, so t * No kind declares `acceptsRowWrites`: the query grammar is `get` / `put` / `delete` / * `prefix` / `range` and has no INSERT, so Generate Test Data would have nothing to emit * and the folder's create item would open a modal this engine cannot serve - * (`supportsCreateTable: false`). No kind declares `hasSource` either, for the reason the - * export list gives: there is no routine here to have source. + * (`supportsCreateTable: false`). No kind declares `hasSource` either, and the reason is + * per kind rather than one sentence about the package (#789). A document entry records + * `{ kind: "document" }` and nothing more, and a `keyspace` is a prefix this server derived, + * so neither has anything authored to show. A relational table's entry DOES persist its + * `{ primaryKey, columns }` schema, and it stays out anyway: that map is already what + * `describeObject` answers as the table's columns, no statement in this grammar could + * re-apply an edited one, and `recordRelational` throws on a schema mismatch rather than + * migrating. `docs/providers/libredb.md` section 6.1 carries the measurement in full. */ const LIBREDB_OBJECT_KINDS: readonly ObjectKindSpec[] = Object.freeze([ { id: "table", role: "relation", label: "Table", labelPlural: "Tables" }, diff --git a/src/lib/db/providers/keyvalue/redis.ts b/src/lib/db/providers/keyvalue/redis.ts index 59006a6d..c690202d 100644 --- a/src/lib/db/providers/keyvalue/redis.ts +++ b/src/lib/db/providers/keyvalue/redis.ts @@ -16,7 +16,13 @@ import Redis, { type RedisOptions } from "ioredis"; import { BaseDatabaseProvider } from "../../base-provider"; -import { callerBoundTruncationReason, containerDepth, declaredKinds, findKind } from "../../object-kinds"; +import { + applySourceBound, + callerBoundTruncationReason, + containerDepth, + declaredKinds, + findKind, +} from "../../object-kinds"; import { comparePaths } from "../../object-path"; import { type DatabaseConnection, @@ -44,6 +50,7 @@ import { type ObjectDetail, type ObjectDetailBatch, type ObjectKindSpec, + type ObjectSourceDocument, } from "../../types"; import { DatabaseConfigError, QueryError, ConnectionError } from "../../errors"; @@ -197,6 +204,31 @@ function declaredLevels(capabilities: ProviderCapabilities): readonly ContainerL return (capabilities.containerLevels ?? []).slice(0, containerDepth(capabilities)); } +/** + * The path SHAPE both object reads share, with ONE writer for the rule and its sentence. + * + * Derived, not counted: the depth comes from `containerDepth()` through `declaredLevels`, + * and the names in the message are the declared labels, so the check and its message cannot + * disagree. Neither kind declares `attachedTo`, so there is one shape rather than two. + * + * `describeObject` has checked this since Phase 1 and `readObjectSource` did not, which the + * external review of PR #820 found (#789). The HTTP route bounds an empty path, but both + * methods are published through `@libredb/studio`, are reached by the embedded host seam and + * by the conformance helper, and none of those three sees the route. Measured on the + * unchecked method: an empty path made `path[path.length - 1]` `undefined`, and ioredis then + * threw `undefined is not an object (evaluating 'arg.toUpperCase')` out of the command + * encoder, which is this file's defect arriving as the driver's. + */ +function assertObjectPathShape(capabilities: ProviderCapabilities, kind: string, path: readonly string[]): void { + const levels = declaredLevels(capabilities); + if (path.length === levels.length + 1) return; + throw new QueryError( + `A Redis "${kind}" path is [${[...levels.map((level) => level.label.toLowerCase()), "name"].join(", ")}], ` + + `received ${JSON.stringify(path)}`, + "redis", + ); +} + /** * The segment of `path` belonging to the declared container level `id`. * @@ -339,6 +371,59 @@ function parseFunctionLibraries(reply: unknown): string[] { return names; } +/** + * One library's `library_code` out of a `FUNCTION LIST ... WITHCODE` reply, selected + * BYTE-EQUAL (#789 Phase 2). + * + * The selection is the whole of this function's reason to exist. MEASURED on redis 8.10.0 + * against the committed fixture: the library dictionary is CASE-SENSITIVE, so `libredb_probe` + * and `LIBREDB_PROBE` coexist, while the `LIBRARYNAME` argument is a CASE-INSENSITIVE glob, so + * ONE lookup for either name answers BOTH. `reply[0]` would therefore hand back the other + * library's Lua as this object's definition, and `docker/redis-init/01-object-fixture.redis` + * holds that pair for exactly this reason. Reply order is not part of the protocol contract: + * RESP3 answers a map, where there is no order at all. + * + * The pairs are walked rather than indexed, the same rule `parseFunctionLibraries` records: + * the nested `functions` value is itself a list of key/value lists, so a parser reading + * positions takes a field name for a library name the moment the server adds a field. + */ +function parseFunctionLibraryCode(reply: unknown, name: string): string | undefined { + for (const entry of Array.isArray(reply) ? reply : []) { + if (!Array.isArray(entry)) continue; + let matched = false; + let code: string | undefined; + for (let index = 0; index + 1 < entry.length; index += 2) { + const key = String(entry[index]); + const value = entry[index + 1]; + if (key === "library_name" && value === name) matched = true; + if (key === "library_code" && typeof value === "string") code = value; + } + if (matched) return code; + } + return undefined; +} + +/** + * Whether a driver rejection is the SERVER's own error reply, rather than a transport + * failure (#789 Phase 2). + * + * MEASURED against ioredis 5.11.1 and redis 8.10.0, from a container created for the + * measurement: an ACL denial rejects with a `redis-errors` `ReplyError` + * (`constructor.name` and `name` both "ReplyError") carrying "NOPERM User ... has no + * permissions to run the 'function|list' command", and so does an unknown command + * ("ERR unknown command 'NOSUCHCOMMAND'"). A DROPPED SOCKET rejects with a plain `Error` + * named "Error", message "Connection is closed." with the offline queue on and "Stream + * isn't writeable and enableOfflineQueue options is false" with it off. + * + * The NAME and not `instanceof`: ioredis re-exports the class, but the integration suite + * replaces the whole module with `mock.module`, so an `instanceof` against the driver's + * export would be `instanceof undefined` there. `redis-errors` sets `name` on the + * prototype, so the name is the one fact both the real driver and a double can carry. + */ +function isServerErrorReply(error: unknown): boolean { + return error instanceof Error && error.name === "ReplyError"; +} + // ============================================================================ // Redis Provider // ============================================================================ @@ -1137,18 +1222,8 @@ export class RedisProvider extends BaseDatabaseProvider { throw new QueryError(`Redis declares no object kind "${kind}"`, "redis"); } - // Derived, not counted: the depth comes from `containerDepth()` through - // `declaredLevels`, and the names in the message are the declared labels, so the check - // and its message cannot disagree. Neither kind declares `attachedTo`, so there is one - // shape rather than two. + assertObjectPathShape(capabilities, kind, path); const levels = declaredLevels(capabilities); - if (path.length !== levels.length + 1) { - throw new QueryError( - `A Redis "${kind}" path is [${[...levels.map((level) => level.label.toLowerCase()), "name"].join(", ")}], ` + - `received ${JSON.stringify(path)}`, - "redis", - ); - } if (kind !== "keyspace") return { path: [...path], columns: [], indexes: [], foreignKeys: [] }; @@ -1168,6 +1243,129 @@ export class RedisProvider extends BaseDatabaseProvider { }); } + /** + * `FUNCTION LIST LIBRARYNAME WITHCODE`, as its own method (#789 Phase 2). + * + * A method rather than an inline call so the refusal arm of `readObjectSource` can be + * driven without reaching into ioredis, and so the command text has one writer. It is + * SERVER-SCOPED and takes no database: measured on redis 8.10.0, one `FUNCTION LOAD` is + * visible from every numbered database and `SELECT` does not change what `FUNCTION LIST` + * answers, which is the same fact `listObjects` records for the listing. + */ + private async callFunctionList(name: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await (this.client as any).call("FUNCTION", "LIST", "LIBRARYNAME", name, "WITHCODE"); + } + + /** + * A function library's Lua source (#789 Phase 2). + * + * ONE kind can answer here and the DECLARATION says which: `function` declares `hasSource` + * and `keyspace` does not, because a key prefix is a grouping this server derived from a + * bounded `SCAN` and nobody wrote a definition for it. That is the + * `tablesAreDerivedGroupings` refusal carried into the object model rather than left behind + * with the flag's old reader. The refusal is read off the declaration and never off the + * kind id, so a kind this engine does not declare at all takes the same path. + * + * A library the server does not hold RAISES. It has to: measured on redis 8.10.0, + * `FUNCTION LIST LIBRARYNAME no_such_library WITHCODE` answers an EMPTY ARRAY and not an error, so + * emptiness is absence here and a provider that returned a document would invent one. A + * matching entry carrying no `library_code` takes the same arm, because an empty text would + * put an empty editor over a definition that was never read. + * + * A refusal is the server's own sentence, unprefixed. KeyDB, DragonflyDB and Garnet have no + * `FUNCTION` command at all and each refuses in its own words (all measured 2026-09-11), so + * this path is reachable on three of the four Redis-wire relatives this type id serves. + * + * A refusal is ONLY the server's own error reply. A TRANSPORT failure RAISES, because it is + * nobody answering rather than the server answering "no", and a pane reading "Connection is + * closed." as this object's refusal would be a symptom presented as a fact about the + * object. `isServerErrorReply` carries the measurement that tells the two apart. + * + * The name is `path[path.length - 1]` and never `path[1]`: standing ruling 5g, and the + * integration suite pins it by swapping a two-level declaration in. The path SHAPE that + * makes the last segment meaningful is checked by `assertObjectPathShape`, the same + * function and the same sentence `describeObject` uses, because the HTTP route is not the + * only caller: this method is published through `@libredb/studio` and reached by the + * embedded host seam and by the conformance helper, none of which passes through a route. + * + * A kind that declares source and no `sourceLanguage` RAISES rather than falling back to + * a literal "lua", which the external review of PR #820 corrected (#789). An unregistered + * Monaco id degrades to plain text with no throw and nothing observable, so the fallback + * hid a deleted declaration behind a tab that had quietly stopped highlighting. + */ + public async readObjectSource(path: readonly string[], kind: string, limit?: number): Promise { + this.ensureConnected(); + const capabilities = this.getCapabilities(); + const spec = findKind(capabilities, kind); + if (spec?.hasSource !== true) { + throw new QueryError(`Redis declares no readable source for the kind "${kind}"`, "redis"); + } + const language = spec.sourceLanguage; + if (language === undefined) { + // An unregistered or absent Monaco id degrades to plain text with no throw and nothing + // observable, so a kind that declared source and forgot its language would ship a + // Source tab that silently stopped highlighting. The declaration is the only source of + // the language and there is no literal here to fall back to: the census in + // `tests/isolated/object-source-declarations.test.ts` pins every declared language, so + // this arm is only ever reached by a declaration somebody deleted. + throw new QueryError( + `Redis declares readable source for the kind "${kind}" and no sourceLanguage to render it with`, + "redis", + ); + } + assertObjectPathShape(capabilities, kind, path); + const name = path[path.length - 1]; + let reply: unknown; + try { + reply = await this.callFunctionList(name); + } catch (error) { + // ONLY the server's own error reply is a refusal. A transport failure is nobody + // answering at all, and answering a document for it would put "Connection is closed." + // in the Source pane as this object's own refusal, with no raise, no retry affordance + // and nothing in the document telling it apart from a real NOPERM. The two shapes are + // measured on `isServerErrorReply`. + if (!isServerErrorReply(error)) { + throw new ConnectionError( + `Failed to read the Redis function library ${JSON.stringify(name)}: ` + + `${error instanceof Error ? error.message : String(error)}`, + "redis", + ); + } + return { + path: [...path], + kind, + parts: [ + { + id: "definition", + label: "Definition", + unavailable: error instanceof Error ? error.message : String(error), + }, + ], + }; + } + const code = parseFunctionLibraryCode(reply, name); + if (code === undefined || code.trim() === "") { + throw new QueryError(`Redis holds no function library called "${name}"`, "redis"); + } + const bounded = applySourceBound(code, limit); + return { + path: [...path], + kind, + parts: [ + { + id: "definition", + label: "Definition", + text: bounded.text, + language, + form: "complete", + origin: "stored", + ...(bounded.truncated === undefined ? {} : { truncated: bounded.truncated }), + }, + ], + }; + } + /** * Columns for EVERY object of one kind in one database, from ONE walk (#789). * diff --git a/src/lib/db/providers/sql/cassandra/index.ts b/src/lib/db/providers/sql/cassandra/index.ts index 8bbdb2fc..af3c81b6 100644 --- a/src/lib/db/providers/sql/cassandra/index.ts +++ b/src/lib/db/providers/sql/cassandra/index.ts @@ -65,6 +65,7 @@ import { type MaintenanceType, type ObjectDetail, type ObjectDetailBatch, + type ObjectSourceDocument, type PerformanceMetrics, type PreparedQuery, type ProviderCapabilities, @@ -100,6 +101,7 @@ import { countObjects as readObjectCounts, describeObject as readObjectDetail, describeObjects as readObjectDetails, + readObjectSource as readSource, listContainers as readContainers, listObjects as readObjects, } from "./objects"; @@ -678,6 +680,21 @@ export class CassandraProvider extends SQLBaseProvider { return this.guarded(() => readObjectDetails(transport, this.getCapabilities(), container, kind, limit)); } + /** + * One object's definition text, through the server's own `DESCRIBE` (#789). + * + * Guarded exactly like the five above, and that is load-bearing here rather than uniform: + * an absence on this engine is the SERVER refusing the statement, so the raise a caller + * sees is `mapCassandraError`'s `invalid` arm carrying Cassandra's own sentence unprefixed + * ("Table 'no_such_table' not found in keyspace 'probe'", measured). Inventing a sentence + * here would replace a message that names both the object and the kind it was looked for + * under with one that names less. + */ + public async readObjectSource(path: readonly string[], kind: string, limit?: number): Promise { + const transport = this.requireTransport(); + return this.guarded(() => readSource(transport, this.getCapabilities(), path, kind, limit)); + } + // ========================================================================== // Monitoring // ========================================================================== diff --git a/src/lib/db/providers/sql/cassandra/objects.ts b/src/lib/db/providers/sql/cassandra/objects.ts index bd21bd19..76222b43 100644 --- a/src/lib/db/providers/sql/cassandra/objects.ts +++ b/src/lib/db/providers/sql/cassandra/objects.ts @@ -70,8 +70,12 @@ * answers "Trigger class 'probe.NoopTrigger' couldn't be loaded" (all measured). None * of that makes a trigger a thing the engine does not have; it makes it a thing an * operator installs. Withholding the folder would hide an object a person created, - * which is the absence this epic keeps finding, and Phase 1 shows names rather than - * bodies anyway - so no kind here declares `hasSource`. + * which is the absence this epic keeps finding. `trigger` is the one kind here that + * declares no `hasSource`, and Phase 2 measured which absence it is: `DescribeStatement` + * has no TRIGGER target at all (`DESCRIBE TRIGGER` answers code 8192, "no viable + * alternative at input", measured on 5.0.9), and a trigger's body is a Java class on the + * node's filesystem rather than anything the database holds. The other six kinds DO + * declare `hasSource`; see `CASSANDRA_OBJECT_KINDS` below, which is the list. * * Only `table` declares `acceptsRowWrites`. A materialized view refuses every write * ("Cannot directly modify a materialized view", measured), and the other five kinds @@ -81,7 +85,14 @@ */ import { QueryError } from "@/lib/db/errors"; -import { callerBoundTruncationReason, containerDepth, declaredKinds, findKind } from "@/lib/db/object-kinds"; +import { + applySourceBound, + callerBoundTruncationReason, + containerDepth, + declaredKinds, + findKind, + requireSourceKind, +} from "@/lib/db/object-kinds"; import { comparePaths } from "@/lib/db/object-path"; import type { ColumnSchema, @@ -94,11 +105,13 @@ import type { ObjectDetail, ObjectDetailBatch, ObjectKindSpec, + ObjectSourceDocument, + ObjectSourcePart, ProviderCapabilities, } from "@/lib/db/types"; import { quoteLiteral } from "@/lib/sql/values"; import { cassandraTableColumns } from "./introspect"; -import type { CassandraRow, CassandraTransport } from "./transport"; +import { CassandraTransportError, type CassandraRow, type CassandraTransport } from "./transport"; const PROVIDER = "cassandra" as const; @@ -116,18 +129,75 @@ export const CASSANDRA_CONTAINER_LEVELS: ContainerLevels = Object.freeze([ { id: "schema", label: "Keyspace", labelPlural: "Keyspaces" }, ] as const); +/** + * The Monaco id every readable kind here renders under, and the reason it is a compromise. + * + * `cql` IS NOT A MONACO LANGUAGE ID. Measured against the installed monaco-editor 0.56.0 + * bundle in this epic: it registers 89 ids and `cql` is not one of them, and an unregistered + * id degrades to plain text with no throw and nothing observable. `sql` is the closest + * registered dialect, so a `CREATE TABLE` renders correctly and CQL-only spellings + * (`PRIMARY KEY ((a), b)`, `frozen
`, a `$$ ... $$` function body) are highlighted as + * whatever the SQL tokenizer makes of them. `docs/providers/cassandra.md` says so as a + * limitation rather than implying the text is highlighted as CQL. + */ +const CASSANDRA_SOURCE_LANGUAGE = "sql" as const; + export const CASSANDRA_OBJECT_KINDS: readonly ObjectKindSpec[] = Object.freeze([ - { id: "table", role: "relation", label: "Table", labelPlural: "Tables", acceptsRowWrites: true }, + { + id: "table", + role: "relation", + label: "Table", + labelPlural: "Tables", + acceptsRowWrites: true, + hasSource: true, + sourceLanguage: CASSANDRA_SOURCE_LANGUAGE, + }, { id: "materialized_view", role: "relation", label: "Materialized View", labelPlural: "Materialized Views", + hasSource: true, + sourceLanguage: CASSANDRA_SOURCE_LANGUAGE, + }, + { + id: "index", + role: "config", + label: "Index", + labelPlural: "Indexes", + hasSource: true, + sourceLanguage: CASSANDRA_SOURCE_LANGUAGE, }, - { id: "index", role: "config", label: "Index", labelPlural: "Indexes" }, - { id: "type", role: "config", label: "Type", labelPlural: "Types" }, - { id: "function", role: "routine", label: "Function", labelPlural: "Functions" }, - { id: "aggregate", role: "routine", label: "Aggregate", labelPlural: "Aggregates" }, + { + id: "type", + role: "config", + label: "Type", + labelPlural: "Types", + hasSource: true, + sourceLanguage: CASSANDRA_SOURCE_LANGUAGE, + }, + { + id: "function", + role: "routine", + label: "Function", + labelPlural: "Functions", + hasSource: true, + sourceLanguage: CASSANDRA_SOURCE_LANGUAGE, + }, + { + id: "aggregate", + role: "routine", + label: "Aggregate", + labelPlural: "Aggregates", + hasSource: true, + sourceLanguage: CASSANDRA_SOURCE_LANGUAGE, + }, + // NO `hasSource`, and it is a RESULT rather than a gap. `DescribeStatement` has no `TRIGGER` + // target at all - measured on 5.0.9, `DESCRIBE TRIGGER probe.probe_audit` is + // "line 1:17 no viable alternative at input 'probe'" - and `system_schema.triggers` carries + // only the trigger's name, its base table and the CLASS an operator installed. That class is + // a compiled Java file in every node's trigger directory, so the definition is not in the + // database in ANY form and there is nothing this product declines to reach. { id: "trigger", role: "attached", label: "Trigger", labelPlural: "Triggers", attachedTo: "table" }, ] as const); @@ -169,6 +239,33 @@ function literal(value: string): string { return quoteLiteral(value, PROVIDER); } +/** + * A keyspace or object name as a QUOTED CQL identifier (#789). + * + * Quoting is NOT optional and the measurement says why: `system_schema` stores a name as it + * was written, and an unquoted identifier is lowercased by the parser, so + * `DESCRIBE TABLE t14scratch.MixedCase` against a table created as `"MixedCase"` answers + * "Table 'mixedcase' not found in keyspace 't14scratch'" (measured on 5.0.9). Every name that + * reaches here came out of a catalog row, so every one of them is already in its stored + * spelling and every one of them is quoted. + * + * DOUBLING THE QUOTE IS THE WHOLE ESCAPE, and this is the one place the ClickHouse hazard + * (probe 11 of this epic, where a backslash inside a quoted identifier IS an escape and + * swallows the closing quote) is measurably ABSENT. Measured on 5.0.9 with a function named + * `back\slash`: `DESCRIBE FUNCTION t14scratch."back\slash"` RESOLVES it, and the + * backslash-doubled `"back\\slash"` is "User defined function 'back\\slash' not found", + * so CQL reads a backslash inside a quoted identifier as DATA. A name holding a `"` is + * reachable too: `qu"ote` was created as a function name and + * `DESCRIBE FUNCTION t14scratch."qu""ote"` resolves it. + * + * Not `SQLBaseProvider.escapeIdentifier`: that is a protected method on the class which + * branches on `this.type`, and this module is the free-function half of the provider. The rule + * it would apply for a non-mysql, non-mssql dialect is the same one written here. + */ +function identifier(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + /** * Every keyspace the connected role can see, system ones included. * @@ -211,15 +308,46 @@ interface ObjectCatalogSpec { * listing statement the count and the folder already share. */ readonly orderColumn: string; + /** + * The `DESCRIBE` target keywords for this kind, or undefined where the statement has none. + * + * `DESCRIBE` is a REAL SERVER-SIDE STATEMENT here, unlike every SQL engine in #789 that + * SELECTs a definition out of a catalog table: the server executes it and answers the + * columns `keyspace_name, type, name, create_statement` (measured on 5.0.9). So the target + * is part of the statement's GRAMMAR and there is no column list to project. + * + * `trigger` has no entry, and that is the grammar rather than an omission: measured, + * `DESCRIBE TRIGGER probe.probe_audit` is "line 1:17 no viable alternative at input 'probe'". + */ + readonly describeTarget?: string; + /** + * The value the reply's own `type` column carries for a row of this kind. + * + * It exists because `DESCRIBE TABLE` DOES NOT ANSWER ONE ROW. Measured on 5.0.9, + * `DESCRIBE TABLE probe.customers` answers FOUR: the table, its two indexes and the + * materialized view over it, each typed by this column. Every one of those is its own + * addressable object in this tree with its own Source, so the read selects the row the + * CALLER asked for and the others are reached under their own paths. + */ + readonly describeType?: string; } const CASSANDRA_OBJECT_CATALOGS: Readonly> = Object.freeze({ - table: { table: "tables", nameColumn: "table_name", projection: ["table_name"], orderColumn: "table_name" }, + table: { + table: "tables", + nameColumn: "table_name", + projection: ["table_name"], + orderColumn: "table_name", + describeTarget: "TABLE", + describeType: "table", + }, materialized_view: { table: "views", nameColumn: "view_name", projection: ["view_name"], orderColumn: "view_name", + describeTarget: "MATERIALIZED VIEW", + describeType: "materialized_view", }, index: { table: "indexes", @@ -231,14 +359,25 @@ const CASSANDRA_OBJECT_CATALOGS: Readonly> = O // NOT `index_name`: this catalog clusters on `(table_name, index_name)` and ordering // by the second clustering column alone is a server error (measured). orderColumn: "table_name", + describeTarget: "INDEX", + describeType: "index", + }, + type: { + table: "types", + nameColumn: "type_name", + projection: ["type_name"], + orderColumn: "type_name", + describeTarget: "TYPE", + describeType: "type", }, - type: { table: "types", nameColumn: "type_name", projection: ["type_name"], orderColumn: "type_name" }, function: { table: "functions", nameColumn: "function_name", projection: ["function_name", "argument_types"], overloaded: true, orderColumn: "function_name", + describeTarget: "FUNCTION", + describeType: "function", }, aggregate: { table: "aggregates", @@ -246,6 +385,8 @@ const CASSANDRA_OBJECT_CATALOGS: Readonly> = O projection: ["aggregate_name", "argument_types"], overloaded: true, orderColumn: "aggregate_name", + describeTarget: "AGGREGATE", + describeType: "aggregate", }, trigger: { table: "triggers", @@ -292,6 +433,31 @@ export function cassandraObjectListCql(keyspace: string, kind: string, limit?: n return limit === undefined ? base : `${base} ORDER BY ${spec.orderColumn} ASC LIMIT ${limit}`; } +/** + * The server-side `DESCRIBE` for one object of one kind, or undefined for a kind whose + * grammar has no target (#789). + * + * This is the statement that makes Cassandra unlike every SQL engine in #789: the definition + * is not in a catalog COLUMN anybody can select, it is produced by a statement the SERVER + * executes, and the answer arrives as ordinary rows carrying a `create_statement`. + * + * The target takes an IDENTIFIER and no bind, which is not this provider choosing to + * interpolate: `DESCRIBE` accepts no `?` at all, and this provider sends every catalog + * statement one-shot with `prepare: false` anyway. Both segments go through `identifier()`, + * which is the whole escape (see that function for the two measurements). + * + * A ROUTINE'S TARGET IS ITS BARE NAME AND NEVER ITS IDENTITY. Measured on 5.0.9, + * `DESCRIBE FUNCTION probe.render(int)` is the SYNTAX error "line 1:30 mismatched input '(' + * expecting EOF", so the argument list cannot be sent, and `DESCRIBE FUNCTION probe.render` + * answers BOTH overloads as two rows. The caller's overload is chosen from the reply, which + * is what `describeSignature()` is for. + */ +export function cassandraDescribeCql(keyspace: string, kind: string, name: string): string | undefined { + const spec = objectCatalog(kind); + if (spec?.describeTarget === undefined) return undefined; + return `DESCRIBE ${spec.describeTarget} ${identifier(keyspace)}.${identifier(name)}`; +} + /** * Every column of every table and every materialized view in ONE keyspace. * @@ -424,6 +590,31 @@ function containerKeyspace(capabilities: ProviderCapabilities, container: readon return containerSegment(capabilities, container, "schema"); } +/** + * How many segments a path of one KIND has, checked against the declaration (#789). + * + * ONE writer for two readers: `describeObject` and `readObjectSource` ask the same question + * about the same path, and two copies of this derivation are two chances for the detail pane + * and the Source tab to disagree about what an object's address is. + * + * Derived, not counted. One segment per declared container level plus the name, and a nesting + * segment for a kind that declares `attachedTo` - which is the ONLY thing that changes the + * depth, so both shapes come from the declaration rather than from a kind id written out here. + */ +function assertObjectPathShape( + capabilities: ProviderCapabilities, + spec: ObjectKindSpec, + path: readonly string[], +): void { + const levels = declaredLevels(capabilities).map((level) => level.label.toLowerCase()); + const shape = spec.attachedTo === undefined ? [...levels, "name"] : [...levels, spec.attachedTo, "name"]; + if (path.length === shape.length) return; + throw new QueryError( + `A Cassandra "${spec.id}" path is [${shape.join(", ")}], received ${JSON.stringify(path)}`, + PROVIDER, + ); +} + /** * The server's own sentence, verbatim, for ONE kind whose read was refused. * @@ -792,18 +983,7 @@ export async function describeObject( throw new QueryError(`Cassandra declares no object kind "${kind}"`, PROVIDER); } - // Derived, not counted. One segment per declared container level plus the name, and a - // nesting segment for a kind that declares `attachedTo` - which is the ONLY thing - // that changes the depth, so the two shapes come from the declaration rather than - // from a kind id written out here. - const levels = declaredLevels(capabilities).map((level) => level.label.toLowerCase()); - const shape = spec.attachedTo === undefined ? [...levels, "name"] : [...levels, spec.attachedTo, "name"]; - if (path.length !== shape.length) { - throw new QueryError( - `A Cassandra "${kind}" path is [${shape.join(", ")}], received ${JSON.stringify(path)}`, - PROVIDER, - ); - } + assertObjectPathShape(capabilities, spec, path); const catalog = objectCatalog(kind); if (catalog === undefined) { @@ -1008,3 +1188,239 @@ async function describeRelationBatch( return relationDetail(objectPath(container, spec, row), name, byOwner.get(name) ?? [], indexes.rows); }); } + +// ============================================================================ +// The object source read (issue #789) +// ============================================================================ + +/** The one part id and label this engine emits: one object, one statement, one text. */ +const SOURCE_PART_ID = "definition"; +const SOURCE_PART_LABEL = "Definition"; + +/** + * The server's own sentence when a `DESCRIBE` result is invalidated between pages. + * + * MEASURED on 5.0.9 and REPRODUCED rather than quoted: `DESCRIBE TABLE probe.customers` with + * `fetchSize: 1`, a `CREATE TABLE` in another keyspace, then a fetch of the second page with + * the first page's `pageState`, answers protocol code 8704 with exactly this text. It is the + * server telling the client to ASK AGAIN, not a refusal about the object, so reporting it as + * an `unavailable` part would put a transient instruction in the Source pane as if it were a + * fact about the definition. + * + * THIS IS THE ONE PLACE THIS PROVIDER READS A SERVER SENTENCE TO DECIDE ANYTHING, and the + * trade is the opposite way round from the one `transport.ts` records for the monitoring + * degradation. There the sentence was the only discriminator for five panels and a rephrase + * would have silently disabled all five, so the discriminator moved to a property of the + * server. Here 8704 alone cannot be the discriminator - an absent object carries it too - and + * a rephrase costs exactly one thing: the retry stops firing and the error propagates as a + * raise, which is what this code does anyway when the retry does not help. A degradation to + * the documented fallback is an acceptable dependency on a sentence; a silent disappearance + * is not. + */ +const SCHEMA_CHANGED_MID_PAGE = "The schema has changed since the previous page of the DESCRIBE statement result."; + +/** The protocol's `Invalid query` code, which both the retry and every absence arrive under. */ +const CQL_INVALID_CODE = 8704; + +/** + * One `DESCRIBE`, retried ONCE if the schema moved under a paged result. + * + * The retry is bounded at one attempt on purpose: a cluster whose schema is changing faster + * than a catalog read completes will not be fixed by a third try, and an unbounded retry would + * turn a busy cluster into a hang the Source pane can never leave. + * + * REACHABILITY, stated rather than implied: this provider sends no `fetchSize`, so the + * driver's own default of 5000 rows applies, and a single-object `DESCRIBE` answers one row + * plus, for a table, one per index and materialized view over it. So the paging this error + * needs is out of reach for every shape the fixture holds, and the arm is driven in the suite + * rather than by a cluster. It is written because the bound is the DRIVER's default and not a + * guarantee of the statement. + */ +async function describeRows(transport: CassandraTransport, cql: string): Promise { + try { + return (await transport.execute(cql)).rows; + } catch (error) { + if (!isSchemaChangedMidPage(error)) throw error; + return (await transport.execute(cql)).rows; + } +} + +/** Whether a failure is the server asking for the DESCRIBE to be sent again. */ +function isSchemaChangedMidPage(error: unknown): boolean { + return ( + error instanceof CassandraTransportError && + error.code === CQL_INVALID_CODE && + error.message.includes(SCHEMA_CHANGED_MID_PAGE) + ); +} + +/** + * A routine signature with every space removed, which is what makes two spellings of one + * signature comparable. + * + * MEASURED on 5.0.9, and it is why an equality against the reply's `name` column would never + * match: the server writes `sum_state(int, int)` with a SPACE after the comma while this + * provider's identity segment is `sum_state(int,int)`, and a `map` argument is + * `map` in `system_schema.functions.argument_types` itself. Stripping whitespace + * makes both spellings one value and changes nothing else: a CQL type name holds no + * significant space. + */ +function normalizeSignature(signature: string): string { + return signature.replace(/\s+/g, ""); +} + +/** + * The argument list the reply's `name` column carries, without the routine's own name. + * + * NOT a comparison against the whole `name`, and the measurement is the reason. The reply's + * `name` is not a usable identity: for a quoted table it is `"MixedCase"` WITH the quotes, and + * for a function named `Fn(x` - which the server ACCEPTS, unlike a table name, measured - it + * is the unusable `"Fn"(x(int)`. What IS reliable in every one of those is the trailing + * parenthesized argument list, because a CQL type name is built from angle brackets and holds + * no parenthesis, so the LAST `(` opens the signature. + */ +function describeSignature(name: string): string | undefined { + const open = name.lastIndexOf("("); + if (open < 0 || !name.endsWith(")")) return undefined; + return normalizeSignature(name.slice(open + 1, -1)); +} + +/** + * The catalog row an OVERLOADED kind's path segment addresses, or undefined for none. + * + * The identity is resolved through `objectIdentity()`, THE SAME FUNCTION that built the + * segment in `listObjects`, so the resolution can never drift from the listing: a change to + * how a routine is addressed moves both sides at once. Nothing here re-parses the segment, + * which is the alternative and is wrong on this engine - a function name may itself contain a + * `(` (measured: `Fn(x` was created and sits in `system_schema.functions`). + * + * It is the same statement `countObjects` and `listObjects` send, which is the pattern + * `describeIndex` above already follows for the same reason. + */ +async function resolveOverloadedRow( + transport: CassandraTransport, + keyspace: string, + kind: string, + spec: ObjectCatalogSpec, + segment: string, +): Promise { + const rows = (await transport.execute(cassandraObjectListCql(keyspace, kind)!)).rows; + return rows.find((row) => objectIdentity(spec, row) === segment); +} + +/** + * One object's definition, as the server regenerates it (#789). + * + * `form: "complete"` and `origin: "regenerated"`, both measured rather than assumed. The text + * is a whole `CREATE` statement ending in a semicolon, and it is a RECONSTRUCTION and not the + * author's bytes: the fixture writes `CREATE TABLE probe.customers (id, name, city, home, + * tags)` and the server answers the partition key first and then the other columns + * alphabetically, with all twenty table options spelled out. Nothing in this product stores + * the original. + * + * A FUNCTION'S BODY IS NOT CQL and the part does not pretend otherwise. `DESCRIBE FUNCTION` + * answers a CQL envelope wrapping a body verbatim in `$$ ... $$`, whose language comes from + * the catalog (`java` on 5.0). The part's `language` is the kind's declared `sql` because the + * part IS the envelope, and `docs/providers/cassandra.md` says that plainly rather than + * implying the body is highlighted correctly. + * + * ABSENCE RAISES AND IT IS THE SERVER'S OWN SENTENCE. Measured on 5.0.9, `DESCRIBE` NAMES THE + * KIND IT LOOKED FOR in every one of them - "Table 'no_such_table' not found in keyspace + * 'probe'", "Materialized view 'x' not found in 'probe'", "User defined function 'x' not found + * in 'probe'" - so a WRONG-KIND ask is the same fact as a missing object and is told apart by + * the sentence rather than by a code: `DESCRIBE TABLE probe.customers_by_city` against the + * fixture's materialized view answers "Table 'customers_by_city' not found in keyspace + * 'probe'". Both are absences and both raise, which is what the non-empty `parts` tuple + * requires: there is no empty document for an absence to be confused with. + * + * THERE IS NO PRIVILEGE-DRIVEN REFUSAL ON THIS ENGINE, measured rather than assumed, and it is + * the same shape probe 1 of this epic found for PostgreSQL's `pg_get_*` family. On a 5.0.9 + * node running `PasswordAuthenticator` with `CassandraAuthorizer`, a role created with a login + * and NO GRANT OF ANY KIND read the complete `DESCRIBE TABLE` and `DESCRIBE FUNCTION` text for + * the fixture's objects, in the same session where `SELECT` on `system_schema.tables` returned + * nothing. So the `unavailable` arm below has exactly ONE producer: a `create_statement` that + * is empty or whitespace only, which no build in this epic's measurements has ever answered + * and which is refused anyway, because an empty definition is not a definition and an empty + * editor over one is the failure this whole phase exists to stop. + */ +export async function readObjectSource( + transport: CassandraTransport, + capabilities: ProviderCapabilities, + path: readonly string[], + kind: string, + limit?: number, +): Promise { + const spec = requireSourceKind(capabilities, kind, { displayName: "Cassandra", type: PROVIDER }); + assertObjectPathShape(capabilities, spec, path); + const catalog = objectCatalog(kind); + if (catalog?.describeTarget === undefined || catalog.describeType === undefined) { + throw new QueryError( + `Cassandra declares readable source for the kind "${kind}" but DESCRIBE has no target for it`, + PROVIDER, + ); + } + + // Neither read is positional. The keyspace comes from the segment the DECLARATION assigns to + // the `schema` level, and the object's own name is the LAST segment, which is right at both + // depths this provider produces. + const keyspace = containerSegment(capabilities, path, "schema"); + const segment = path[path.length - 1]; + + let name = segment; + let signature: string | undefined; + if (catalog.overloaded === true) { + const row = await resolveOverloadedRow(transport, keyspace, kind, catalog, segment); + if (row === undefined) { + throw new QueryError(`No Cassandra ${kind} named ${segment} in ${keyspace}`, PROVIDER); + } + name = readText(row[catalog.nameColumn]); + signature = normalizeSignature(readTextList(row.argument_types).join(",")); + } + + const cql = cassandraDescribeCql(keyspace, kind, name)!; + const rows = await describeRows(transport, cql); + const row = rows.find( + (candidate) => + readText(candidate.type) === catalog.describeType && + (signature === undefined || describeSignature(readText(candidate.name)) === signature), + ); + if (row === undefined) { + // Defensive rather than observed: for every kind the server raises instead of answering a + // reply with no row of the target's own type, and an overload the catalog did not hold was + // already refused above. A short answer is still an absence and never a refusal part. + throw new QueryError(`No Cassandra ${kind} named ${segment} in ${keyspace}`, PROVIDER, cql); + } + + // An array LITERAL, which is what satisfies the non-empty tuple. `rows.map(...)` does not, + // and casting past it would defeat the invariant the tuple exists for. + const parts: [ObjectSourcePart] = [sourcePart(readText(row.create_statement), spec.sourceLanguage, limit)]; + return { path: [...path], kind, parts }; +} + +/** + * One `create_statement` as a part, or the refusal an empty one is. + * + * The refusal sentence is OURS and says so, which is a declared deviation from "the engine's + * own sentence, unprefixed": there is no engine sentence to carry, because the read SUCCEEDED + * and the server simply put nothing in the column. `docs/providers/cassandra.md` records both + * halves - that this is our wording, and that no measured build has produced it. + */ +function sourcePart(text: string, language: string, limit: number | undefined): ObjectSourcePart { + if (text.trim() === "") { + return { + id: SOURCE_PART_ID, + label: SOURCE_PART_LABEL, + unavailable: "Cassandra answered this DESCRIBE with an empty create_statement.", + }; + } + const bounded = applySourceBound(text, limit); + return { + id: SOURCE_PART_ID, + label: SOURCE_PART_LABEL, + text: bounded.text, + language, + form: "complete", + origin: "regenerated", + ...(bounded.truncated === undefined ? {} : { truncated: bounded.truncated }), + }; +} diff --git a/src/lib/db/providers/sql/clickhouse/index.ts b/src/lib/db/providers/sql/clickhouse/index.ts index 4f81c316..a126c15a 100644 --- a/src/lib/db/providers/sql/clickhouse/index.ts +++ b/src/lib/db/providers/sql/clickhouse/index.ts @@ -57,6 +57,7 @@ import { type KindCount, type ObjectDetail, type ObjectDetailBatch, + type ObjectSourceDocument, type ProviderCapabilities, type ProviderLabels, type ProviderOptions, @@ -82,6 +83,7 @@ import { listContainers as readContainers, listObjects as readObjects, literal, + readObjectSource as readSourceDocument, } from "./objects"; import { type ClickHouseQueryResult, @@ -805,6 +807,19 @@ export class ClickHouseProvider extends SQLBaseProvider { return this.guarded(() => readObjectDetails(transport, this.getCapabilities(), container, kind, limit)); } + /** + * One object's definition text (#789 Phase 2). + * + * The fifth thin wrapper, and it is OPTIONAL on `DatabaseProvider` while the five above are + * required: a provider that declares no source-bearing kind implements nothing, and + * `assertObjectSurface` asserts the pairing in both directions. ClickHouse declares all five + * of its kinds source-bearing, so the method is here and the pairing holds. + */ + public async readObjectSource(path: readonly string[], kind: string, limit?: number): Promise { + const transport = this.requireTransport(); + return this.guarded(() => readSourceDocument(transport, this.getCapabilities(), path, kind, limit)); + } + // ========================================================================== // Monitoring (every source degrades to empty or zeroed, never throws) // ========================================================================== diff --git a/src/lib/db/providers/sql/clickhouse/objects.ts b/src/lib/db/providers/sql/clickhouse/objects.ts index 4b7343f5..220d4ada 100644 --- a/src/lib/db/providers/sql/clickhouse/objects.ts +++ b/src/lib/db/providers/sql/clickhouse/objects.ts @@ -59,7 +59,14 @@ */ import { QueryError } from "@/lib/db/errors"; -import { callerBoundTruncationReason, containerDepth, declaredKinds, findKind } from "@/lib/db/object-kinds"; +import { + applySourceBound, + callerBoundTruncationReason, + containerDepth, + declaredKinds, + findKind, + requireSourceKind, +} from "@/lib/db/object-kinds"; import { comparePaths } from "@/lib/db/object-path"; import type { ColumnSchema, @@ -72,6 +79,8 @@ import type { ObjectDetail, ObjectDetailBatch, ObjectKindSpec, + ObjectSourceDocument, + ObjectSourcePart, ProviderCapabilities, } from "@/lib/db/types"; import { @@ -83,7 +92,7 @@ import { readText, splitKeyExpression, } from "./introspect"; -import type { ClickHouseRow, ClickHouseTransport } from "./transport"; +import { type ClickHouseRow, type ClickHouseTransport, ClickHouseTransportError } from "./transport"; const PROVIDER = "clickhouse" as const; @@ -103,17 +112,50 @@ export const CLICKHOUSE_CONTAINER_LEVELS: ContainerLevels = Object.freeze([ { id: "schema", label: "Database", labelPlural: "Databases" }, ] as const); +/** + * EVERY declared kind has a definition text, and `sql` is the right Monaco id for all five + * (#789 Phase 2). + * + * The four table-backed kinds read `system.tables.create_table_query` and a function reads + * `system.functions.create_query`. The function row is a MEASUREMENT and not a reading of the + * documentation, which marks that column Obsolete: on 26.7.1.1315 it carries + * `CREATE FUNCTION order_total_with_tax AS total -> (total * 1.2)` for the one + * `SQLUserDefined` row while all 1858 `System` rows are empty, and the measurement wins over + * the label (#789 probe 10). `docs/providers/clickhouse.md` records both, so a later reader + * knows the column is deprecated rather than absent. + * + * `hasSource` answers for the KIND and the READ answers per object. Two of these kinds hold + * objects with no text at all - a config-file dictionary and a function whose `origin` is + * `ExecutableUserDefined` or `WasmUserDefined` - and each is a refusal PART beside readable + * siblings of the same kind, never a dropped declaration. + */ export const CLICKHOUSE_OBJECT_KINDS: readonly ObjectKindSpec[] = Object.freeze([ - { id: "table", role: "relation", label: "Table", labelPlural: "Tables" }, - { id: "view", role: "relation", label: "View", labelPlural: "Views" }, + { id: "table", role: "relation", label: "Table", labelPlural: "Tables", hasSource: true, sourceLanguage: "sql" }, + { id: "view", role: "relation", label: "View", labelPlural: "Views", hasSource: true, sourceLanguage: "sql" }, { id: "materialized_view", role: "relation", label: "Materialized View", labelPlural: "Materialized Views", + hasSource: true, + sourceLanguage: "sql", + }, + { + id: "dictionary", + role: "config", + label: "Dictionary", + labelPlural: "Dictionaries", + hasSource: true, + sourceLanguage: "sql", + }, + { + id: "function", + role: "routine", + label: "Function", + labelPlural: "Functions", + hasSource: true, + sourceLanguage: "sql", }, - { id: "dictionary", role: "config", label: "Dictionary", labelPlural: "Dictionaries" }, - { id: "function", role: "routine", label: "Function", labelPlural: "Functions" }, ] as const); /** @@ -487,6 +529,27 @@ function containerDatabase(capabilities: ProviderCapabilities, container: readon return containerSegment(capabilities, container, "schema"); } +/** + * Refuses an object path of the wrong shape, naming the shape it does admit. + * + * ONE writer for two readers since #789 Phase 2: `describeObject` and `readObjectSource` ask + * the same question about the same path, and two copies of this derivation are two chances + * for the detail pane and the Source tab to disagree about what an object's address is. + * + * Derived, not counted. One segment per declared container level plus the name, and the + * segment NAMES are the declared level labels sliced to the same depth, so the message and + * the check cannot disagree. No kind here declares `attachedTo`, so there is a single shape + * rather than the two MySQL accepts. + */ +function assertObjectPathShape(capabilities: ProviderCapabilities, kind: string, path: readonly string[]): void { + const shape = [...declaredLevels(capabilities).map((level) => level.label.toLowerCase()), "name"]; + if (path.length === shape.length) return; + throw new QueryError( + `A ClickHouse "${kind}" path is [${shape.join(", ")}], received ${JSON.stringify(path)}`, + PROVIDER, + ); +} + /** * Every declared kind seeded at zero, before any row is read. * @@ -802,17 +865,7 @@ export async function describeObject( throw new QueryError(`ClickHouse declares no object kind "${kind}"`, PROVIDER); } - // Derived, not counted. One segment per declared container level plus the name, and - // the segment NAMES are the declared level labels sliced to the same depth, so the - // message and the check cannot disagree. No kind here declares `attachedTo`, so - // there is a single shape rather than the two MySQL accepts. - const shape = [...declaredLevels(capabilities).map((level) => level.label.toLowerCase()), "name"]; - if (path.length !== shape.length) { - throw new QueryError( - `A ClickHouse "${kind}" path is [${shape.join(", ")}], received ${JSON.stringify(path)}`, - PROVIDER, - ); - } + assertObjectPathShape(capabilities, kind, path); // The same two questions `listObjects` asks, in the same order: the DECLARATION // decides whether the kind exists, then the catalog map decides whether anything can @@ -985,3 +1038,355 @@ function groupRows(rows: readonly ClickHouseRow[]): Map } return grouped; } + +// ============================================================================ +// Object source reading (#789 Phase 2) +// ============================================================================ + +/** + * ONE object's definition text, and the whole reason this read takes NO IDENTIFIER + * POSITION anywhere. + * + * `SHOW CREATE TABLE|VIEW|DICTIONARY` is the statement a ClickHouse user knows, and it + * takes an identifier where a bind would go. On this engine that position is a + * statement-injection hazard rather than a quoting inconvenience: MEASURED on 26.7.1.1315 + * (#789 probe 11), a backslash inside a QUOTED IDENTIFIER is processed as an ESCAPE in both + * the double-quote and the backtick form, so `SELECT 1 AS "a\"b"` and `SELECT 1 AS "a""b"` + * produce the same identifier, and a table created as `"x\\"` stores exactly one trailing + * backslash. A name ending in one therefore SWALLOWS the closing quote and the parser keeps + * reading: the same statement with two more aliases fails ten characters later, at the + * FORMAT clause. `SQLBaseProvider.escapeIdentifier` doubles ONLY the quote character and is + * unsafe here, and `literal()` below is a STRING escaper whose single quotes are a parse + * error in an identifier position. + * + * So the position is REMOVED rather than escaped. Every one of the three statements here is + * a `WHERE x = ` read of a system table, which is the same value position the other + * nine `literal()` call sites in this file use and where the doubled quote plus the escaped + * backslash are both measured to be correct. Live-verified against the committed fixture's + * own `` demo.`bs_one\` ``: the escaped spelling reads the object back, and the naive one, + * with the backslash left alone, answers code 62 `Single quoted string is not closed` having + * run on into the trailing clause. + * + * NOTHING IS LOST BY AVOIDING `SHOW CREATE`, and that is a measurement too rather than an + * assumption: `formatQuery(create_table_query)` is BYTE-IDENTICAL to what + * `SHOW CREATE ` answers, for a table, a view, a materialised view and a DDL + * dictionary alike (measured on 26.7.1.1315 against the fixture: 323, 238 and 212 bytes, and + * `mv_rollup` identical). The catalog column on its own is a single line, which is the same + * statement and a much worse thing to hand a reader, so the engine's own formatter is asked + * for the spelling and the engine's own catalog for the text. + * + * A `function` is the one kind NOT formatted, and that is forced rather than chosen: + * `formatQuery('')` raises code 62 `Empty query`, `system.functions.create_query` really is + * EMPTY for a non-SQL origin, and turning that per-object refusal into a hard error would + * take the whole document away. Measured: the formatter leaves the one SQL function's text + * byte-identical anyway, so the column is read raw. + * + * `system.tables.create_table_query` REDACTS a credential. A DDL dictionary comes back + * carrying `PASSWORD '[HIDDEN]'`, which is the server's own substitution under + * `format_display_secrets_in_show_and_select = 0`, the default, and `SHOW CREATE` does the + * same. The text is still `complete`: it is the statement the server publishes for that + * object, and section 6.2 of docs/providers/clickhouse.md says where the placeholder comes + * from so a reader is not told a password was lost in transit. + */ +function objectSourceSql(database: string, name: string): string { + return [ + "SELECT formatQuery(t.create_table_query) AS objectSource", + "FROM system.tables AS t", + `WHERE t.database = ${literal(database)} AND t.name = ${literal(name)}`, + ].join(" "); +} + +/** + * One function's text, with the ORIGIN that says why there may be none. + * + * `create_query` carries the statement on 26.7.1.1315 despite the current documentation + * marking the column Obsolete, and the measurement wins over the label (#789 probe 10). The + * control that makes it a PER OBJECT fact rather than a server-wide one: all 1858 `System` + * rows are empty beside the one `SQLUserDefined` row that is not. + * + * `origin` is projected because it is the only thing that can say WHICH absence an empty + * text is. An `ExecutableUserDefined` or `WasmUserDefined` function has no SQL text at all - + * its body is an external program or a WASM module - and a refusal that did not name the + * origin would be a guess dressed as the engine's answer. + */ +function functionSourceSql(name: string): string { + return [ + "SELECT f.origin AS objectOrigin, f.create_query AS objectSource", + "FROM system.functions AS f", + `WHERE f.name = ${literal(name)}`, + ].join(" "); +} + +/** + * Whether a dictionary of this name is declared in a CONFIGURATION FILE, and which one. + * + * Asked only after `objectSourceSql()` has answered no row, and it is what separates the two + * facts that read produces for this kind: a dictionary nobody declared, and a dictionary + * declared where `system.tables` cannot see it. MEASURED: a config-file dictionary has NO + * `system.tables` row at all and sits in `system.dictionaries` with an EMPTY `database` and + * an `origin` naming the file, while a DDL dictionary carries its database and a uuid. + * + * `d.database = ''` is that flavour's whole address, because a database cannot be named the + * empty string, so this question can never be answered by the DDL dictionary the first read + * already covers. + * + * `SHOW CREATE DICTIONARY` is not what asks it. Measured: for the fixture's + * `dict_regions_config` it answers code 390 CANNOT_GET_CREATE_TABLE_QUERY, + * "Table `dict_regions_config` doesn't exist.", which is a FALSE claim about a dictionary + * the server is serving - the same shape as Oracle's ORA-31603 "not found in schema" for an + * object a caller simply may not read (#789 ruling C). A refusal has to say which absence it + * is, so this read answers the fact and the sentence is composed from it. + */ +function configDictionarySql(name: string): string { + return [ + "SELECT d.origin AS objectOrigin", + "FROM system.dictionaries AS d", + `WHERE d.name = ${literal(name)} AND d.database = ''`, + ].join(" "); +} + +/** + * What one source statement produced: a text, a refusal, or a legal absence. + * + * Three arms and never one shape with an optional `text`, for the reason `ObjectSourcePart` + * is a union: a refusal and an absent text are different facts, and one shape carrying both + * makes them the same value at every call site below. + */ +type SourceRead = + | { readonly outcome: "text"; readonly text: string } + | { readonly outcome: "refused"; readonly unavailable: string } + | { readonly outcome: "absent" }; + +/** + * One source statement, with a PRIVILEGE DENIAL classified rather than thrown. + * + * MEASURED on 26.7.1.1315, and the two catalogs behave differently, which is why this is one + * helper rather than an arm on one of them. `system.tables` FILTERS BY GRANT: a user holding + * only `SELECT ON demo.orders` reads that table's `create_table_query` and gets ZERO ROWS for + * `customers`, never a denial - so a caller who cannot see an object never lists it and never + * reaches this read for it. `system.dictionaries` DENIES instead, code 497 as HTTP 500 + * (section 3.3), with the sentence "src_probe: Not enough privileges. To execute this query, + * it's necessary to have the grant SELECT ON system.dictionaries." + * + * That sentence is carried VERBATIM and never through the provider's error mapping, which + * would put this product's prefix in front of the server's words in a pane whose whole + * purpose is to show the reader what the server said. Every OTHER failure propagates: a + * timeout or a dropped socket is nobody answering at all, and rendering it as this object's + * own refusal would present a symptom as a fact about the object. + */ +async function querySource( + transport: ClickHouseTransport, + sql: string, +): Promise<{ readonly rows: readonly ClickHouseRow[] } | { readonly unavailable: string }> { + try { + return { rows: (await transport.query(sql)).rows }; + } catch (error) { + if (error instanceof ClickHouseTransportError && error.is("ACCESS_DENIED")) { + return { unavailable: error.message }; + } + throw error; + } +} + +/** One table-backed object's definition: a table, a view, a materialised view or a DDL dictionary. */ +async function readTableBackedSource( + transport: ClickHouseTransport, + database: string, + name: string, +): Promise { + const answer = await querySource(transport, objectSourceSql(database, name)); + if (!("rows" in answer)) return { outcome: "refused", unavailable: answer.unavailable }; + const row = answer.rows[0]; + if (row === undefined) return { outcome: "absent" }; + const text = readText(row.objectSource); + if (text.trim() === "") { + // An empty definition is not a definition. No live row produces one - 0 of 186 + // `system.tables` rows carry an empty `create_table_query`, and `formatQuery` raises + // code 62 on one rather than answering a blank - so this is the arm that keeps a + // wire-compatible fork or a future column change from reaching an editor buffer with + // nothing in it. + return { + outcome: "refused", + unavailable: + "ClickHouse answered an empty create_table_query for this object, so there is no definition to show. " + + "The object is in system.tables and the column that carries its CREATE statement is blank.", + }; + } + return { outcome: "text", text }; +} + +/** + * One function's definition, or the reason its origin has none. + * + * The empty text is the LIVE case here rather than the defensive one, which is the exact + * mirror of the table-backed read above. + */ +async function readFunctionSource(transport: ClickHouseTransport, name: string): Promise { + const answer = await querySource(transport, functionSourceSql(name)); + if (!("rows" in answer)) return { outcome: "refused", unavailable: answer.unavailable }; + const row = answer.rows[0]; + if (row === undefined) return { outcome: "absent" }; + const text = readText(row.objectSource); + if (text.trim() === "") { + const origin = readIdentifier(row.objectOrigin); + return { + outcome: "refused", + unavailable: + "ClickHouse publishes no SQL text for this function: system.functions.create_query is empty" + + // The no-origin arm names the second absence rather than dropping the clause. It is + // not a live shape - `origin` is an Enum8 and always carries one of its four names - + // but an arm that produced the EMPTY string was DEAD while raw lcov reported this + // line as hit, because the truthy arm is on the same physical line (standing ruling + // 5b, #789). It is driven in the suite by a server answering a blank origin. + (origin === null ? " and system.functions reports no origin for it" : ` and its origin is ${origin}`) + + ". A function whose body is an external program or a WASM module has no SQL definition to read.", + }; + } + return { outcome: "text", text }; +} + +/** + * One dictionary's definition, and the SECOND question a missing row makes it ask. + * + * A DDL dictionary is read exactly as a table is, out of `system.tables`. Nothing there means + * one of two facts and they must not be reported as one: a CONFIG-FILE dictionary, which the + * server is serving and publishes no CREATE statement for, and no dictionary of that name at + * all. The second read answers which, and only the first of the two is a refusal - the second + * raises, because an object the provider cannot find never answers a document (#789). + */ +async function readDictionarySource( + transport: ClickHouseTransport, + database: string, + name: string, +): Promise { + const first = await readTableBackedSource(transport, database, name); + if (first.outcome !== "absent") return first; + + const answer = await querySource(transport, configDictionarySql(name)); + if (!("rows" in answer)) return { outcome: "refused", unavailable: answer.unavailable }; + const row = answer.rows[0]; + if (row === undefined) return { outcome: "absent" }; + const origin = readIdentifier(row.objectOrigin); + return { + outcome: "refused", + unavailable: + "ClickHouse publishes no CREATE DICTIONARY statement for this dictionary: " + + // Two WHOLE clauses rather than a name interpolated into one sentence. The earlier + // spelling put the fallback inside the phrase "the configuration file X", so a server + // reporting no origin produced "the configuration file system.dictionaries reports no + // origin for, not in SQL", which is broken prose shown to a reader as the engine's own + // fact. That arm was DEAD while raw lcov reported the line as hit (standing ruling 5b, + // #789); both arms are driven in the suite now. + (origin === null + ? "it is declared outside SQL and system.dictionaries reports no origin for it" + : `it is declared in the configuration file ${origin}, not in SQL`) + + ", so it has no system.tables row to read one from. SHOW CREATE DICTIONARY answers that the table does " + + "not exist for it, which is a false claim about a dictionary this server is serving.", + }; +} + +/** + * One read as the part a document carries. + * + * The two arms are built as WHOLE LITERALS and neither is spread from the other, which is the + * point rather than a style. A part carrying BOTH `text` and `unavailable` COMPILES as an + * `ObjectSourcePart`, because TypeScript's excess-property check on a union admits any + * property declared on ANY member of it, and `isSourcePartUnavailable` then narrows such a + * part to the refusal arm and drops a definition the engine really returned. This function + * cannot build one: the refusal arm returns before the text arm is reached and neither + * literal mentions the other's keys (#789). + */ +function sourcePart( + read: SourceRead & { readonly outcome: "text" | "refused" }, + language: string, + limit: number | undefined, +): ObjectSourcePart { + if (read.outcome === "refused") { + return { id: SOURCE_PART_ID, label: SOURCE_PART_LABEL, unavailable: read.unavailable }; + } + const bounded = applySourceBound(read.text, limit); + return { + id: SOURCE_PART_ID, + label: SOURCE_PART_LABEL, + text: bounded.text, + language, + // The statement runs as given, so `complete`; the server REBUILT it from its own catalog + // rather than storing what the author typed, so `regenerated`. Measured: a table created + // as `total Decimal(12, 2) DEFAULT 0` comes back backquoted, with a `SETTINGS + // index_granularity = 8192` clause nobody wrote, and a reader must never be shown a + // reconstruction as an original. + form: "complete", + origin: "regenerated", + ...(bounded.truncated === undefined ? {} : { truncated: bounded.truncated }), + }; +} + +/** Every part this engine emits is the object's whole definition, so there is one id and one label. */ +const SOURCE_PART_ID = "definition"; +const SOURCE_PART_LABEL = "Definition"; + +/** + * One object's definition text (#789 Phase 2). + * + * EVERY declared kind can answer, so there is no kind here that declares nothing. The + * DECLARATION is what decides, read off `objectKinds` and never off a list of kind ids kept + * beside it, and the catalog map decides which statement reads it - the same two questions, + * in the same order, that `listObjects` and `describeObject` ask. + * + * ONE PART per document. No ClickHouse object is two texts: there is no package, no spec and + * body split, and a materialised view's implicit inner table is storage the server created + * rather than a second definition of the view (it is excluded from the tree for that reason, + * see this file's docblock). + * + * The database is the container segment the DECLARATION names `schema` and the object's own + * name is `path[path.length - 1]`, never a literal index (standing ruling 5g), pinned in this + * provider's suite by a two-level declaration AND by one that swaps the two levels over. + * + * A FUNCTION is server-global and its path's container segment records where it was reached + * from rather than something that owns it, exactly as the listing says, so that segment is + * deliberately not part of the statement that reads it. + */ +export async function readObjectSource( + transport: ClickHouseTransport, + capabilities: ProviderCapabilities, + path: readonly string[], + kind: string, + limit?: number, +): Promise { + const spec = requireSourceKind(capabilities, kind, { displayName: "ClickHouse", type: PROVIDER }); + assertObjectPathShape(capabilities, kind, path); + const catalog = objectCatalog(kind); + if (catalog === undefined) { + throw new QueryError( + `ClickHouse declares readable source for the kind "${kind}" but has no statement that reads it`, + PROVIDER, + ); + } + + const database = containerSegment(capabilities, path, "schema"); + const name = path[path.length - 1]; + const read = + catalog === "functions" + ? await readFunctionSource(transport, name) + : catalog === "dictionaries" + ? await readDictionarySource(transport, database, name) + : await readTableBackedSource(transport, database, name); + + if (read.outcome === "absent") { + // Absence RAISES and is never a refusal part: the document's `parts` tuple leaves no + // empty value for an absence to be confused with, and a refusal sentence over an object + // nobody found would be a claim about the wrong thing. The sentence names the object's + // last segment, which is the identifier the caller asked under. + throw new QueryError( + catalog === "functions" + ? `No ClickHouse function named ${name}` + : `No ClickHouse ${kind} named ${name} in ${database}`, + PROVIDER, + ); + } + + // An array LITERAL, which is what satisfies the non-empty tuple. `rows.map(...)` does not, + // and casting past it would defeat the invariant the tuple exists for. + const parts: [ObjectSourcePart] = [sourcePart(read, spec.sourceLanguage, limit)]; + return { path: [...path], kind, parts }; +} diff --git a/src/lib/db/providers/sql/druid/objects.ts b/src/lib/db/providers/sql/druid/objects.ts index 04cbc4c3..4b247948 100644 --- a/src/lib/db/providers/sql/druid/objects.ts +++ b/src/lib/db/providers/sql/druid/objects.ts @@ -52,6 +52,17 @@ * answers `Unsupported SQL statement [UPDATE]` - which is the same measurement behind * the provider's `supportsInlineRowEdit: false`. * + * No kind declares `hasSource` and there is no `readObjectSource` here, and the reason + * differs per kind rather than being one sentence about the engine (#789). A datasource + * and a system table have no definition text ANYWHERE, by the same parser refusal above: + * neither was ever written down as a statement, so a refusal part would claim a read + * failed where nothing was ever readable. A lookup is different - it really is authored, + * as a JSON spec - but that spec lives on the Coordinator REST API, `DruidTransport` + * publishes only `query(sql)` and `close()`, and the seam guard fails the build on any + * endpoint reached from this directory, so it is a TRANSPORT change and is filed in + * `docs/BACKLOG.md` rather than left as an unrecorded gap. What SQL answers for a lookup + * is its key/value PAIRS, which are its content and not its definition. + * * Nothing here reads `sys` and that is deliberate, for the reason `introspect.ts` * states about the schema tree: a cluster running `druid-basic-security` grants the * `sys` schema separately from the catalogs, so a row count taken from `sys.segments` diff --git a/src/lib/db/providers/sql/duckdb/index.ts b/src/lib/db/providers/sql/duckdb/index.ts index 03f84d8e..862fa0bf 100644 --- a/src/lib/db/providers/sql/duckdb/index.ts +++ b/src/lib/db/providers/sql/duckdb/index.ts @@ -53,6 +53,7 @@ import { type MaintenanceType, type ObjectDetail, type ObjectDetailBatch, + type ObjectSourceDocument, type PerformanceMetrics, type ProviderCapabilities, type ProviderExecutionContext, @@ -64,7 +65,14 @@ import { type StorageStats, type TableStats, } from "../../../types"; -import { callerBoundTruncationReason, containerDepth, declaredKinds, findKind } from "../../../object-kinds"; +import { + applySourceBound, + callerBoundTruncationReason, + containerDepth, + declaredKinds, + findKind, + requireSourceKind, +} from "../../../object-kinds"; import { DatabaseConfigError, DatabaseError, @@ -117,6 +125,13 @@ import { listObjectsSql, listedObject, objectRead, + type ObjectSourceRow, + blankDefinitionReason, + blankDefinitionShape, + objectSourceColumn, + objectSourceForm, + objectSourceOrigin, + objectSourceSql, seedZeroCounts, } from "./objects"; import { comparePaths } from "@/lib/db/object-path"; @@ -422,19 +437,51 @@ export class DuckDBProvider extends SQLBaseProvider { // catalog hierarchy entirely - it has no `database_name` and no `schema_name`, so // there is no container in this model it could hang under. Recorded in // `docs/providers/duckdb.md` as out of Phase 1's scope. + // + // EVERY declared kind carries `hasSource` (#789), and all four are `sql`. DuckDB + // publishes a definition text for each of them and no fifth kind is declared, so + // the "declares nothing" half of this engine's row in #789 is empty. `sql` is the + // honest id rather than a compromise: DuckDB's dialect is PostgreSQL-shaped, the + // installed monaco-editor 0.56.0 registers no DuckDB id, and the text the engine + // publishes is ordinary SQL. The `macro` text is the only `partial` form ON THIS + // ENGINE, not in the fleet: the #789 design names PostgreSQL `view` and + // `materialized_view` and Couchbase `function` as producers of the same arm, and + // `postgres.ts` already answers it. `objects.ts` records why a macro is one. objectKinds: [ - { id: "table", role: "relation", label: "Table", labelPlural: "Tables", acceptsRowWrites: true }, + { + id: "table", + role: "relation", + label: "Table", + labelPlural: "Tables", + acceptsRowWrites: true, + hasSource: true, + sourceLanguage: "sql", + }, // No `acceptsRowWrites` on a view. Measured on v1.5.5: `INSERT INTO ` // answers `Catalog Error: is not an table`, so a view is never an import // or inline-edit target here - not even the single-table case PostgreSQL takes. - { id: "view", role: "relation", label: "View", labelPlural: "Views" }, + { id: "view", role: "relation", label: "View", labelPlural: "Views", hasSource: true, sourceLanguage: "sql" }, // ONE kind for both macro forms. A scalar macro (`AS `) and a table // macro (`AS TABLE
_`), and every + * listing and every count here carries `name NOT LIKE 'sqlite\\_%' ESCAPE '\\'`, so no path + * the tree offers addresses such a row. The arm exists anyway, because an empty definition + * must never reach an editor as a definition: this is what the read would say if it ever + * started producing one. + */ +function blankDefinitionReason(shape: BlankDefinitionShape, kind: string, name: string): string { + const opening = `SQLite answered a row for the ${kind} "${name}"`; + if (shape === "absent") { + return ( + `${opening} with no sqlite_schema.sql column at all. That is a fact about this read and ` + + "not about the object: the statement asks for one column and the reply does not carry it." + ); + } + if (shape === "blank") { + return ( + `${opening} whose sqlite_schema.sql holds no non-whitespace character. An empty ` + + "definition is not a definition, so it is refused rather than opened in an editor, and " + + "the engine supplies no sentence of its own for this." + ); + } + return ( + `${opening} whose sqlite_schema.sql is NULL. The engine stores NULL there only for an ` + + "index it created for itself, and it supplies no sentence of its own for this." + ); +} + /** Which statement lists one kind, and what it binds. */ const OBJECT_LISTINGS: Readonly> = { table: { sql: LIST_TABLES_SQL, params: [MAIN_SCHEMA] }, @@ -1294,14 +1472,7 @@ export class SQLiteProvider extends SQLBaseProvider { throw new QueryError(`SQLite declares no object kind "${kind}"`, "sqlite"); } - const levels = declaredLevels(capabilities).map((level) => level.label.toLowerCase()); - const shape = spec.attachedTo === undefined ? [...levels, "name"] : [...levels, spec.attachedTo, "name"]; - if (path.length !== shape.length) { - throw new QueryError( - `A SQLite "${kind}" path is [${shape.join(", ")}], received ${JSON.stringify(path)}`, - "sqlite", - ); - } + assertObjectPathShape(capabilities, spec, kind, path); if (spec.role !== "relation") { return { path: [...path], columns: [], indexes: [], foreignKeys: [] }; @@ -1462,6 +1633,91 @@ export class SQLiteProvider extends SQLBaseProvider { return truncated ? { details, truncated: { limit, reason: callerBoundTruncationReason(limit) } } : { details }; } + /** + * One object's definition text, exactly as its author submitted it (#789 Phase 2). + * + * EVERY DECLARED KIND CAN ANSWER, and this is the simplest source story in the fleet: + * `sqlite_schema` keeps one row per object and `sql` on that row is the text somebody + * typed. So `form` is `complete` - every one of them runs as given - and `origin` is + * `stored`, which on this engine is a real distinction rather than a formality. It is the + * one engine family where the `stored` arm has a producer at all among the SQL providers, + * and the caption's whole job is to stop a REGENERATION reading as the user's own text: if + * every engine said `regenerated` the distinction would be decoration. + * + * `stored` CARRIES ONE CAVEAT, measured on SQLite 3.53.2 and recorded in + * docs/providers/sqlite.md: the engine REWRITES the stored text on `ALTER TABLE`. A rename + * rewrites the name and quotes it, and an added column is appended to the text. So the + * bytes are the author's own bytes up to the last schema change, which is still a different + * fact from a statement rebuilt out of a catalog, and a reader is told which one they have. + * + * NO REFUSAL IS REACHABLE HERE, and that is a measured CANNOT rather than an omission. + * `sqlite_schema.sql` is NULL for exactly one shape, an index SQLite created for itself, + * and every listing this provider answers excludes the `sqlite_` prefix, so no path the + * tree offers addresses such a row. The blank arm below exists anyway, because an empty + * definition must never reach an editor as a definition. + * + * The document is ONE part, so `parts` is an array literal of one. There is nothing to + * assemble: unlike an Oracle or a MariaDB package, a SQLite object has exactly one text. + * + * Absence RAISES rather than answering a refusal part, and the two are different facts: a + * refusal says the definition cannot be read, and no rows says nothing of that name is + * there under that kind. The message names the last segment because that is the only thing + * a caller can act on. + * + * Neither bind is positional (standing ruling 5g). The object's own name is + * `path[path.length - 1]`, which is right at every depth, and the path SHAPE comes from + * `assertObjectPathShape`, the same reader `describeObject` uses, so the Source tab and the + * detail pane cannot disagree about what a trigger's address is. A two-level declaration + * driven all the way to the binds pins both in this provider's suite, because a zero-level + * engine's own fixture cannot tell a derivation from a literal. + */ + public async readObjectSource(path: readonly string[], kind: string, limit?: number): Promise { + this.ensureConnected(); + const capabilities = this.getCapabilities(); + const spec = requireSourceKind(capabilities, kind, { displayName: "SQLite", type: "sqlite" }); + assertObjectPathShape(capabilities, spec, kind, path); + if (!Object.hasOwn(SOURCE_CATALOG_TYPES, kind)) { + throw new QueryError( + `SQLite declares readable source for the kind "${kind}" but has no catalog type that reads it`, + "sqlite", + ); + } + + const name = path[path.length - 1]; + const rows = this.runObjectQuery(OBJECT_SOURCE_SQL, [SOURCE_CATALOG_TYPES[kind], name]); + const row = rows[0]; + if (row === undefined) { + throw new QueryError(`No SQLite ${kind} named ${name} in ${MAIN_SCHEMA}`, "sqlite", OBJECT_SOURCE_SQL); + } + + const definition = row.sql; + if (definition === null || definition === undefined || definition.trim() === "") { + const reason = blankDefinitionReason(blankDefinitionShape(row), kind, name); + return { + path: [...path], + kind, + parts: [{ id: "definition", label: "Definition", unavailable: reason }], + }; + } + + const bounded = applySourceBound(definition, limit); + return { + path: [...path], + kind, + parts: [ + { + id: "definition", + label: "Definition", + text: bounded.text, + language: spec.sourceLanguage, + form: "complete", + origin: "stored", + ...(bounded.truncated === undefined ? {} : { truncated: bounded.truncated }), + }, + ], + }; + } + // ============================================================================ // Health & Monitoring // ============================================================================ diff --git a/src/lib/db/providers/sql/trino/index.ts b/src/lib/db/providers/sql/trino/index.ts index 4ddd88eb..35d03ae0 100644 --- a/src/lib/db/providers/sql/trino/index.ts +++ b/src/lib/db/providers/sql/trino/index.ts @@ -54,7 +54,14 @@ import { QueryError, TimeoutError, } from "@/lib/db/errors"; -import { callerBoundTruncationReason, containerDepth, declaredKinds, findKind } from "@/lib/db/object-kinds"; +import { + applySourceBound, + callerBoundTruncationReason, + containerDepth, + declaredKinds, + findKind, + requireSourceKind, +} from "@/lib/db/object-kinds"; import { type ActiveSessionDetails, type Container, @@ -68,6 +75,7 @@ import { type MaintenanceType, type ObjectDetail, type ObjectDetailBatch, + type ObjectSourceDocument, type PerformanceMetrics, type PreparedQuery, type ProviderCapabilities, @@ -99,6 +107,7 @@ import { TRINO_MATERIALIZED_VIEW_KIND, type KindCountRow, type TrinoContainer, + type TrinoObjectRead, applyKindCounts, objectDetailFromRows, objectKey, @@ -114,8 +123,15 @@ import { trinoMaterializedViewListSql, trinoObjectColumnsSql, trinoObjectCountsSql, + TRINO_SOURCE_PART_ID, + trinoArgumentSignature, + trinoCreateSignature, + trinoObjectSourceSql, trinoRelationListSql, trinoSchemaListSql, + trinoSourceStatementFor, + trinoTranslationRefusal, + trinoUnreadableSourceReason, } from "./objects"; import { comparePaths } from "@/lib/db/object-path"; import { @@ -356,11 +372,19 @@ export class TrinoProvider extends SQLBaseProvider { // into `memory.app.customers` succeeds while `tpch` answers that its connector does // not support modifying table rows. The declaration is about the engine's model, and // the connector's own refusal is the better message for the case it cannot. - { id: "table", role: "relation", label: "Table", labelPlural: "Tables", acceptsRowWrites: true }, + { + id: "table", + role: "relation", + label: "Table", + labelPlural: "Tables", + acceptsRowWrites: true, + hasSource: true, + sourceLanguage: "sql", + }, // No `acceptsRowWrites` on either view kind, measured on 476: an INSERT answers // "Inserting into views is not supported" and "Inserting into materialized views is // not supported" respectively, on every connector. - { id: "view", role: "relation", label: "View", labelPlural: "Views" }, + { id: "view", role: "relation", label: "View", labelPlural: "Views", hasSource: true, sourceLanguage: "sql" }, // Supported by SOME connectors only, Iceberg among them, and declared anyway: the // kind exists in the engine's model and `system.metadata.materialized_views` is an // engine-level catalog, so a catalog holding none answers an honest 0 rather than a @@ -371,6 +395,8 @@ export class TrinoProvider extends SQLBaseProvider { role: "relation", label: "Materialized View", labelPlural: "Materialized Views", + hasSource: true, + sourceLanguage: "sql", }, // Catalog-stored SQL functions, from release 431 and on the Hive and Memory // connectors only. Declared because it was CONFIRMED on the build @@ -379,7 +405,14 @@ export class TrinoProvider extends SQLBaseProvider { // `SHOW FUNCTIONS FROM memory.app` lists it. Leaving the kind out would make a // function somebody wrote invisible in the tree, which is a worse absence than an // empty folder. - { id: "function", role: "routine", label: "Function", labelPlural: "Functions" }, + { + id: "function", + role: "routine", + label: "Function", + labelPlural: "Functions", + hasSource: true, + sourceLanguage: "sql", + }, ], }; } @@ -872,8 +905,11 @@ export class TrinoProvider extends SQLBaseProvider { * A catalog-level call is REFUSED rather than fanned out over the catalog's schemas: the * fan-out is one full HTTP exchange per schema, unbounded on a Hive or Iceberg catalog, * and `SHOW FUNCTIONS` is the only surface there is - `information_schema` holds no - * routine catalog on this engine and `system.jdbc.procedures` answers zero rows for a - * schema holding three functions (measured on 476). + * routine catalog on this engine and `system.jdbc.procedures` is EMPTY, whole table, + * `SELECT count(*)` answering 0 while `SHOW FUNCTIONS FROM memory.app` answers a row for + * every function this repository's fixture creates (re-measured on 476, 2026-09-13). The + * emptiness of the whole table is stated rather than a row count for one schema so that + * the sentence counts nothing and cannot go stale when the fixture grows (#789). */ private async listFunctions(capabilities: ProviderCapabilities, read: TrinoContainer): Promise { if (read.schema === undefined) throw new QueryError(this.functionScopeRefusal(), this.type); @@ -1029,6 +1065,146 @@ export class TrinoProvider extends SQLBaseProvider { return truncated ? { details, truncated: { limit, reason: callerBoundTruncationReason(limit) } } : { details }; } + // ========================================================================== + // The source read (#789) + // ========================================================================== + + /** + * One object's definition text, as `SHOW CREATE
` regenerates it. + * + * FOUR KINDS, ONE PART EACH, and all four are `regenerated` rather than `stored`. Trino + * keeps no copy of the statement anybody typed: measured on 476, a view created as + * `CREATE VIEW memory.app.customer_names AS SELECT id, name FROM memory.app.customers` + * reads back as `CREATE VIEW memory.app.customer_names SECURITY DEFINER AS\nSELECT\n id\n, + * name\nFROM\n memory.app.customers`, with a security clause the author never wrote and + * the projection reformatted. Each is `complete`, because each is a statement that runs as + * given rather than a body or a bare SELECT. + * + * THE VIEW KIND CARRIES A LIMIT THIS METHOD CANNOT SEE, and the provider doc states it + * rather than the wire. A Hive-NATIVE view reached through a `hive` connector is not a + * Trino view at all: what comes back is a MACHINE TRANSLATION of a statement nobody wrote + * in Trino SQL, and nothing in the reply distinguishes it from a view Trino itself created. + * That is exactly why `origin` does not claim `stored` for any view here, and why the + * translation FAILURE - the case where the machine cannot do it - is carried as a REFUSAL + * with the engine's own sentence instead of being raised. + * + * A ROUTINE IS RESOLVED IN TWO STEPS and a relation in one, and the branch is on the + * declared `role` rather than on the kind id, the rule `CLAUDE.md` states for everything + * under `src/lib/db`. The reason is a property of a routine rather than of Trino's spelling + * of one: overloads share a name, so the path's last segment is the disambiguated + * `plus_one(bigint)` form {@link functionSegment} minted, and `SHOW CREATE FUNCTION` takes + * the BARE name and answers one row per overload. + */ + public async readObjectSource(path: readonly string[], kind: string, limit?: number): Promise { + const capabilities = this.getCapabilities(); + const spec = requireSourceKind(capabilities, kind, { displayName: this.dialect.displayName, type: this.type }); + + const read = objectRead(capabilities, spec, path); + const statement = trinoSourceStatementFor(kind); + // The bare name and the signature to match, for a routine; the segment itself and no + // signature for a relation, whose reply is one row. + const resolved: { name: string; signature?: string } = + spec.role === "routine" ? await this.resolveOverload(read) : { name: read.name }; + + const sql = trinoObjectSourceSql(statement, read.catalog, read.schema, resolved.name); + let rows: TrinoRow[]; + try { + // NOT `runObjectRows`, and that is the whole reason this one read is spelled out here. + // `mapTrinoError` turns everything in the `engine` category into a bare `QueryError` + // carrying the message alone, which throws away the `errorName` the coordinator sent + // and leaves an English sentence as the only thing left to branch on. The fault name is + // what the branch below keys on, so the failure has to reach it unmapped. + rows = (await this.requireTransport().query(sql)).rows; + } catch (error) { + // ONLY the translation failure becomes a refusal. Every other failure is rethrown, so + // an object that is not there RAISES rather than telling a user its definition cannot + // be read (design guarantee 6, #789). Measured on 476, the absence sentences are + // `Table 'memory.app.no_such_table' does not exist` and + // `Relation 'memory.app.customer_names' is a view, not a table`, both of which name the + // object and neither of which is a statement about readability. + const refusal = trinoTranslationRefusal(error); + // Mapped HERE and not before the check, so a raise still reaches a caller as the + // provider error every other read answers, with the statement attached. + if (refusal === undefined) throw this.mapTrinoError(error, sql); + return { + path: [...path], + kind, + parts: [{ id: TRINO_SOURCE_PART_ID, label: statement.column, unavailable: refusal }], + }; + } + + const row = + resolved.signature === undefined + ? rows[0] + : rows.find((candidate) => trinoCreateSignature(candidate[statement.column]) === resolved.signature); + if (row === undefined) { + throw new QueryError(`No Trino ${kind} named ${read.name} in ${read.catalog}.${read.schema}`, this.type, sql); + } + + const definition = row[statement.column]; + if (typeof definition !== "string" || definition.trim() === "") { + return { + path: [...path], + kind, + parts: [ + { + id: TRINO_SOURCE_PART_ID, + label: statement.column, + unavailable: trinoUnreadableSourceReason(statement, read.name, definition), + }, + ], + }; + } + + const bounded = applySourceBound(definition, limit); + return { + path: [...path], + kind, + parts: [ + { + id: TRINO_SOURCE_PART_ID, + label: statement.column, + text: bounded.text, + language: spec.sourceLanguage, + // Both are per-kind facts that happen to agree across all four kinds here, and + // both are written once rather than per branch: every `SHOW CREATE` form answers a + // statement that runs as given, and none of them is the author's own bytes. + form: "complete", + origin: "regenerated", + ...(bounded.truncated === undefined ? {} : { truncated: bounded.truncated }), + }, + ], + }; + } + + /** + * One routine path segment resolved back into the bare name and the signature to match. + * + * Through `SHOW FUNCTIONS`, which is the statement that MINTED the segment: the match is + * `functionSegment(name, argumentTypes) === segment`, reconstructed with the same function + * `listObjects` builds the path with, so nothing here parses the segment. That matters, + * because the segment is NOT unambiguously parseable: the fixture holds a function called + * `we(ird`, whose segment `we(ird(bigint)` has its first parenthesis inside the name. + * + * A segment no row reconstructs is ABSENCE and it raises naming the segment. The engine's + * own sentence for it would not do: measured on 476, `SHOW CREATE FUNCTION` answers + * `Function not found` for a name that is not there, which names neither the function nor + * the schema. + */ + private async resolveOverload(read: TrinoObjectRead): Promise<{ name: string; signature: string }> { + const sql = trinoFunctionListSql(read.catalog, read.schema); + const rows = await this.runObjectRows(sql); + for (const row of rows) { + const name = readObjectIdentifier(row[TRINO_FUNCTION_COLUMNS.name]); + const argumentTypes = row[TRINO_FUNCTION_COLUMNS.argumentTypes]; + if (name === null || typeof argumentTypes !== "string") continue; + if (functionSegment(name, argumentTypes) === read.name) { + return { name, signature: trinoArgumentSignature(argumentTypes) }; + } + } + throw new QueryError(`No Trino function ${read.name} in ${read.catalog}.${read.schema}`, this.type, sql); + } + // ========================================================================== // Monitoring // ========================================================================== diff --git a/src/lib/db/providers/sql/trino/objects.ts b/src/lib/db/providers/sql/trino/objects.ts index b1bc2646..85f38507 100644 --- a/src/lib/db/providers/sql/trino/objects.ts +++ b/src/lib/db/providers/sql/trino/objects.ts @@ -76,7 +76,7 @@ import type { ProviderCapabilities, } from "@/lib/db/types"; import { TRINO_METADATA_SCHEMA, TRINO_UNKNOWN_TEXT } from "./introspect"; -import type { TrinoRow } from "./transport"; +import { TrinoTransportError, type TrinoRow } from "./transport"; /** The canonical type-id, for the errors raised here. */ const TYPE_ID = "trino"; @@ -272,8 +272,13 @@ export function trinoMaterializedViewListSql(container: TrinoContainer): string * * A `SHOW` statement and not a projection, because there is no relation to project: * `information_schema` holds eight views on this engine and none of them is a routine - * catalog, and `system.jdbc.procedures` answers zero rows for a schema holding three - * functions (measured). The column names below therefore cannot be aliased, which is why + * catalog, and `system.jdbc.procedures` is EMPTY: `SELECT count(*)` over the whole table + * answers 0 while `SHOW FUNCTIONS FROM memory.app` answers a row for every function this + * repository's fixture creates (re-measured on 476, 2026-09-13, with that fixture applied). + * The emptiness of the WHOLE TABLE is what is stated here, rather than a row count against + * one schema, because a per-schema count goes stale the moment the fixture gains a function + * and this sentence counts nothing (#789). The column names below therefore cannot be + * aliased, which is why * {@link TRINO_FUNCTION_COLUMNS} spells them with their spaces. */ export function trinoFunctionListSql(catalog: string, schema: string): string { @@ -666,3 +671,292 @@ export function listedObject( export function functionSegment(name: string, argumentTypes: string): string { return `${name}(${argumentTypes})`; } + +// ============================================================================ +// The source read (#789) +// ============================================================================ + +/** + * The one part id every Trino source document carries. + * + * Provider-local, and core reads it as an identity WITHIN ONE DOCUMENT and for nothing else + * (#789). Trino answers exactly one text per object: there is no spec-and-body split here, + * because the engine holds no object whose definition comes in two pieces. + */ +export const TRINO_SOURCE_PART_ID = "definition"; + +export interface TrinoSourceStatement { + /** The object keyword `SHOW CREATE` takes: `TABLE`, `VIEW`, `MATERIALIZED VIEW`, `FUNCTION`. */ + readonly object: string; + /** The reply's only column, which is also the part's label. */ + readonly column: string; +} + +/** + * The `SHOW CREATE` form one source-bearing kind is read with, and the single column its + * reply carries. + * + * Both halves of each row were MEASURED on trinodb/trino:476 on 2026-09-13, against the + * `memory` catalog `database-compose.yml` configures and an Iceberg catalog on an Apache + * Hive 4.0.1 standalone metastore, and all four match what the object-source design + * predicted. The column names are the engine's own and cannot be aliased, the same property + * {@link TRINO_FUNCTION_COLUMNS} records for `SHOW FUNCTIONS`: `SHOW CREATE` is a statement + * rather than a projection, so there is nowhere to put an `AS`. + * + * The column is ALSO the part's label, deliberately, rather than a friendlier word of this + * product's own. It is the engine's own name for the text (design guarantee: a label is the + * engine's word, rendered as-is), and it makes a wrong column VISIBLE: a reply column + * spelled wrongly reads as `undefined` and turns a readable definition into a refusal, which + * passes a whole-statement pin and every count assertion (the epic's recipe rule 6), so + * binding the label to the same constant puts that mistake in front of a reader. + */ +const TRINO_SOURCE_STATEMENTS: Readonly> = { + table: { object: "TABLE", column: "Create Table" }, + view: { object: "VIEW", column: "Create View" }, + materialized_view: { object: "MATERIALIZED VIEW", column: "Create Materialized View" }, + function: { object: "FUNCTION", column: "Create Function" }, +}; + +/** + * The statement one kind's source is read with, or a refusal naming the kind. + * + * `Object.hasOwn` and not `in`: a kind id is an OPEN string, so `TRINO_SOURCE_STATEMENTS["toString"]` + * answers a function off the prototype chain and a kind spelled `toString` would be accepted + * as readable (standing ruling 5g, #789). + */ +export function trinoSourceStatementFor(kind: string): TrinoSourceStatement { + if (!Object.hasOwn(TRINO_SOURCE_STATEMENTS, kind)) { + throw new QueryError( + `Trino declares readable source for the kind "${kind}" and no SHOW CREATE form for it`, + TYPE_ID, + ); + } + return TRINO_SOURCE_STATEMENTS[kind]; +} + +/** One object's definition statement, three-part named and every segment quoted. */ +export function trinoObjectSourceSql( + statement: TrinoSourceStatement, + catalog: string, + schema: string, + name: string, +): string { + return `SHOW CREATE ${statement.object} ${quoteIdentifier(catalog)}.${quoteIdentifier(schema)}.${quoteIdentifier(name)}`; +} + +/** + * The engine's own fault NAME for a Hive-native view that cannot be translated into Trino SQL. + * + * DOCUMENTED AND NOT MEASURED HERE, stated in advance rather than reported around. Reaching + * it needs a `hive` connector catalog holding a view that Hive itself created, which the + * compose cluster does not configure and which no statement this provider can send will + * produce. The message it comes with is Trino's own `Failed to translate Hive view '%s': %s`, + * and that message is what the refusal part carries, untouched in whichever shape it arrives + * and unprefixed by any word of this product's own. + * + * THE NAME AND NOT THE MESSAGE IS WHAT THE BRANCH IS KEYED ON, and the first round of this + * work had it the other way round. A `startsWith` on the sentence bets on a wording that is + * demonstrably not uniform on this engine: of the failure replies captured verbatim from 476 + * in `tests/integration/db/trino-provider.test.ts`, `line 1:1: mismatched input 'SELEKT'.` + * and `line 1:1: Table 'memory.app.no_such_table' does not exist` carry the source location + * the analyzer attached, while `This connector does not support creating tables`, thrown by + * a connector rather than by the analyzer, is bare. Which shape a message takes is a property + * of where the throw came from, and for THIS branch, the one branch that cannot be reached on + * any cluster this repository can start, that property is unmeasurable. So a location prefix + * on the sentence would silently turn the declared refusal back into a raise, which is the + * exact case the branch exists for. + * + * The name is not unmeasurable. `errorName` is on the wire on every failed statement, + * {@link TrinoTransportError} already carries it as `code` (its own docblock calls it "the + * engine's stable fault name"), and it does not move when a release rewords a sentence. + * + * It is matched on the FAULT and not on the kind, because the fault is what identifies it: + * only a `view` can produce one today, but a provider that keyed the branch on the kind would + * have to be edited again the day another one can. + * + * Module-private: the only reader is the predicate below, and the tests reach the fault + * through the verbatim wire payload they serve rather than through this constant. + */ +const TRINO_HIVE_VIEW_TRANSLATION_FAULT = "HIVE_VIEW_TRANSLATION_ERROR"; + +/** + * The engine's own translation-failure sentence, or `undefined` for any other failure. + * + * Takes the transport's UNMAPPED error, because {@link TrinoTransportError.code} is the thing + * being read and `mapTrinoError` discards it: everything in the `engine` category becomes a + * bare `QueryError` carrying the message alone. + * + * A failure that is NOT this one is rethrown by the caller rather than dressed as a refusal: + * a missing object must RAISE (design guarantee 6), and turning every failure into an + * `unavailable` part would put "the definition cannot be read" in front of a user whose + * object simply is not there. + */ +export function trinoTranslationRefusal(error: unknown): string | undefined { + if (!(error instanceof TrinoTransportError)) return undefined; + return error.code === TRINO_HIVE_VIEW_TRANSLATION_FAULT ? error.message : undefined; +} + +/** + * Why a reply this provider received carries no definition. + * + * A DEVIATION from the "engine's own sentence, unprefixed" guarantee, declared rather than + * smuggled, and it is the same deviation `mssql` and `duckdb` already declared: the read + * SUCCEEDED, so the engine said nothing to carry. Measured on 476, no `SHOW CREATE` this + * provider sends answers a blank or a non-text value, so both arms exist for the release + * that changes that rather than for a shape seen here - and the two are kept apart because + * they are different facts about the cluster and only one of them is a text at all. + */ +export function trinoUnreadableSourceReason(statement: TrinoSourceStatement, name: string, value: unknown): string { + const where = `the "${statement.column}" column of SHOW CREATE ${statement.object} for ${name}`; + if (typeof value === "string") { + return `Trino answered ${where} with a text holding nothing but whitespace, and an empty definition is not a definition.`; + } + return `Trino answered ${where} as ${value === null ? "null" : typeof value} rather than as text.`; +} + +// ---------------------------------------------------------------------------- +// Overload resolution, which is a signature comparison and not a string compare +// ---------------------------------------------------------------------------- + +/** + * The text inside the first TOP-LEVEL parentheses, or `null` when the text has none. + * + * QUOTE AWARE, and that is not defensive. Measured on 476: a function whose name holds an + * open parenthesis renders its definition as + * `CREATE FUNCTION memory.app."we(ird"(x bigint)`, so the FIRST `(` in the string belongs to + * the NAME and a scan that took it would read `ird"(x bigint` as the parameter list. The + * fixture holds that function (`docker/trino-init/01-object-fixture.sql`) so this scan is + * driven by an object rather than by an argument. + * + * A `"` toggles the quoted state, which handles Trino's doubled-quote escape without a case + * of its own: `""` toggles twice and lands back where it started. + */ +function trinoParenthesisedList(text: string): string | null { + let quoted = false; + let depth = 0; + let start = -1; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (character === '"') { + quoted = !quoted; + continue; + } + if (quoted) continue; + if (character === "(") { + if (depth === 0) start = index + 1; + depth += 1; + continue; + } + if (character === ")") { + depth -= 1; + if (depth === 0) return text.slice(start, index); + } + } + return null; +} + +/** + * One comma-separated list split at its TOP LEVEL only. + * + * The nesting matters on both sides of the comparison below: measured on 476, one argument + * can read `decimal(10,2)` and another `row("a" bigint,"b" varchar)`, so a split on every + * comma would turn three arguments into six. + * + * THERE IS NO EMPTY-LIST ARM, and its absence is measured rather than an oversight. An early + * `list.trim() === "" -> []` was written here for the fixture's zero-argument `answer()`, and + * mutating it away left the whole suite at 137 pass 0 fail: an empty list answers `[""]` + * instead of `[]`, `[""]` normalises to the empty string, and so does `[]`, so the two are + * the same signature on BOTH sides of the comparison. A guard for a state that changes no + * answer is a covered line nothing executes (standing ruling 5b, #789), so it is gone. + */ +function trinoSplitTopLevel(list: string): string[] { + const parts: string[] = []; + let quoted = false; + let depth = 0; + let start = 0; + for (let index = 0; index < list.length; index += 1) { + const character = list[index]; + if (character === '"') quoted = !quoted; + else if (quoted) continue; + else if (character === "(") depth += 1; + else if (character === ")") depth -= 1; + else if (character === "," && depth === 0) { + parts.push(list.slice(start, index)); + start = index + 1; + } + } + parts.push(list.slice(start)); + return parts; +} + +/** + * One rendered PARAMETER as its TYPE alone, with the parameter name dropped. + * + * `SHOW CREATE FUNCTION` renders `amount decimal(10, 2)` where `SHOW FUNCTIONS` renders + * `decimal(10,2)`, so the two can only be compared once the name is gone. The name is + * everything up to the FIRST SPACE, and that single rule is enough because of what Trino 476 + * will not accept, measured on 2026-09-13 rather than assumed: + * + * - a name that NEEDS quoting is fine and renders quoted, `"order" bigint` for a reserved + * word, and the closing quote is followed by the space this scan stops at; + * - a name HOLDING a space is refused outright at creation: `CREATE FUNCTION + * memory.app.spaced("my arg" bigint) RETURNS bigint RETURN "my arg"` answers + * `Internal error`, and so does the same statement with a name holding a comma. + * + * So there is no name this rule mis-reads, and a quote-aware scan for the closing quote was + * written here first and then DELETED: it answered `bigint` for `"order" bigint` exactly as + * this line does, mutating it away left the whole suite at 137 pass 0 fail, and an arm no + * payload can reach is reported as covered while dead (standing ruling 5b, #789). + * + * `slice(space + 1)` and not a `space === -1` arm, for the same reason: an element with no + * space at all is not a `name type` pair, `indexOf` answers -1, and `slice(0)` hands back the + * whole text, which is the best reading available for something this shape. A branch would + * be one more line no statement can reach. + */ +function trinoParameterType(parameter: string): string { + const text = parameter.trim(); + return text.slice(text.indexOf(" ") + 1).trim(); +} + +/** + * One argument type list reduced to a form the engine's TWO renderings of it agree on. + * + * THIS IS THE MEASUREMENT THIS WHOLE FILE'S FUNCTION READ RESTS ON, and it refutes the + * obvious implementation. `SHOW CREATE FUNCTION` answers one row per overload and carries no + * `Argument Types` column of its own, so the row belonging to a path segment has to be found + * by comparing signatures - and the two renderings are NOT the same text. Measured on 476 + * for the fixture's `hard`: + * + * SHOW FUNCTIONS `Argument Types` decimal(10,2), array(varchar), row("a" bigint,"b" varchar) + * SHOW CREATE parameter list amount decimal(10, 2), tags array(varchar), r ROW(a bigint, b varchar) + * + * three differences in one signature: a space inside `decimal(10, 2)`, `ROW` in upper case + * against `row`, and field names quoted on one side and bare on the other. So the comparison + * is made on a form with the case, the whitespace and the quoting removed, which is what the + * two renderings do agree on. + * + * WHAT THAT COSTS, said plainly rather than left for a reader to find: removing the + * whitespace also removes the boundary between a ROW field's NAME and its TYPE, so + * `row(a bigint)` and a hypothetical `row(ab igint)` reduce to the same string. The second + * is not a type Trino will parse, so no pair of real signatures collides, but the form is + * lossy and this is where that is written down. + */ +function trinoNormalisedSignature(types: readonly string[]): string { + return types.map((type) => type.toLowerCase().replace(/[\s"]/g, "")).join(","); +} + +/** A `SHOW FUNCTIONS` `Argument Types` cell as the comparable signature above. */ +export function trinoArgumentSignature(argumentTypes: string): string { + return trinoNormalisedSignature(trinoSplitTopLevel(argumentTypes)); +} + +/** + * One `Create Function` statement as the comparable signature above, or `null` when its + * parameter list cannot be found at all. + */ +export function trinoCreateSignature(createStatement: unknown): string | null { + if (typeof createStatement !== "string") return null; + const list = trinoParenthesisedList(createStatement); + if (list === null) return null; + return trinoNormalisedSignature(trinoSplitTopLevel(list).map(trinoParameterType)); +} diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts index 686e4b6f..a67db058 100644 --- a/src/lib/db/types.ts +++ b/src/lib/db/types.ts @@ -726,6 +726,31 @@ export interface DatabaseProvider { */ describeObjects(container: readonly string[], kind: string, limit?: number): Promise; + /** + * The definition text of ONE object, as a document of named parts (#789 Phase 2). + * + * OPTIONAL, unlike the five object methods above, and the asymmetry is argued rather than + * inherited. Those five are required because a provider that does not implement them + * answers nothing at all about what a database holds. A provider that does not implement + * this one answers everything about what the database holds and simply declares no + * source-bearing kind, which is the TRUE and measured state of `druid` and `libredb`: + * neither has a kind with a definition text anywhere, so a required method would put an + * unreachable throw in each, which is precisely the shape that got the 501 deleted. + * + * `kind` is required for the reason `describeObject`'s is: measured on MySQL, MariaDB and + * DuckDB, one name addresses more than one object of different kinds in one container, so a + * path alone reads the wrong object. + * + * `limit` bounds ONE PART's character count. Absent means unbounded. A provider may apply a + * bound of its own, and must then set `truncated` on the part it bounded and never on a part + * it read whole. + * + * The declaration and the method cannot disagree: `assertObjectSurface` asserts, in BOTH + * directions, that a provider declares a kind with `hasSource` exactly when it implements + * this method. + */ + readObjectSource?(path: readonly string[], kind: string, limit?: number): Promise; + /** * Get health and performance metrics */ @@ -1329,3 +1354,94 @@ export interface ObjectDetailBatch { /** Absent when every object of that kind in that container was described. */ readonly truncated?: { readonly limit: number; readonly reason: string }; } + +/** + * What this text IS, so a reader is never shown a fragment that looks like a statement (#789). + * + * CLOSED: two arms, both with producers in the shipped fleet. `complete` runs as given; + * `partial` is a body or a bare SELECT that does not. PostgreSQL's `pg_get_viewdef`, DuckDB's + * `macro_definition` and Couchbase's `definition.text` are the measured `partial` producers. + */ +export type ObjectSourceForm = "complete" | "partial"; + +/** + * Where this text came from, so a reader is never shown a reconstruction as an original (#789). + * + * CLOSED: three arms, each with at least one producer. `stored` is the author's own bytes + * (SQL Server modules, SQLite's `sqlite_schema.sql`); `regenerated` is the engine rebuilding + * from its catalog, which PostgreSQL documents as "a decompiled reconstruction, not the + * original text of the command"; `rendered` is a structured definition this product prints as + * JSON (a MongoDB view, a search pipeline or template). + */ +export type ObjectSourceOrigin = "stored" | "regenerated" | "rendered"; + +/** + * One text belonging to one object, or the engine's own reason there is none (#789). + * + * A UNION and not one shape with an optional `text`, for the reason `KindCount` is a union: a + * refusal and an empty answer are different facts, and a shape carrying `text?: string` makes + * them the same value at every call site. The refused arm declares NO `text`, so a value + * narrowed to it cannot reach an editor buffer. That composition is what DBeaver gets wrong: + * measured in its source, an unreadable definition reaches a WRITABLE editor holding one + * comment line. + * + * The union closes that path in ONE DIRECTION ONLY, and saying so here is what stops the next + * implementer from trusting it for the other. MEASURED against tsc 6.0.3 with no cast + * anywhere: a literal carrying `unavailable` BESIDE `text`, `language`, `form` and `origin` + * COMPILES as an `ObjectSourcePart`, because TypeScript's excess-property check on a union + * admits any property declared on ANY member of it. Such a part narrows to the refusal arm, so + * a provider composing one (spreading a catalog row, or spreading a conditional + * `{unavailable}` onto a bounded text) would put a refusal sentence over a definition the + * engine really returned. `assertObjectSurface` refuses that part by name for our own + * providers, and that is the ONLY refusal standing today. A HOST's answer is unguarded: the + * embedded seam's runtime shape check, the one `isRenderableShape` in + * `src/components/object-tree/use-tree-nodes.ts` is the precedent for, is later work in #789 + * Phase 2 and does not exist in this tree. + * + * `id` is provider-local. Core reads it as an identity WITHIN ONE DOCUMENT and for nothing + * else: the part switcher's selection key, and the Source tab's remembered selection. Core + * never compares it against a literal, never branches on it, and never carries it between two + * documents. + * + * `text` is never empty and never whitespace only. TypeScript cannot express that, so it is a + * runtime invariant, asserted in `assertObjectSurface` for our own providers and, for a host's + * answer, by the same shape check that does not exist yet. Where an engine answers empty, the + * provider emits a REFUSAL carrying the engine's own fact instead. + */ +export type ObjectSourcePart = + | { + readonly id: string; + /** The engine's own word: "Package body", "Specification". Rendered as-is. */ + readonly label: string; + readonly text: string; + /** A Monaco language id the installed bundle registers. `plsql`, `tsql` and `cql` are not. */ + readonly language: string; + readonly form: ObjectSourceForm; + readonly origin: ObjectSourceOrigin; + readonly truncated?: { readonly limit: number; readonly reason: string }; + } + | { + readonly id: string; + readonly label: string; + /** The engine's own sentence, unprefixed, never a rewrite of it. */ + readonly unavailable: string; + }; + +/** + * One object's definition, as its provider reads it (#789). + * + * `parts` is a NON-EMPTY tuple, which makes a zero-part document a compile error at every + * provider: there is no shape in which the renderer is handed a document and has nothing to + * draw. Two spellings satisfy it and no third is accepted: an array literal, and + * `const parts: [ObjectSourcePart, ...ObjectSourcePart[]] = [first]` plus a conditional push. + * `rows.map(...)` does not, and casting past it defeats the whole invariant. + * + * More than one part is not a special case for one engine: an Oracle package and a MariaDB + * package are each ONE node over two texts, and core branches on `parts.length` and on nothing + * else. + */ +export interface ObjectSourceDocument { + readonly path: readonly string[]; + readonly kind: string; + readonly parts: readonly [ObjectSourcePart, ...ObjectSourcePart[]]; +} diff --git a/src/lib/editor/monaco-theme.ts b/src/lib/editor/monaco-theme.ts new file mode 100644 index 00000000..65cfd6d6 --- /dev/null +++ b/src/lib/editor/monaco-theme.ts @@ -0,0 +1,91 @@ +import type * as Monaco from "monaco-editor"; + +/** + * The two editor themes, with ONE definition each, shared by every Monaco mount in the app. + * + * They used to be defined inside `QueryEditor`'s `beforeMount`, which is per-mount state: + * `beforeMount` runs for the mount that declares it and for no other, so a second mount that + * does not run that exact callback gets Monaco's stock `vs`/`vs-dark` and sits visibly beside + * a query editor it does not match. The read-only object source viewer (#789) is that second + * mount, and Phase 3's diff preview would be a third. Both import this module and hand it + * their own `monaco` instance. + * + * `editor.defineTheme` registers on the Monaco INSTANCE, not on the mount, and monaco-editor + * 0.56.0 documents it as "Define a new theme or update an existing theme" + * (`monaco-editor/esm/vs/editor/editor.api.d.ts:1124`), so calling this from every mount's + * `beforeMount` rewrites the same two entries with the same payload rather than accumulating + * per-mount state. + */ + +/** Theme id applied whenever the effective app theme is anything but light. */ +export const STUDIO_THEME_DARK = "db-dark"; + +/** Theme id applied when the effective app theme is light. */ +export const STUDIO_THEME_LIGHT = "db-light"; + +/** + * Registers `db-dark` and `db-light` on the Monaco instance handed in. Call it from a mount's + * `beforeMount`, which is the last point before Monaco paints and the first at which an + * instance exists. + */ +export function defineStudioThemes(monacoInstance: typeof Monaco): void { + monacoInstance.editor.defineTheme(STUDIO_THEME_DARK, { + base: "vs-dark", + inherit: true, + rules: [ + { token: "keyword", foreground: "569cd6", fontStyle: "bold" }, + { token: "function", foreground: "dcdcaa" }, + { token: "string", foreground: "ce9178" }, + { token: "number", foreground: "b5cea8" }, + { token: "comment", foreground: "6a9955" }, + { token: "operator", foreground: "d4d4d4" }, + { token: "identifier", foreground: "9cdcfe" }, + ], + colors: { + "editor.background": "#050505", + "editor.foreground": "#d4d4d4", + "editorCursor.foreground": "#569cd6", + "editor.lineHighlightBackground": "#111111", + "editorLineNumber.foreground": "#333333", + "editorLineNumber.activeForeground": "#666666", + "editor.selectionBackground": "#264f78", + "editor.inactiveSelectionBackground": "#3a3d41", + "editorIndentGuide.background": "#1a1a1a", + "editorIndentGuide.activeBackground": "#333333", + }, + }); + + /* + * Monaco paints its own canvas and knows nothing about the CSS token layer, + * so the editor is the one surface that needs the palette written twice. + * Same syntax hues either side, because they are chosen for contrast against the + * CODE rather than against the chrome, with only the ground and the guides moved. + * `editor.background` mirrors `--studio-canvas` in both themes so the pane + * sits flush with the shell it lives in. + */ + monacoInstance.editor.defineTheme(STUDIO_THEME_LIGHT, { + base: "vs", + inherit: true, + rules: [ + { token: "keyword", foreground: "0000ff", fontStyle: "bold" }, + { token: "function", foreground: "795e26" }, + { token: "string", foreground: "a31515" }, + { token: "number", foreground: "098658" }, + { token: "comment", foreground: "008000" }, + { token: "operator", foreground: "3f3f46" }, + { token: "identifier", foreground: "001080" }, + ], + colors: { + "editor.background": "#f4f4f5", + "editor.foreground": "#27272a", + "editorCursor.foreground": "#0000ff", + "editor.lineHighlightBackground": "#e4e4e7", + "editorLineNumber.foreground": "#a1a1aa", + "editorLineNumber.activeForeground": "#52525b", + "editor.selectionBackground": "#add6ff", + "editor.inactiveSelectionBackground": "#e5ebf1", + "editorIndentGuide.background": "#e4e4e7", + "editorIndentGuide.activeBackground": "#a1a1aa", + }, + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index a4aeba0a..9d2c6fba 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -15,6 +15,7 @@ no module graph is created by it. */ import type { StoredObject } from "@/lib/db/detailed-object"; +import type { ObjectSourceDocument } from "@/lib/db/types"; export type DatabaseType = | "postgres" @@ -343,6 +344,33 @@ export interface QueryResult { columnTypes?: Record; } +/** + * A Source tab's whole state: an ADDRESS, what has been read against it, and which part is + * shown (#789 Phase 2). + * + * No connection id, deliberately. Tabs are already scoped per connection by the persistence + * key `libredb_workspace_tabs_v1:${connection.id}`, and the shell renders the active + * connection beside the active tab, so an id here would be a third copy of a fact two places + * already hold and the three could disagree. + * + * The ADDRESS is the only half that is persisted, and `PersistedTabState` in + * `src/hooks/use-tab-manager.ts` is where that is enforced and argued. A restored Source tab + * therefore carries `path` and `kind` alone and RE-READS, which is also why every other field + * here is optional: absent is the state a freshly opened and a freshly restored tab share, and + * it is what tells the viewer to issue a read. + */ +export interface SourceTabState { + readonly path: readonly string[]; + readonly kind: string; + /** Absent while loading and after a failed read. Never persisted: see `PersistedTabState`. */ + readonly document?: ObjectSourceDocument; + /** The route's own sentence. */ + readonly failure?: string; + readonly activePartId?: string; + /** The catalog-change counter's value when this document was read. */ + readonly readAtToken?: number; +} + export interface QueryTab { id: string; name: string; @@ -356,6 +384,22 @@ export interface QueryTab { currentOffset?: number; isLoadingMore?: boolean; allRows?: Record[]; + /** + * Present exactly on a Source tab (#789 Phase 2). + * + * An optional FIELD and deliberately not a fifth member of `type`. Every member of that + * union is a QUERY DIALECT that `resolveTabType` may answer and that + * `editorLanguageForTabType` maps onto `QueryEditor`'s closed language union, so a + * `"source"` member would be an arm the resolver can never produce and the language mapper + * would have to answer for, and it would put the per-object language decision back into the + * two functions `CLAUDE.md` keeps it out of. The definition's own Monaco language travels on + * the PART instead, which is where the provider put it. + * + * A Source tab therefore still carries a `type`, and it is the neutral default: nothing + * reads it, because both surfaces that would branch on it, the tab bar's icon and the editor + * pane, branch on the presence of this field first. + */ + source?: SourceTabState; } export interface QueryHistoryItem { diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx index 194b7e93..6b72d885 100644 --- a/src/workspace/StudioWorkspace.tsx +++ b/src/workspace/StudioWorkspace.tsx @@ -5,6 +5,7 @@ import type { CsvDelimiter } from "@/lib/export/csv"; import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { Sidebar } from "@/components/sidebar"; import { type TreeRowActionHandlers } from "@/components/object-tree"; +import { ObjectSourceView, type ObjectSourcePatch } from "@/components/object-source"; import { objectAtPath } from "@/lib/db/detailed-object"; // MobileNav and mobile tab panels excluded in embedded mode — platform provides its own navigation import { QueryEditor, QueryEditorRef } from "@/components/QueryEditor"; @@ -17,7 +18,8 @@ import { SaveQueryModal } from "@/components/SaveQueryModal"; import { StudioTabBar, QueryToolbar, BottomPanel } from "@/components/studio/index"; import type { MaskingConfig } from "@/lib/data-masking"; import type { DatabaseObject } from "@/lib/db/types"; -import { relationKindIds } from "@/lib/db/object-kinds"; +import { findKind, kindHasSource, relationKindIds } from "@/lib/db/object-kinds"; +import { objectPathLabel } from "@/lib/db/object-path"; import { useToast } from "@/hooks/use-toast"; import { useTabManager } from "@/hooks/use-tab-manager"; import { useConnectionAdapter } from "@/workspace/hooks/use-connection-adapter"; @@ -304,10 +306,126 @@ export function StudioWorkspace({ const onObjectClick = useCallback( (object: DatabaseObject) => { const capabilities = conn.metadata?.capabilities; - if (capabilities === undefined || !relationKindIds(capabilities).includes(object.kind)) return; - onTableClick(object.path); + if (capabilities === undefined) return; + if (relationKindIds(capabilities).includes(object.kind)) { + onTableClick(object.path); + return; + } + /* + * A NON-RELATION row whose kind declares source opens its Source tab (#789 Phase 2). + * + * The same two-branch shape `src/components/Studio.tsx` writes, with ONE conjunct this + * shell adds: the host must have declared a source read. Without one the viewer would + * fall through to its default, which is this application's own route, and this package + * ships none - which is B76 exactly, an action that cannot succeed on any connection. + * So a host that implements nothing keeps the Phase 1 behaviour, where activating a + * routine row does nothing at all. + * + * The gate is the DECLARATION and never the kind id, exactly as the branch above is. + */ + if (conn.sourceReader !== undefined && kindHasSource(capabilities, object.kind)) { + tabMgr.openSourceTab(object); + } + }, + [conn.metadata, conn.sourceReader, onTableClick, tabMgr], + ); + + /** + * The active tab's source address, whether or not this shell can still READ one (#789). + * + * THIS OVERTURNS THE FIRST ANSWER, which conjoined `conn.sourceReader !== undefined` and read + * the tab as an ORDINARY one when the host stopped declaring `readObjectSource`. Half of that + * reasoning was right and is kept: the viewer's own default reader posts to + * `/api/db/objects/source`, this package ships no routes, so the pane must never fall through + * to it. What it got wrong is what a person then SAW. A Source tab's text is never persisted, + * only its address, so the tab came back named `Source: app.order_total(integer)` holding an + * EMPTY, EDITABLE editor with a live Run button, which is the empty-editor hazard this whole + * phase exists to prevent: an empty editor reads as "there is no source", and a user who types + * over it deletes the object. + * + * So the address stands on its own and the pane stays a pane. Every consumer below is still + * consistent, because they all read THIS value: no Run button, no toolbar and no statement + * loader on a Source tab, whatever the host currently declares. What the host's absence + * changes is the one thing it really means, which is that there is nothing to read with, and + * `sourceFailure` below says so in the viewer's own grammar. + */ + const sourceTab = tabMgr.currentTab.source; + + /** + * What the pane shows when the host has stopped reading definitions (#789). + * + * The tab's OWN failure first, because it is the engine's or the host's sentence about a read + * that really happened, and ours would overwrite a fact with a circumstance. + * + * Ours only when there is NOTHING TO SHOW, which is also what makes this load-bearing rather + * than cosmetic: with no document, no failure and no reader, the viewer would issue its read + * through `httpSourceReader` and ask a route that does not exist in this package. A failure + * makes `needsRead` false, so no read is issued at all and the pane refuses instead. + * + * A definition ALREADY IN HAND is left on screen. It was read from the engine a moment ago and + * a host handing a new reader object on a render is not a reason to throw a real definition + * away; what it must not become is an editor with a Run button, and it does not. + * + * THE ONE PATH THIS DOES NOT COVER, named here because a later change would open it: a tab + * holding a document whose state is CLEARED while the host declares no reader would issue the + * read. The only control that clears one is the viewer's stale banner, and this shell passes a + * hardcoded `refreshToken={0}` below, so nothing here is ever marked stale and the banner is + * never drawn. A shell that starts counting DDL has to hand this conjunction a reader that + * refuses, or the read goes to a route this package does not ship. + */ + const sourceFailure = + sourceTab?.failure ?? + (conn.sourceReader === undefined && sourceTab?.document === undefined + ? "This host no longer reads object definitions, so this definition cannot be read here." + : undefined); + + /** + * What every statement entry point OUTSIDE the editor pane is handed while a Source tab is + * active (#789 Phase 2). + * + * The pane below branches around the toolbar AND the editor together, so a Source tab draws + * no Run button. That covers the editor and nothing else, and this shell has exactly one + * other entry point in the class, which is an entry point that reads or WRITES the active + * tab's statement: `BottomPanel`'s `onLoadQuery`, which is rendered outside that branch and + * is wired inside the panel to both `QueryHistory`'s and `SavedQueries`' `onSelectQuery`. + * Without this, opening History over a Source tab and clicking a past query wrote a statement + * onto a tab whose pane shows a read-only definition, and `use-tab-manager` persists it. + * + * `DataImportModal` and `TestDataGenerator` are deliberately NOT gated, on the same reasoning + * the standalone shell states: they call `executeQuery(sql)` with THEIR OWN statement aimed at + * an object the reader picked, an override never writes the tab's `query`, and the result + * lands in the panel below the definition. + */ + const runsTheActiveTab = sourceTab === undefined; + + const { setTabs, activeTabId } = tabMgr; + /** + * What the source viewer writes back onto the tab it is mounted in (#789 Phase 2). + * + * MERGED BY SPREAD, so an explicitly-undefined key in the patch is a CLEAR rather than a + * no-op, which is how the stale banner's re-read control puts the tab back into the state the + * viewer reads from. STABLE across renders, because the viewer's read effect lists it among + * its dependencies. Addressed by tab ID and not by `currentTab`, because an answer can land + * after the reader has switched tabs and the patch belongs to the tab that asked for it. + * + * MEASURED, because a mutation asked the question: the tab this addresses is always the tab + * that ASKED, even for an answer that lands after a switch, since the viewer holds the + * `onChange` it was handed when it issued the read and that closure captured the then-active + * id. So the `tab.source !== undefined` half is a shape guard the shell cannot currently make + * false, and deleting it fails no test. It is kept rather than trimmed because it is what + * makes the spread safe if a later patch ever reaches a tab that is not a Source tab, and + * because `src/components/Studio.tsx` writes this identically: one writer shape across both + * shells is worth more than one conjunct removed from one of them (#789). + */ + const onSourceChange = useCallback( + (patch: ObjectSourcePatch) => { + setTabs((previous) => + previous.map((tab) => + tab.id === activeTabId && tab.source !== undefined ? { ...tab, source: { ...tab.source, ...patch } } : tab, + ), + ); }, - [conn.metadata, onTableClick], + [setTabs, activeTabId], ); /** @@ -336,6 +454,13 @@ export function StudioWorkspace({ onProfileObject: features.codeGenerator ? (object) => setProfilerPath(object.path) : undefined, onGenerateCode: features.codeGenerator ? (object) => setCodeGenPath(object.path) : undefined, onGenerateTestData: features.testDataGenerator ? (object) => setTestDataPath(object.path) : undefined, + /* + * Passed ONLY where the host declared a source read (#789 Phase 2). An absent handler is an + * item the tree does not draw, which is the rule the two handlers above this file already + * withholds follow, and here it is what keeps a host that implements nothing from being + * offered an action no route in this package can serve. + */ + onViewSource: conn.sourceReader === undefined ? undefined : (object) => tabMgr.openSourceTab(object), }; // === No-op callbacks for disabled features === @@ -432,47 +557,109 @@ export function StudioWorkspace({
- setIsSaveQueryModalOpen(true) : undefined} - onExecuteQuery={() => queryExec.executeQuery()} - onCancelQuery={queryExec.cancelQuery} - // Withheld, not `noop`: this shell runs no transaction, - // no sandbox and no inline editing — `transactionActive` - // and `editingEnabled` are hardcoded false above and - // nothing here can change them. While it passed - // `metadata={null}` the group never rendered and `noop` - // was invisible; passing the host's real metadata (#427) - // would have put three dead buttons on any host that - // declares `queryLanguage: "sql"`, with no disabled state - // and no tooltip. A withheld callback hides its control. - onBeginTransaction={undefined} - onCommitTransaction={undefined} - onRollbackTransaction={undefined} - onTogglePlayground={undefined} - onToggleEditing={undefined} - onImport={features.dataImport ? () => setIsImportModalOpen(true) : undefined} - /> - -
- tabMgr.updateTabById(tabMgr.currentTab.id, { query: val })} - language={editorLanguageForTabType(tabMgr.currentTab.type)} - databaseType={conn.activeConnection?.type} - schemaContext={conn.schemaContext} - capabilities={conn.metadata?.capabilities} - /> -
+ {/* + One branch around the toolbar AND the editor together, so a Source tab + shows no Run button rather than a disabled one: there is nothing on a + definition to run, and a control that is present and refuses is a worse + answer than a control that is not there (#789 Phase 2). + + THE CONNECTION IS NO LONGER PART OF THIS BRANCH, and that conjunct was + the third door onto the same hazard (#789 fix round 1). It read + `|| conn.activeConnection === null`, on a docblock arguing the state + was admitted by the type and not reached by the product. It is + reached: `use-connection-adapter.ts` auto-selects whenever the host's + list is non-empty, so a null active connection is exactly "the host + handed an empty connections array", which a host does when a person + deletes the last connection in its own UI while a Source tab is open. + The tab then came back labelled `Source: ` over an EMPTY, + EDITABLE buffer with a live Run button. The viewer now takes a + nullable connection and refuses in its own grammar, so the pane stays + a pane and no read is issued for a connection that is gone. + */} + {sourceTab === undefined ? ( + <> + setIsSaveQueryModalOpen(true) : undefined} + onExecuteQuery={() => queryExec.executeQuery()} + onCancelQuery={queryExec.cancelQuery} + // Withheld, not `noop`: this shell runs no transaction, + // no sandbox and no inline editing, so `transactionActive` + // and `editingEnabled` are hardcoded false above and + // nothing here can change them. While it passed + // `metadata={null}` the group never rendered and `noop` + // was invisible; passing the host's real metadata (#427) + // would have put three dead buttons on any host that + // declares `queryLanguage: "sql"`, with no disabled state + // and no tooltip. A withheld callback hides its control. + onBeginTransaction={undefined} + onCommitTransaction={undefined} + onRollbackTransaction={undefined} + onTogglePlayground={undefined} + onToggleEditing={undefined} + onImport={features.dataImport ? () => setIsImportModalOpen(true) : undefined} + /> + +
+ tabMgr.updateTabById(tabMgr.currentTab.id, { query: val })} + language={editorLanguageForTabType(tabMgr.currentTab.type)} + databaseType={conn.activeConnection?.type} + schemaContext={conn.schemaContext} + capabilities={conn.metadata?.capabilities} + /> +
+ + ) : ( +
+ +
+ )}
@@ -496,7 +683,10 @@ export function StudioWorkspace({ onCellChange={noop as never} onApplyChanges={noop} onDiscardChanges={noop} - onLoadQuery={(q) => tabMgr.updateCurrentTab({ query: q })} + onLoadQuery={(q) => { + if (!runsTheActiveTab) return; + tabMgr.updateCurrentTab({ query: q }); + }} onLoadMore={ tabMgr.currentTab.result?.pagination?.hasMore ? queryExec.handleLoadMore : undefined } diff --git a/src/workspace/hooks/use-connection-adapter.ts b/src/workspace/hooks/use-connection-adapter.ts index d0468eba..d2a7d7a7 100644 --- a/src/workspace/hooks/use-connection-adapter.ts +++ b/src/workspace/hooks/use-connection-adapter.ts @@ -5,6 +5,7 @@ import type { DatabaseConnection } from "@/lib/types"; import type { DetailedObject } from "@/lib/db/detailed-object"; import type { ProviderMetadata } from "@/hooks/use-provider-metadata"; import type { ObjectSource } from "@/components/object-tree"; +import type { ObjectSourceReader } from "@/components/object-source"; import { useReadGeneration } from "@/hooks/use-read-generation"; import type { WorkspaceConnection, WorkspaceObjectReader } from "@/workspace/types"; @@ -169,6 +170,42 @@ export function useConnectionAdapter({ [onObjectsFetch], ); + /** + * The source read's own seam, which is not a tree read (#789 Phase 2). + * + * Beside `objectSource` and deliberately not a fourth arm inside it. That switch is + * exhaustive with no `default`, so a fourth arm would have to produce a value for a host that + * declared no `readObjectSource`, which is a state the seam does not reach at all: this value + * is `undefined` in exactly that case, and an absent reader is what removes the affordance. + * The tree's request union is also the tree CACHE's vocabulary, and a definition is not a + * cached listing. + * + * `undefined` when the host declared nothing, which is the B76 answer rather than an errored + * read: no reader, so no `onViewSource`, so no menu item, so no tab, so nothing to fail. + * + * `useMemo` for the same reason `objectSource` is one: the viewer's read effect lists its + * reader among its dependencies, so a value rebuilt on every render would re-run it, and the + * connection arrives as an ARGUMENT rather than through this closure so the identity does not + * move when the selection does. + * + * `async` IS LOAD-BEARING and it is the whole host-trust guard on this seam. A host is + * ordinary JavaScript, so the declared `Promise` is not a runtime + * guarantee, and the viewer's read effect does `reader(...).then(...)` with no `try`. Measured + * on the plain-arrow form: a host that threw before returning gave an uncaught `Error` out of + * `commitHookEffectListMount`, and a host that returned `undefined` gave + * `TypeError: undefined is not an object (evaluating '...then')` at the same place. Both are + * render-phase throws, so they take the adopter's whole page down rather than one tab. The + * `async` wrapper turns the first into a rejection the viewer's error arm already renders with + * the host's own sentence, and the second into a resolved non-document the viewer's shape + * check already refuses. Two tests in + * `tests/components/studio/embedded-source.test.tsx` drive exactly these two shapes. + */ + const sourceReader = useMemo(() => { + const read = onObjectsFetch.readObjectSource; + if (read === undefined) return undefined; + return async (conn, path, kind) => read(conn.id, path, kind); + }, [onObjectsFetch]); + const schemaContext = useMemo(() => JSON.stringify(schema), [schema]); // The embedded shell's stand-in for `useProviderMetadata`: it has no @@ -202,6 +239,8 @@ export function useConnectionAdapter({ objectScanDeferred: activeConnection !== null && scanDeferred(activeConnection), loadObjects, objectSource, + /** The host's own source read, or `undefined` where it declared none. */ + sourceReader, schemaContext, }; } diff --git a/src/workspace/types.ts b/src/workspace/types.ts index 0093016c..09817009 100644 --- a/src/workspace/types.ts +++ b/src/workspace/types.ts @@ -1,7 +1,14 @@ // src/workspace/types.ts import type { DatabaseType, SavedQuery, QueryWarning } from "@/lib/types"; import type { DetailedObject } from "@/lib/db/detailed-object"; -import type { Container, DatabaseObject, KindCount, ProviderCapabilities, ProviderLabels } from "@/lib/db/types"; +import type { + Container, + DatabaseObject, + KindCount, + ObjectSourceDocument, + ProviderCapabilities, + ProviderLabels, +} from "@/lib/db/types"; // === Connection (platform → studio) === @@ -86,6 +93,34 @@ export interface WorkspaceObjectReader { countObjects(connectionId: string, container: readonly string[]): Promise>; /** The objects of one container and one kind, which is one opened folder. */ listObjects(connectionId: string, container: readonly string[], kind: string): Promise; + /** + * One object's definition text, if this host can read one (#789 Phase 2). + * + * OPTIONAL, and the absence is the documented shape of a thing a shell cannot do rather than + * an error: when it is missing the workspace passes no `onViewSource`, so the tree offers no + * action, activation opens no tab, and nothing can fail. An adopter who does nothing sees the + * tree exactly as it is today; an adopter who implements one method gets the feature. + * + * That distinction is what keeps this from repeating B76, whose regression was a surface that + * ERRORED on every connection: the tree self-fetched `/api/db/objects/*` through a payload + * this shell cannot fill, and every engine refused it by name. An absent affordance is not a + * regression; a read that cannot succeed is. + * + * A host implementing this owes the same guarantees a provider does, because nothing + * type-checks a host and the provider conformance helper never runs against one: at least one + * part, never a part carrying both a text and a refusal, never an empty text, never an empty + * refusal sentence, unique part ids, a Monaco language id the editor registers, and a RAISE + * rather than a document for an object it cannot find. What the workspace does NOT trust is + * checked at the seam: a document failing that check is reported as a failed read, with the + * viewer's own sentence, and never rendered. + * + * The declared return type is not a runtime guarantee either, and the seam does not assume it + * is. A method that THROWS before returning, or that returns anything which is not a thenable, + * is turned into a failed read by the adapter rather than into a render-phase throw: measured + * on the first form of this seam, both took the whole embedded workspace down instead of one + * tab. See `sourceReader` in `src/workspace/hooks/use-connection-adapter.ts`. + */ + readObjectSource?(connectionId: string, path: readonly string[], kind: string): Promise; } // === User (platform → studio) === diff --git a/tests/api/db-objects.test.ts b/tests/api/db-objects.test.ts index 9ac08f03..826174fe 100644 --- a/tests/api/db-objects.test.ts +++ b/tests/api/db-objects.test.ts @@ -1,8 +1,15 @@ import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { existsSync, readdirSync } from "node:fs"; +import path from "node:path"; import { createMockRequest, parseResponseJSON } from "../helpers/mock-next"; import { createMockProvider } from "../helpers/mock-provider"; import { clearRateLimitState } from "@/lib/api/rate-limit"; import { INVENTORY_LIMIT, INVENTORY_PAIR_LIMIT } from "@/lib/api/object-route"; +import { SOURCE_CHARACTER_LIMIT, SOURCE_PART_LIMIT, sourceBoundTruncationReason } from "@/lib/db/object-kinds"; +// The CLIENT's shape check, imported into the route's own suite on purpose: the route carries a +// refusal sentence through untouched and the client refuses that document, and one test pinning +// both ends is the only thing that keeps the pair from drifting into a contradiction (#789). +import { isSourceDocumentShape } from "@/components/object-source/source-reader"; import { ApiErrorCode } from "@/lib/api/error-codes"; import { QueryError } from "@/lib/db/errors"; import type { @@ -14,6 +21,8 @@ import type { KindCount, ObjectDetail, ObjectKindSpec, + ObjectSourceDocument, + ObjectSourcePart, } from "@/lib/db/types"; import { DatabaseError, @@ -100,6 +109,23 @@ const listRoute = await import("@/app/api/db/objects/list/route"); const describeRoute = await import("@/app/api/db/objects/describe/route"); const searchRoute = await import("@/app/api/db/objects/search/route"); const inventoryRoute = await import("@/app/api/db/objects/inventory/route"); +const sourceRoute = await import("@/app/api/db/objects/source/route"); + +/** + * The seven handlers KEYED BY THE DIRECTORY each one lives in, so the census can be checked + * against `src/app/api/db/objects/` rather than against itself. + */ +const objectRoutes: Record Promise }> = { + containers: containersRoute, + counts: countsRoute, + list: listRoute, + describe: describeRoute, + search: searchRoute, + inventory: inventoryRoute, + source: sourceRoute, +}; + +const OBJECT_ROUTE_DIR = path.resolve(import.meta.dir, "../..", "src/app/api/db/objects"); // ============================================================================ // Fixtures @@ -122,6 +148,7 @@ interface ProviderShape { listObjects?: DatabaseProvider["listObjects"]; describeObject?: DatabaseProvider["describeObject"]; describeObjects?: DatabaseProvider["describeObjects"]; + readObjectSource?: DatabaseProvider["readObjectSource"]; } /** A provider that declares one schema level and two kinds unless the test says otherwise. */ @@ -138,6 +165,7 @@ function objectProvider(shape: ProviderShape = {}): DatabaseProvider { if (shape.listObjects) provider.listObjects = shape.listObjects; if (shape.describeObject) provider.describeObject = shape.describeObject; if (shape.describeObjects) provider.describeObjects = shape.describeObjects; + if (shape.readObjectSource) provider.readObjectSource = shape.readObjectSource; return provider; } @@ -178,11 +206,30 @@ describe("the shared guard", () => { expect(mockGetOrCreateProvider).toHaveBeenCalledTimes(0); }); - test("every one of the six routes refuses an unauthenticated caller", async () => { - const routes = [containersRoute, countsRoute, listRoute, describeRoute, searchRoute, inventoryRoute]; - for (const route of routes) { + test("every route directory under api/db/objects is censused, and every one refuses an unauthenticated caller", async () => { + // Derived from the DIRECTORY rather than from the array literal this replaced. That literal + // certified only what it happened to hold: deleting one handler from it left this file at + // 71 pass, 0 fail with the test still naming a count, so an eighth route could have landed + // uncensused. The population is now the filesystem, and a route directory with no handler + // here fails by name (#789). + const directories = readdirSync(OBJECT_ROUTE_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && existsSync(path.join(OBJECT_ROUTE_DIR, entry.name, "route.ts"))) + .map((entry) => entry.name) + .sort(); + + // The zero-iteration case certifies nothing, so it is refused BY NAME: a readdir that found + // no route directory would otherwise run the loop below zero times and pass. + if (directories.length === 0) { + throw new Error(`no route.ts found under ${OBJECT_ROUTE_DIR}, so this census would certify nothing`); + } + expect(Object.keys(objectRoutes).sort()).toEqual(directories); + + for (const name of directories) { + if (!Object.hasOwn(objectRoutes, name)) { + throw new Error(`this census holds no handler for src/app/api/db/objects/${name}/route.ts`); + } mockGetSession.mockResolvedValueOnce(null as unknown as { role: string; username: string }); - const response = await route.POST( + const response = await objectRoutes[name].POST( new Request("http://localhost:3000/api/db/objects/x", { method: "POST" }) as never, ); expect(response.status).toBe(401); @@ -1202,3 +1249,473 @@ describe("POST /api/db/objects/inventory", () => { expect("defaultContainer" in body).toBe(false); }); }); + +// ============================================================================ +// source +// ============================================================================ + +describe("POST /api/db/objects/source", () => { + const FUNCTION_KIND: ObjectKindSpec = { + id: "function", + role: "routine", + label: "Function", + labelPlural: "Functions", + hasSource: true, + sourceLanguage: "sql", + }; + + function readablePart(overrides: Partial> = {}): ObjectSourcePart { + return { + id: "body", + label: "Body", + text: "CREATE FUNCTION order_total(integer) RETURNS integer AS $$ SELECT 1 $$ LANGUAGE sql", + language: "sql", + form: "complete", + origin: "regenerated", + ...overrides, + }; + } + + /** + * A provider that answers only the kind it declares and raises its OWN error for anything else. + * + * The raise is what makes the gate's two conjuncts distinguishable. A double that answered any + * kind would turn the undeclared-kind test into a 200-versus-400 comparison, and deleting the + * declaration conjunct would then be caught by the status alone. Raising a `QueryError` is what + * a real provider does when asked for an object it cannot read, and `createErrorResponse` maps + * it to the same 400 the gate uses, so only the SENTENCE separates the two. + */ + function sourceProviderReading(document: ObjectSourceDocument): DatabaseProvider { + return objectProvider({ + objectKinds: [TABLE_KIND, FUNCTION_KIND], + readObjectSource: mock(async (path: readonly string[], kind: string) => { + if (kind !== "function") { + throw new QueryError(`the engine has no readable ${kind} at ${path.join(".")}`, "postgres"); + } + return document; + }), + }); + } + + function documentWithOnePart(part: ObjectSourcePart = readablePart()): ObjectSourceDocument { + return { path: ["app", "order_total(integer)"], kind: "function", parts: [part] }; + } + + test("answers the provider's document for a source-bearing kind, bounded by the route", async () => { + const read = mock(async () => documentWithOnePart()); + activeProvider = objectProvider({ objectKinds: [TABLE_KIND, FUNCTION_KIND], readObjectSource: read }); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "order_total(integer)"], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(200); + const body = await parseResponseJSON(response); + expect(body).toEqual(documentWithOnePart()); + // The route names its own bound rather than leaving `limit` absent, which is what makes a + // provider that honours the argument bound the same way the route would have bounded it. + expect(read).toHaveBeenCalledWith(["app", "order_total(integer)"], "function", SOURCE_CHARACTER_LIMIT); + }); + + test("calls the reader with the provider as its receiver, so a method reading `this` still works", async () => { + // Every shipped provider reads `this` in this method: it is where the pool, the config and the + // escaper live. The reader is taken off the instance as a VALUE here, and a value called with + // no receiver has `this === undefined` under a module's strict mode, so the label below is + // read from the receiver rather than closed over. Unbound, this double raises a TypeError + // instead of answering, which is the difference a 200 and a label can see. + activeProvider = objectProvider({ + objectKinds: [FUNCTION_KIND], + readObjectSource: mock(async function (this: DatabaseProvider) { + return documentWithOnePart(readablePart({ label: this.type })); + }), + }); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(200); + const [part] = (await parseResponseJSON(response)).parts; + expect(part.label).toBe("postgres"); + }); + + test("refuses a kind that declares no source, naming the engine and the kind", async () => { + activeProvider = sourceProviderReading(documentWithOnePart()); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "orders"], kind: "table" }, + }) as never, + ); + + expect(response.status).toBe(400); + // The SENTENCE and not the status: the double raises its own error for this kind, and that + // error also maps to 400, so a status-only assertion survives deleting the declaration + // conjunct of the gate. + expect((await parseResponseJSON<{ error: string }>(response)).error).toBe( + 'postgres declares no readable source for kind "table"', + ); + }); + + test("refuses a provider that declares hasSource and implements no method, with the same sentence", async () => { + activeProvider = objectProvider({ objectKinds: [TABLE_KIND, FUNCTION_KIND] }); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(400); + expect((await parseResponseJSON<{ error: string }>(response)).error).toBe( + 'postgres declares no readable source for kind "function"', + ); + }); + + test("refuses a kind the engine does not declare at all", async () => { + activeProvider = sourceProviderReading(documentWithOnePart()); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "procedure" }, + }) as never, + ); + + expect(response.status).toBe(400); + expect((await parseResponseJSON<{ error: string }>(response)).error).toBe( + 'postgres declares no readable source for kind "procedure"', + ); + }); + + test("refuses an empty path", async () => { + activeProvider = sourceProviderReading(documentWithOnePart()); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: [], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(400); + expect((await parseResponseJSON<{ error: string }>(response)).error).toContain("must name an object"); + }); + + test("refuses a missing kind", async () => { + activeProvider = sourceProviderReading(documentWithOnePart()); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"] }, + }) as never, + ); + + expect(response.status).toBe(400); + expect((await parseResponseJSON<{ error: string }>(response)).error).toContain('"kind" must be a non-empty string'); + }); + + test("bounds a provider that ignores the limit, and marks what it bounded", async () => { + activeProvider = sourceProviderReading( + documentWithOnePart(readablePart({ text: "x".repeat(SOURCE_CHARACTER_LIMIT + 10) })), + ); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(200); + const body = await parseResponseJSON(response); + const [part] = body.parts; + if ("unavailable" in part) throw new Error("the double answers a readable part"); + expect(part.text).toHaveLength(SOURCE_CHARACTER_LIMIT); + expect(part.truncated).toEqual({ + limit: SOURCE_CHARACTER_LIMIT, + reason: sourceBoundTruncationReason(SOURCE_CHARACTER_LIMIT), + }); + }); + + test("joins its own sentence to a bound the provider already reported", async () => { + activeProvider = sourceProviderReading( + documentWithOnePart( + readablePart({ + text: "y".repeat(SOURCE_CHARACTER_LIMIT + 10), + truncated: { limit: 4000, reason: "the engine stopped at 4,000 characters" }, + }), + ), + ); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + const body = await parseResponseJSON(response); + const [part] = body.parts; + if ("unavailable" in part) throw new Error("the double answers a readable part"); + // Two bounds are two facts, so the engine's own sentence is kept beside the route's rather + // than replaced by it. + expect(part.truncated?.reason).toBe( + `the engine stopped at 4,000 characters; ${sourceBoundTruncationReason(SOURCE_CHARACTER_LIMIT)}`, + ); + expect(part.truncated?.limit).toBe(SOURCE_CHARACTER_LIMIT); + }); + + test("leaves a part that already fits exactly as the provider wrote it", async () => { + const exact = readablePart({ text: "z".repeat(SOURCE_CHARACTER_LIMIT) }); + activeProvider = sourceProviderReading(documentWithOnePart(exact)); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + const body = await parseResponseJSON(response); + const [part] = body.parts; + if ("unavailable" in part) throw new Error("the double answers a readable part"); + // An exact answer is never marked: marking one teaches a reader to discount every mark. + expect(part.truncated).toBeUndefined(); + expect(part.text).toHaveLength(SOURCE_CHARACTER_LIMIT); + }); + + test("bounds a part that is not the first one, so the walk is not a first-part special case", async () => { + const document: ObjectSourceDocument = { + path: ["app", "pkg"], + kind: "function", + parts: [ + readablePart({ id: "spec", label: "Specification", text: "SHORT" }), + readablePart({ id: "body", label: "Body", text: "w".repeat(SOURCE_CHARACTER_LIMIT + 1) }), + ], + }; + activeProvider = sourceProviderReading(document); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "pkg"], kind: "function" }, + }) as never, + ); + + const body = await parseResponseJSON(response); + const [spec, second] = body.parts; + if ("unavailable" in spec || second === undefined || "unavailable" in second) { + throw new Error("the double answers two readable parts"); + } + expect(spec.truncated).toBeUndefined(); + expect(second.text).toHaveLength(SOURCE_CHARACTER_LIMIT); + expect(second.truncated?.reason).toBe(sourceBoundTruncationReason(SOURCE_CHARACTER_LIMIT)); + }); + + test("drops a surrogate pair whole when the bound lands inside it, the way applySourceBound does", async () => { + /* + * ONE SLICER, and this is the test that makes the route use it (#789). The bound counts + * UTF-16 CODE UNITS, so it can land BETWEEN the two halves of an astral character: a + * PL/pgSQL body or a Lua library holding an emoji at exactly that offset. The route sliced + * with a bare `text.slice(0, limit)` while every one of the sixteen providers bounded + * through `applySourceBound`, which drops the orphaned half. MEASURED on the bare slice: the + * text came back ending in `\ud83d`, which is not a character, JSON serialises it as a lone + * escape and Monaco draws a replacement glyph. + * + * The pair sits ON the boundary by construction, so the assertion cannot pass by accident: + * the high half is the last unit a `slice(0, limit)` would keep. + */ + const straddling = `${"a".repeat(SOURCE_CHARACTER_LIMIT - 1)}\u{1F600}b`; + // The floor: without it a future constant could move the pair off the boundary and this + // test would certify a bound that never cut a pair at all. + expect(straddling.charCodeAt(SOURCE_CHARACTER_LIMIT - 1)).toBe(0xd83d); + expect(straddling.charCodeAt(SOURCE_CHARACTER_LIMIT)).toBe(0xde00); + activeProvider = sourceProviderReading(documentWithOnePart(readablePart({ text: straddling }))); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + const body = await parseResponseJSON(response); + const [part] = body.parts; + if ("unavailable" in part) throw new Error("the double answers a readable part"); + expect(part.text).toHaveLength(SOURCE_CHARACTER_LIMIT - 1); + const last = part.text.charCodeAt(part.text.length - 1); + expect(last >= 0xd800 && last <= 0xdbff).toBe(false); + // `truncated.limit` still names the CALLER's number and not the emitted length. + expect(part.truncated?.limit).toBe(SOURCE_CHARACTER_LIMIT); + expect(part.truncated?.reason).toBe(sourceBoundTruncationReason(SOURCE_CHARACTER_LIMIT)); + }); + + test("carries a refused part through untouched, and the CLIENT is where that document is refused", async () => { + /* + * TWO FILES ON THIS BRANCH PIN ONE INPUT, and this docblock is what stops the pair reading + * as a contradiction (#789 fix round 1). The route keeps an over-long refusal SENTENCE + * whole, deliberately: the sentence is the engine's or the provider's own words, and the + * refused arm carries no `truncated` mark in the type, so slicing it would ship half a + * sentence with nothing on screen saying a cut was made. + * + * What a person then sees is NOT this document. `isSourceDocumentShape` bounds the sentence + * at the client seam, so on the standalone path this 1,000,010-character refusal reaches the + * viewer, fails the shape check and draws "The source read answered with a body this viewer + * cannot render." instead of the engine's sentence. The route's name used to promise a + * guarantee that does not hold end to end; the assertion below now pins BOTH ends, so a + * later change to either side makes one of them fail rather than leaving the pair silently + * inconsistent. + */ + const refusal: ObjectSourcePart = { + id: "body", + label: "Body", + unavailable: "u".repeat(SOURCE_CHARACTER_LIMIT + 10), + }; + activeProvider = sourceProviderReading(documentWithOnePart(refusal)); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(200); + const body = await parseResponseJSON(response); + const [part] = body.parts; + if (!("unavailable" in part)) throw new Error("the double answers a refused part"); + expect(part.unavailable).toHaveLength(SOURCE_CHARACTER_LIMIT + 10); + // The other end of the same input, in one assertion: what the route carries, the client + // refuses. Without this line the two files pin opposite behaviours and neither says so. + expect(isSourceDocumentShape(body)).toBe(false); + }); + + test("refuses a part that carries both a refusal and a text, rather than shipping one over the other", async () => { + // MEASURED against tsc 6.0.3 and recorded on `ObjectSourcePart`: a literal carrying + // `unavailable` BESIDE `text` COMPILES, because the excess-property check on a union admits + // any property declared on ANY member of it. `isSourcePartUnavailable` then narrows it to the + // refusal arm, so the route's character bound would return it untouched and a 2 MB definition + // would reach `NextResponse.json` under a 1,000,000 bound while the client rendered a refusal + // over it. No cast is needed to build one and none is used here. + const hybrid = { + ...readablePart({ text: "x".repeat(SOURCE_CHARACTER_LIMIT * 2) }), + unavailable: "the engine refused this body", + } satisfies ObjectSourcePart; + activeProvider = sourceProviderReading(documentWithOnePart(hybrid)); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(400); + expect((await parseResponseJSON<{ error: string }>(response)).error).toBe( + "the source read answered a part that carries both a refusal and a text; a refusal and a definition " + + "are different facts and a reader must never be shown one over the other", + ); + }); + + test("refuses a document that carries no parts at all, by name and not by a TypeError", async () => { + // `parts` is a NON-EMPTY tuple in the type, and a JS caller or a host is not held to it. Before + // this guard an empty array reached `"unavailable" in part` on `undefined` and the TypeError + // went to `createErrorResponse` as an unhandled error, so the walk's zero-part case certified + // nothing. The cast is what a type-checked caller CANNOT write, which is the point. + activeProvider = sourceProviderReading({ + path: ["app", "f"], + kind: "function", + parts: [] as unknown as ObjectSourceDocument["parts"], + }); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(400); + expect((await parseResponseJSON<{ error: string }>(response)).error).toBe( + "the source read answered a document with no parts, and a source document names at least one", + ); + }); + + test("refuses a document carrying more parts than the route will carry", async () => { + const parts = Array.from({ length: SOURCE_PART_LIMIT + 1 }, (_unused, index) => + readablePart({ id: `p${index}`, label: `Part ${index}` }), + ) as unknown as ObjectSourceDocument["parts"]; + activeProvider = sourceProviderReading({ path: ["app", "f"], kind: "function", parts }); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(400); + expect((await parseResponseJSON<{ error: string }>(response)).error).toBe( + `the source read answered ${SOURCE_PART_LIMIT + 1} parts and this route carries at most ${SOURCE_PART_LIMIT}`, + ); + }); + + test("carries a document holding exactly the part limit", async () => { + const parts = Array.from({ length: SOURCE_PART_LIMIT }, (_unused, index) => + readablePart({ id: `p${index}`, label: `Part ${index}`, text: "ok" }), + ) as unknown as ObjectSourceDocument["parts"]; + activeProvider = sourceProviderReading({ path: ["app", "f"], kind: "function", parts }); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(200); + expect((await parseResponseJSON(response)).parts).toHaveLength(SOURCE_PART_LIMIT); + }); + + test("carries the engine's own error to a 400 rather than inventing one", async () => { + activeProvider = objectProvider({ + objectKinds: [FUNCTION_KIND], + readObjectSource: mock(async () => { + throw new QueryError("permission denied for schema app", "postgres"); + }), + }); + + const response = await sourceRoute.POST( + createMockRequest("/api/db/objects/source", { + method: "POST", + body: { connection, path: ["app", "f"], kind: "function" }, + }) as never, + ); + + expect(response.status).toBe(400); + const body = await parseResponseJSON<{ error: string; code: string }>(response); + expect(body.error).toBe("permission denied for schema app"); + expect(body.code).toBe(ApiErrorCode.QUERY_ERROR); + }); + + test("refuses an unauthenticated caller before parsing a body", async () => { + mockGetSession.mockResolvedValueOnce(null as unknown as { role: string; username: string }); + + const response = await sourceRoute.POST( + new Request("http://localhost:3000/api/db/objects/source", { method: "POST" }) as never, + ); + + expect(response.status).toBe(401); + expect(mockGetOrCreateProvider).toHaveBeenCalledTimes(0); + }); +}); diff --git a/tests/components/object-source/ObjectSourceView.test.tsx b/tests/components/object-source/ObjectSourceView.test.tsx new file mode 100644 index 00000000..b35be1bb --- /dev/null +++ b/tests/components/object-source/ObjectSourceView.test.tsx @@ -0,0 +1,1138 @@ +import "../../setup-dom"; +import "../../helpers/mock-navigation"; + +import { mock } from "bun:test"; +import React from "react"; + +/** + * The read-only object source viewer (#789). + * + * `@monaco-editor/react` is replaced with a `