diff --git a/docs/AGENT_ANALYST_DESIGN.md b/docs/AGENT_ANALYST_DESIGN.md index 3e41019da..f660a854c 100644 --- a/docs/AGENT_ANALYST_DESIGN.md +++ b/docs/AGENT_ANALYST_DESIGN.md @@ -119,7 +119,7 @@ The sizes, read off the code rather than guessed: | Packed schema inventory | 6 000 chars, fenced whole | `context-snapshot.ts:348` | | Relations block | 2 000 chars | `er-diagram.ts` (`MAX_ER_CHARS`) | | Tool declarations, resent each turn | ~3 k chars for 7 descriptions + schemas | `investigation.ts:557-566` | -| **One completed read** | **200 rows and 256 KiB, whichever binds first** | `execution-policy.ts:54-55`, enforced at `postgres.ts:917-931` and `sqlite.ts:429` | +| **One completed read** | **200 rows and 256 KiB, whichever binds first** | `execution-policy.ts:54-55`, enforced at `postgres.ts:919-933` and `sqlite.ts:431` | So `B ≈ 16 k chars ≈ 4 k tokens` (at a conservative four characters per token; the ratio is the model's tokeniser's, which this layer deliberately does not know — `context-snapshot.ts:340-347` @@ -397,7 +397,7 @@ selected by workflow — not a variable. **What raising the wall clock costs, stated:** the artifact TTL is derived from the deadline (`runtime.ts:55`), so it scales with it automatically and correctly. PostgreSQL opens and rolls back -its own read-only transaction *per statement* (`postgres.ts:889,903`), so a longer run does not hold +its own read-only transaction *per statement* (`postgres.ts:891,905`), so a longer run does not hold a longer transaction — the run's life and a transaction's life are unrelated on that engine. What a longer deadline does cost is heap: the transcript and the artifacts stay resident for longer on a single replica. At the numbers above that is tens of megabytes, not hundreds. @@ -426,7 +426,7 @@ snapshot — anywhere in the database, not only in the tables the query touches. cluster that is bloat, and over a long enough read it is transaction-ID wraparound pressure. It is not a lock; a writer is not blocked. It is slower, quieter and harder to attribute, which arguably makes it worse. The agent's own path is safe from this by construction (each statement gets its own -`BEGIN READ ONLY` … `ROLLBACK`, bounded to 10 s, `postgres.ts:889-914`). The editor path is not. +`BEGIN READ ONLY` … `ROLLBACK`, bounded to 10 s, `postgres.ts:891-916`). The editor path is not. **On SQLite the risk is worse than the owner said.** A long read genuinely does block writers, and the driver is synchronous, so it also **blocks the studio process itself** — `deadline.ts:20-26` and @@ -570,7 +570,7 @@ means warning plus "Apply to editor", never a silent skip. 1. **The run executed this exact statement itself.** A model may compose a final statement wider than anything it ran; that one is never auto-executed. This condition is close to free and it removes most of the risk class on its own, because the agent path *refuses rather than truncates* - (`postgres.ts:917`, `sqlite.ts:421`): an artifact exists only for a statement that provably + (`postgres.ts:919`, `sqlite.ts:423`): an artifact exists only for a statement that provably returned 200 rows or fewer inside the 10 s statement ceiling. Row explosion is therefore already excluded by the artifact's existence, not by any estimate. 2. **The plan gate the owner asked for**, read per engine, with unknown resolving to risky: @@ -715,8 +715,8 @@ limit and must **not** inherit a tab's `unlimited` flag (§2.1). Beyond that, no > user "writes and DDL are refused either way". Those two sentences cannot both be true of the > implementation they describe. The editor's execution goes to `POST /api/db/query`, a plain > read-WRITE session whose only protection is `isDangerousQuery` — a check on the statement's TEXT — -> while the agent's own read is enforced by the ENGINE (`BEGIN READ ONLY` at `postgres.ts:889`, -> `PRAGMA query_only` at `sqlite.ts:410`). Text is not where the difference lives: a `SELECT` may +> while the agent's own read is enforced by the ENGINE (`BEGIN READ ONLY` at `postgres.ts:891`, +> `PRAGMA query_only` at `sqlite.ts:412`). Text is not where the difference lives: a `SELECT` may > invoke a VOLATILE function that performs an `INSERT`, which the read-only transaction refuses > (SQLSTATE 25006) and the read-write session performs. The same statement was therefore harmless > where the run proved it and harmful where it was replayed — and this repository already treats diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index f29d3f923..e22a9f0d4 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -369,12 +369,12 @@ Found 2026-08-27 by the audit that closed the curated health projection's cap-as removed MySQL's fabricated "Performance schema not available" row; three providers still ship the same shape, in the same field: -- `src/lib/db/providers/sql/postgres.ts:1239` - a database without `pg_stat_statements` answers +- `src/lib/db/providers/sql/postgres.ts:1241` - a database without `pg_stat_statements` answers `[{ query: "pg_stat_statements extension not enabled", calls: 0, avgTime: "N/A" }]`. -- `src/lib/db/providers/document/mongodb.ts:791` - a database whose profiler is off answers +- `src/lib/db/providers/document/mongodb.ts:785` - a database whose profiler is off answers `[{ query: "Profiler not enabled. Run db.setProfilingLevel(1) to enable." }]`, and the outer catch at `:830` answers `[{ query: "Error fetching health info" }]` for a read that failed entirely. -- `src/lib/db/providers/sql/sqlite.ts:721-731` - EVERY SQLite database answers two synthetic rows, +- `src/lib/db/providers/sql/sqlite.ts:707-717` - EVERY SQLite database answers two synthetic rows, `Integrity: OK|FAILED` and `Journal Mode: `, about statements that were never executed. A sentence wearing a row's clothes is the fabrication the absence rule (#477) forbids, and here it is @@ -392,8 +392,8 @@ file, and falsifies `src/lib/db/compatibility.ts:267`, `docs/providers/postgres. and `tests/helpers/sqlite-node-harness.ts:104`, all of which pin the current sentences. **The other path swallows instead of fabricating, and that is not better.** On the `slow-queries` -reading the agent actually uses, `src/lib/db/providers/keyvalue/redis.ts:635-637` and -`src/lib/db/providers/document/mongodb.ts:1047-1049` `return []` from their catch where MySQL now +reading the agent actually uses, `src/lib/db/providers/keyvalue/redis.ts:622-624` and +`src/lib/db/providers/document/mongodb.ts:1041-1043` `return []` from their catch where MySQL now rejects. So a denied grant reaches the model as an empty reading, and the run prompt tells it `"A reading that comes back EMPTY is an answer, not a failure - no blocked session, no slow query, no unused index is what a healthy server looks like"` (`src/lib/agent/investigation.ts:1485`). It @@ -424,12 +424,12 @@ MongoDB's `getOverview()` catch now omits it too. `databaseSize: TRINO_UNAVAILABLE_TEXT`, and `sql/search/index.ts:849` pairs `sizeBytes ?? 0` with `databaseSize: SEARCH_UNKNOWN_TEXT` for both `elasticsearch` and `opensearch`. - Swallowed into an initialiser the way D40's connection counts were: `sql/mssql.ts:1111`, - `sql/oracle.ts:1178`, `sql/sqlite.ts:808`. + `sql/oracle.ts:1154`, `sql/sqlite.ts:794`. - Coerced by a helper that returns 0 for an absent row: `sql/druid/introspect.ts:578` and `sql/clickhouse/index.ts:833` through their local `asNumber`. -- Coerced inline: `sql/postgres.ts:1397` and `sql/mysql.ts:1156` (`parseInt(... || "0")`), +- Coerced inline: `sql/postgres.ts:1360` and `sql/mysql.ts:1158` (`parseInt(... || "0")`), `sql/libsql/introspect.ts:399` and `document/couchbase/index.ts:606` (`?? 0`), - `keyvalue/redis.ts:603`, and `embedded/libredb.ts:709`, whose `fileSizeBytes()` returns 0 when the + `keyvalue/redis.ts:590`, and `embedded/libredb.ts:709`, whose `fileSizeBytes()` returns 0 when the `statSync` throws. **The consumer makes it visible.** `src/components/monitoring/tabs/StorageTab.tsx` keys its entire @@ -510,7 +510,7 @@ correct in isolation and only the running product puts them together. `TablesTab.tsx:390` calls `handleMaintenance(type, table.tableName)` - the BARE table name - from a row whose very next line (`:350`) renders `table.schemaName` beside it. Every provider's `qualifyMaintenanceTarget` then supplies a default schema for an unqualified target: -`postgres.ts:1285` returns `"public." + escapeIdentifier(target)`, and +`postgres.ts:1287` returns `"public." + escapeIdentifier(target)`, and `duckdb/index.ts:712` returns `"main".""`. So the statement names a table that is not there. Measured on DuckDB v1.5.5, clicking **Analyze Table** on the `analytics.events` row: @@ -1226,12 +1226,12 @@ Every other `file.ts:NNNN` in the repository is hand-copied prose, and a sample | Citation | Cited in | Anchor actually at | |---|---|---| -| `postgres.ts:915` (`queryReadOnly`) | `docs/AGENT_GUIDE.md:925` | 2396 | -| `postgres.ts:889` (`BEGIN READ ONLY`) | `docs/AGENT_ANALYST_DESIGN.md:400`, `:718` | 2415 | -| `postgres.ts:892` (`SET LOCAL statement_timeout`) | `src/lib/agent/tools.ts:1552` | 2418 | -| `postgres.ts:2095-2099` (`{ ...baseConfig, connectionString }`) | `src/lib/db/connection-fingerprint.ts:67`, `tests/api/db/objects/edit-apply.test.ts:91`, `tests/unit/lib/db/connection-fingerprint.test.ts` x3 | 2256-2262 | -| `postgres.ts:1239` (`pg_stat_statements extension not enabled`) | `docs/BACKLOG.md:326` | 4001 | -| `postgres.ts:1285` (`"public." + escapeIdentifier`) | `docs/BACKLOG.md:467` | 4048 | +| `postgres.ts:917` (`queryReadOnly`) | `docs/AGENT_GUIDE.md:925` | 2396 | +| `postgres.ts:891` (`BEGIN READ ONLY`) | `docs/AGENT_ANALYST_DESIGN.md:400`, `:718` | 2415 | +| `postgres.ts:894` (`SET LOCAL statement_timeout`) | `src/lib/agent/tools.ts:1552` | 2418 | +| `postgres.ts:2070-2074` (`{ ...baseConfig, connectionString }`) | `src/lib/db/connection-fingerprint.ts:67`, `tests/api/db/objects/edit-apply.test.ts:91`, `tests/unit/lib/db/connection-fingerprint.test.ts` x3 | 2256-2262 | +| `postgres.ts:1241` (`pg_stat_statements extension not enabled`) | `docs/BACKLOG.md:326` | 4001 | +| `postgres.ts:1287` (`"public." + escapeIdentifier`) | `docs/BACKLOG.md:467` | 4048 | | `source-applier.ts:155` (the silent-status sentence) | `tests/components/object-source/ApplyPreviewDialog.test.tsx:1092` | `whenSilent`, elsewhere | | `StudioWorkspace.tsx:494` (`
`) | `docs/BACKLOG.md:1360` | 823 | | `StudioWorkspace.tsx:833` (the `ObjectSourceView` mount) | `docs/BACKLOG.md:1145` | 919 | diff --git a/src/lib/agent/tools.ts b/src/lib/agent/tools.ts index cbb0eb236..61e13f3f0 100644 --- a/src/lib/agent/tools.ts +++ b/src/lib/agent/tools.ts @@ -1566,7 +1566,7 @@ function auditDeadlineRefusal( * on: a credential failure happens while connecting, a grant failure while running. * - **`QueryCancelledError`** is what a PostgreSQL statement timeout arrives as, and * this layer is what CAUSES it: the clamped budget becomes `SET LOCAL - * statement_timeout` (`postgres.ts:892`), the engine says `canceling statement due + * statement_timeout` (`postgres.ts:894`), the engine says `canceling statement due * to statement timeout`, and `mapDatabaseError` matches `canceling statement` * BEFORE its timeout branch (`errors.ts:280-293`) — so the timeout never arrives as * `TimeoutError` on this engine at all. Narrowing the read is the repair that helps. diff --git a/src/lib/api/object-route.ts b/src/lib/api/object-route.ts index 461edc774..d211996ae 100644 --- a/src/lib/api/object-route.ts +++ b/src/lib/api/object-route.ts @@ -650,9 +650,9 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart { * was the only engine that had landed; the day-one set is now three and the count was re-measured * rather than the digit bumped, because what it counts is what the paragraph is for. * - * There are THREE producers of `edit`: `providers/sql/postgres.ts:3182`, gated on + * There are THREE producers of `edit`: `providers/sql/postgres.ts:3157`, gated on * `kindAcceptsSourceEdits(capabilities, kind)`; `providers/sql/trino/index.ts:1257` and - * `providers/keyvalue/redis.ts:1780`, both gated on `spec.acceptsSourceEdits === true`, which is the + * `providers/keyvalue/redis.ts:1768`, both gated on `spec.acceptsSourceEdits === true`, which is the * same fact read through the same declaration. All three sit on the READABLE arm, verified rather * than assumed: no producer attaches `edit` to a part carrying `unavailable`. * @@ -664,7 +664,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart { * Rule 2's producer set GREW and its character changed, which is the part a bumped digit would have * hidden. On PostgreSQL it is a by-product: that site spreads `truncated` and `edit` from a single * read, so a routine over `SOURCE_CHARACTER_LIMIT` reaches it. On Redis it is a DECIDED POSITION, - * stated at `redis.ts:1773-1779`: the affordance is offered on a truncated part deliberately, because + * stated at `redis.ts:1761-1767`: the affordance is offered on a truncated part deliberately, because * the bound is the CALLER's and the same object read without one is whole, so a provider that withheld * it there would be answering a property of the REQUEST as a property of the object. Rule 2 is what * makes that position safe on the standalone path, and the pane's predicate and `buildObjectEdit`'s @@ -684,7 +684,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart { * * THE BOUND, on both sides of the same constant. `edit-plan/route.ts:74` refuses a SUBMITTED text * longer than `EDIT_CHARACTER_LIMIT`, and all three day-one providers refuse a READ definition longer - * than it inside `buildObjectEdit`: `providers/sql/postgres.ts:3339`, `providers/keyvalue/redis.ts:1870` + * than it inside `buildObjectEdit`: `providers/sql/postgres.ts:3314`, `providers/keyvalue/redis.ts:1858` * and `providers/sql/trino/index.ts:1451`. The second is what closes the class rather than narrowing * it: a plan is minted only from the build's own read, so a definition the pane could only have shown * truncated never reaches a plan at all, whatever the client POSTs. diff --git a/src/lib/db/connection-fingerprint.ts b/src/lib/db/connection-fingerprint.ts index c1aef3285..661f86781 100644 --- a/src/lib/db/connection-fingerprint.ts +++ b/src/lib/db/connection-fingerprint.ts @@ -64,10 +64,10 @@ export function tunnelRoute(tunnel: SSHTunnelConfig | undefined): string { * `2dedca1a0ad45abdfb01427f0e1df3130e59617c5658058cbcf4852297f888c7` on both sides. * * - `connectionString` OVERRIDES the field-by-field form outright and is not merged with it. - * `src/lib/db/providers/sql/postgres.ts:2095-2099` returns `{ ...baseConfig, connectionString }` + * `src/lib/db/providers/sql/postgres.ts:2070-2074` returns `{ ...baseConfig, connectionString }` * and never reaches the `host`/`port`/`user`/`database` branch below it. The same shape is at - * `mysql.ts:1973-1976` (`uri`), `oracle.ts:1545-1546`, `sqlite.ts:1189-1192`, - * `document/mongodb.ts:800-801`, `libsql/index.ts:116-120`, `clickhouse/index.ts:402-422` and + * `mysql.ts:1948-1951` (`uri`), `oracle.ts:1521-1522`, `sqlite.ts:1175-1178`, + * `document/mongodb.ts:794-795`, `libsql/index.ts:116-120`, `clickhouse/index.ts:402-422` and * `document/couchbase/index.ts:478`. PostgreSQL is the LIVE population: it declares two editable * kinds. * - `schema` is Trino's session schema and is sent as the `X-Trino-Schema` submission header @@ -76,7 +76,7 @@ export function tunnelRoute(tunnel: SSHTunnelConfig | undefined): string { * The normal path is qualified: measured on Trino 476, `SHOW CREATE FUNCTION` answers * `memory.app.plus_one`, so the case needs a user edit that drops the qualification, which the * pane cannot stop and the seal is not entitled to assume away. - * - `serviceName` is Oracle's connect-string tail, `oracle.ts:1551-1560` building + * - `serviceName` is Oracle's connect-string tail, `oracle.ts:1527-1536` building * `host:port/serviceName`, so it selects WHICH DATABASE on that listener. * - `sshTunnel` is the ROUTE and not a credential. `getOrCreateProvider` * (`src/lib/db/factory.ts:533-540`) REWRITES `host` and `port` to the tunnel's local endpoint diff --git a/src/lib/db/object-kinds.ts b/src/lib/db/object-kinds.ts index cdd02b242..e205c9ad8 100644 --- a/src/lib/db/object-kinds.ts +++ b/src/lib/db/object-kinds.ts @@ -6,7 +6,14 @@ * `acceptsRowWrites` reads as false in every caller because there is only one caller. */ import { QueryError } from "@/lib/db/errors"; -import type { DatabaseType, KindCount, ObjectKindSpec, ObjectSourcePart, ProviderCapabilities } from "@/lib/db/types"; +import type { + ContainerLevelSpec, + DatabaseType, + KindCount, + ObjectKindSpec, + ObjectSourcePart, + ProviderCapabilities, +} from "@/lib/db/types"; /** * How many container levels this engine declares, as the tree models them. @@ -32,6 +39,74 @@ export function findKind(capabilities: ProviderCapabilities, id: string): Object return declaredKinds(capabilities).find((kind) => kind.id === id); } +/** + * The engine half of `assertObjectPathShape`: the identity its error carries, and the one + * policy the nine hoisted copies disagreed on. + */ +export type ObjectPathShapeEngine = { + /** The engine code the thrown `QueryError` is stamped with. */ + code: DatabaseType; + /** The message's opening subject, article included: "A PostgreSQL", "An Oracle". */ + label: string; + /** + * Whether a kind that declares `attachedTo` also admits the bare shape. MySQL and + * Oracle answer yes; every other engine requires the attached segment. + */ + attachedSegment: "required" | "optional"; +}; + +/** + * The container levels this engine declares, sliced to the depth `containerDepth()` + * reports. The same derivation every provider kept locally; hoisted with the assert so + * the depth rule and the level list cannot be taken by two different rules. + */ +function declaredLevels(capabilities: ProviderCapabilities): readonly ContainerLevelSpec[] { + return (capabilities.containerLevels ?? []).slice(0, containerDepth(capabilities)); +} + +/** + * Refuses a path no shape of this kind admits, naming every shape it does admit. + * + * Hoisted from nine provider-local copies (#978) that had drifted apart in more than the + * signature. MySQL and Oracle read an attached kind by EITHER address, so they name both + * shapes in the error and carry `attachedSegment: "optional"`; PostgreSQL, SQLite, + * libSQL and Cassandra require the attached segment; ClickHouse, Redis and MongoDB + * declare no attached kind, so the policy never fires for them. The engine descriptor + * keeps those behaviours exactly as they were, because a hoist that quietly picked one + * would change what a bare-shaped path means on four engines. + * + * `kind` - not `spec.id` - stays in the message because that is what seven of the nine + * copies printed. Callers resolve the kind before this call (findKind, requireSourceKind + * or requireEditableKind), so the spec is always in hand and this function is not the + * kind-existence check: refusing an undeclared kind stays the caller's job, in the + * caller's own words. + */ +export function assertObjectPathShape( + capabilities: ProviderCapabilities, + spec: ObjectKindSpec, + kind: string, + path: readonly string[], + engine: ObjectPathShapeEngine, +): void { + const levels = declaredLevels(capabilities).map((level) => level.label.toLowerCase()); + const attachedTo = spec.attachedTo; + const shapes: string[][] = + attachedTo === undefined + ? [[...levels, "name"]] + : engine.attachedSegment === "optional" + ? [ + [...levels, attachedTo, "name"], + [...levels, "name"], + ] + : [[...levels, attachedTo, "name"]]; + if (shapes.some((shape) => shape.length === path.length)) return; + throw new QueryError( + `${engine.label} "${kind}" path is ${shapes.map((shape) => `[${shape.join(", ")}]`).join(" or ")}, ` + + `received ${JSON.stringify(path)}`, + engine.code, + ); +} + /** * Whether THIS KIND accepts a row write. Absent and undeclared both read as false. * diff --git a/src/lib/db/providers/document/mongodb.ts b/src/lib/db/providers/document/mongodb.ts index 2568cc72b..e73351c5b 100644 --- a/src/lib/db/providers/document/mongodb.ts +++ b/src/lib/db/providers/document/mongodb.ts @@ -47,6 +47,8 @@ import { } from "../../types"; import { applySourceBound, + assertObjectPathShape, + type ObjectPathShapeEngine, callerBoundTruncationReason, containerDepth, declaredKinds, @@ -541,23 +543,15 @@ const MONGODB_SOURCE_PART_LABEL = "Definition"; 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. + * MongoDB: no kind declares `attachedTo`, so the shape is always the declared level plus + * the name, shared by `describeObject` and `readObjectSource` so both refuse the same + * shape in the same words. */ -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", - ); - } -} +const PATH_SHAPE_ENGINE: ObjectPathShapeEngine = { + code: "mongodb", + label: "A MongoDB", + attachedSegment: "required", +}; /** * One catalog row's definition, rendered as MongoDB Extended JSON, or `undefined` when the @@ -1804,7 +1798,7 @@ export class MongoDBProvider extends BaseDatabaseProvider { // 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); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); // 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. @@ -1889,7 +1883,7 @@ export class MongoDBProvider extends BaseDatabaseProvider { "mongodb", ); } - assertObjectPathShape(capabilities, path, kind); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); const database = containerSegment(capabilities, path, "schema"); const name = path[path.length - 1]; diff --git a/src/lib/db/providers/keyvalue/redis.ts b/src/lib/db/providers/keyvalue/redis.ts index b14423a6b..1c6ac4fff 100644 --- a/src/lib/db/providers/keyvalue/redis.ts +++ b/src/lib/db/providers/keyvalue/redis.ts @@ -19,6 +19,8 @@ import Redis, { type RedisOptions } from "ioredis"; import { BaseDatabaseProvider } from "../../base-provider"; import { applySourceBound, + assertObjectPathShape, + type ObjectPathShapeEngine, callerBoundTruncationReason, containerDepth, declaredKinds, @@ -251,29 +253,14 @@ function declaredLevels(capabilities: ProviderCapabilities): readonly ContainerL } /** - * 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. + * Redis: neither kind declares `attachedTo`, so there is one shape: the declared level + * plus the name. */ -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", - ); -} +const PATH_SHAPE_ENGINE: ObjectPathShapeEngine = { + code: "redis", + label: "A Redis", + attachedSegment: "required", +}; /** * The segment of `path` belonging to the declared container level `id`. @@ -1622,11 +1609,12 @@ export class RedisProvider extends BaseDatabaseProvider { public async describeObject(path: readonly string[], kind: string): Promise { this.ensureConnected(); const capabilities = this.getCapabilities(); - if (findKind(capabilities, kind) === undefined) { + const spec = findKind(capabilities, kind); + if (spec === undefined) { throw new QueryError(`Redis declares no object kind "${kind}"`, "redis"); } - assertObjectPathShape(capabilities, kind, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); const levels = declaredLevels(capabilities); if (kind !== "keyspace") return { path: [...path], columns: [], indexes: [], foreignKeys: [] }; @@ -1718,7 +1706,7 @@ export class RedisProvider extends BaseDatabaseProvider { "redis", ); } - assertObjectPathShape(capabilities, kind, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); const name = path[path.length - 1]; let reply: unknown; try { @@ -1840,7 +1828,7 @@ export class RedisProvider extends BaseDatabaseProvider { "redis", ); } - assertObjectPathShape(capabilities, request.kind, request.path); + assertObjectPathShape(capabilities, spec, request.kind, request.path, PATH_SHAPE_ENGINE); // The LAST segment and never `path[1]`: at depth 2 the second segment is a container, and // the suite drives this by spying a two-level declaration in. const name = request.path[request.path.length - 1]; @@ -1989,8 +1977,8 @@ export class RedisProvider extends BaseDatabaseProvider { } const unit = plan.unit; const capabilities = this.getCapabilities(); - requireEditableKind(capabilities, plan.kind, REDIS_ENGINE); - assertObjectPathShape(capabilities, plan.kind, plan.path); + const spec = requireEditableKind(capabilities, plan.kind, REDIS_ENGINE); + assertObjectPathShape(capabilities, spec, plan.kind, plan.path, PATH_SHAPE_ENGINE); if (plan.revision.check === "unavailable") { // H3's three states stay three. A revision that says "this provider could not produce a // token" says NOTHING about whether the object moved, so answering `conflict` / diff --git a/src/lib/db/providers/sql/cassandra/objects.ts b/src/lib/db/providers/sql/cassandra/objects.ts index 76222b43b..9b261ffef 100644 --- a/src/lib/db/providers/sql/cassandra/objects.ts +++ b/src/lib/db/providers/sql/cassandra/objects.ts @@ -87,6 +87,8 @@ import { QueryError } from "@/lib/db/errors"; import { applySourceBound, + assertObjectPathShape, + type ObjectPathShapeEngine, callerBoundTruncationReason, containerDepth, declaredKinds, @@ -591,29 +593,13 @@ function containerKeyspace(capabilities: ProviderCapabilities, container: readon } /** - * 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. + * Cassandra: an attached kind is addressed through its table, so the segment is required. */ -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, - ); -} +const PATH_SHAPE_ENGINE: ObjectPathShapeEngine = { + code: PROVIDER, + label: "A Cassandra", + attachedSegment: "required", +}; /** * The server's own sentence, verbatim, for ONE kind whose read was refused. @@ -983,7 +969,7 @@ export async function describeObject( throw new QueryError(`Cassandra declares no object kind "${kind}"`, PROVIDER); } - assertObjectPathShape(capabilities, spec, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); const catalog = objectCatalog(kind); if (catalog === undefined) { @@ -1351,7 +1337,7 @@ export async function readObjectSource( limit?: number, ): Promise { const spec = requireSourceKind(capabilities, kind, { displayName: "Cassandra", type: PROVIDER }); - assertObjectPathShape(capabilities, spec, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); const catalog = objectCatalog(kind); if (catalog?.describeTarget === undefined || catalog.describeType === undefined) { throw new QueryError( diff --git a/src/lib/db/providers/sql/clickhouse/objects.ts b/src/lib/db/providers/sql/clickhouse/objects.ts index 220d4adab..6fe843957 100644 --- a/src/lib/db/providers/sql/clickhouse/objects.ts +++ b/src/lib/db/providers/sql/clickhouse/objects.ts @@ -61,6 +61,8 @@ import { QueryError } from "@/lib/db/errors"; import { applySourceBound, + assertObjectPathShape, + type ObjectPathShapeEngine, callerBoundTruncationReason, containerDepth, declaredKinds, @@ -530,25 +532,14 @@ function containerDatabase(capabilities: ProviderCapabilities, container: readon } /** - * 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. + * ClickHouse: no kind declares `attachedTo`, so the policy is inert and the shape is + * always the declared levels plus the name. */ -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, - ); -} +const PATH_SHAPE_ENGINE: ObjectPathShapeEngine = { + code: PROVIDER, + label: "A ClickHouse", + attachedSegment: "required", +}; /** * Every declared kind seeded at zero, before any row is read. @@ -865,7 +856,7 @@ export async function describeObject( throw new QueryError(`ClickHouse declares no object kind "${kind}"`, PROVIDER); } - assertObjectPathShape(capabilities, kind, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); // 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 @@ -1354,7 +1345,7 @@ export async function readObjectSource( limit?: number, ): Promise { const spec = requireSourceKind(capabilities, kind, { displayName: "ClickHouse", type: PROVIDER }); - assertObjectPathShape(capabilities, kind, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); const catalog = objectCatalog(kind); if (catalog === undefined) { throw new QueryError( diff --git a/src/lib/db/providers/sql/libsql/objects.ts b/src/lib/db/providers/sql/libsql/objects.ts index aba0596be..c5bd614aa 100644 --- a/src/lib/db/providers/sql/libsql/objects.ts +++ b/src/lib/db/providers/sql/libsql/objects.ts @@ -44,6 +44,8 @@ import type { import { QueryError } from "@/lib/db/errors"; import { applySourceBound, + assertObjectPathShape, + type ObjectPathShapeEngine, callerBoundTruncationReason, containerDepth, declaredKinds, @@ -415,30 +417,13 @@ function assertContainerPath(capabilities: ProviderCapabilities, container: read } /** - * Refuses a path no shape of this kind admits, naming the shape it does admit. - * - * ONE writer for two readers since #789 Phase 2. `describeLibSQLObject` and - * `readLibSQLObjectSource` 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 a - * trigger's address is. - * - * Derived, never counted. The depth comes from `containerDepth()` through `declaredLevels()`, - * so absent and empty cannot be answered differently here than anywhere else, and the segment - * NAMES are the declared level labels, so the message and the check are the same array. There - * is ONE shape per kind rather than MySQL's two, because every trigger on this engine has a - * parent: `sqlite_schema.tbl_name` is never null for one. + * libSQL: an attached kind requires its parent segment, so the error names one shape. */ -function assertObjectPathShape( - capabilities: ProviderCapabilities, - spec: ObjectKindSpec, - kind: string, - 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 libSQL "${kind}" path is [${shape.join(", ")}], received ${JSON.stringify(path)}`, "libsql"); -} +const PATH_SHAPE_ENGINE: ObjectPathShapeEngine = { + code: "libsql", + label: "A libSQL", + attachedSegment: "required", +}; /** * Every declared kind seeded at zero, before any row is read. @@ -779,7 +764,7 @@ export async function describeLibSQLObject( throw new QueryError(`libSQL declares no object kind "${kind}"`, "libsql"); } - assertObjectPathShape(reader.capabilities, spec, kind, path); + assertObjectPathShape(reader.capabilities, spec, kind, path, PATH_SHAPE_ENGINE); if (spec.role !== "relation") { return { path: [...path], columns: [], indexes: [], foreignKeys: [] }; @@ -1106,7 +1091,7 @@ export async function readLibSQLObjectSource( limit?: number, ): Promise { const spec = requireSourceKind(reader.capabilities, kind, { displayName: "libSQL", type: "libsql" }); - assertObjectPathShape(reader.capabilities, spec, kind, path); + assertObjectPathShape(reader.capabilities, spec, kind, path, PATH_SHAPE_ENGINE); if (!Object.hasOwn(SOURCE_CATALOG_TYPES, kind)) { throw new QueryError( `libSQL declares readable source for the kind "${kind}" but has no catalog type that reads it`, diff --git a/src/lib/db/providers/sql/mysql.ts b/src/lib/db/providers/sql/mysql.ts index 9e588eb7f..f4fae8351 100644 --- a/src/lib/db/providers/sql/mysql.ts +++ b/src/lib/db/providers/sql/mysql.ts @@ -43,6 +43,8 @@ import { import { DatabaseConfigError, ConnectionError, QueryError, mapDatabaseError } from "../../errors"; import { applySourceBound, + assertObjectPathShape, + type ObjectPathShapeEngine, callerBoundTruncationReason, containerDepth, declaredKinds, @@ -1329,43 +1331,16 @@ function unavailableCounts(ids: readonly string[], error: unknown): Record level.label.toLowerCase()); - if (spec.attachedTo === undefined) return [[...levels, "name"]]; - return [ - [...levels, spec.attachedTo, "name"], - [...levels, "name"], - ]; -} - -/** Refuses a path no shape of this kind admits, naming every shape it does admit. */ -function assertObjectPathShape( - capabilities: ProviderCapabilities, - spec: ObjectKindSpec, - kind: string, - path: readonly string[], -): void { - const shapes = objectPathShapes(capabilities, spec); - if (shapes.some((shape) => shape.length === path.length)) return; - throw new QueryError( - `A MySQL "${kind}" path is ${shapes.map((shape) => `[${shape.join(", ")}]`).join(" or ")}, ` + - `received ${JSON.stringify(path)}`, - "mysql", - ); -} +const PATH_SHAPE_ENGINE: ObjectPathShapeEngine = { + code: "mysql", + label: "A MySQL", + attachedSegment: "optional", +}; // ---------------------------------------------------------------------------- // Object source reading (#789 Phase 2) @@ -2407,7 +2382,7 @@ export class MySQLProvider extends SQLBaseProvider { // Derived, not counted, and derived in ONE place: `assertObjectPathShape()` is the same // writer `readObjectSource` reads, so the detail pane and the Source tab cannot disagree // about what a trigger's address is. - assertObjectPathShape(capabilities, spec, kind, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); if (!hasColumns(kind)) { return { path: [...path], columns: [], indexes: [], foreignKeys: [] }; @@ -2620,7 +2595,7 @@ export class MySQLProvider extends SQLBaseProvider { this.ensureConnected(); const capabilities = this.getCapabilities(); const spec = requireSourceKind(capabilities, kind, { displayName: "MySQL", type: "mysql" }); - assertObjectPathShape(capabilities, spec, kind, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); if (!Object.hasOwn(MYSQL_SOURCE_PART_PLANS, kind)) { throw new QueryError( `MySQL declares readable source for the kind "${kind}" but has no statement that reads it`, diff --git a/src/lib/db/providers/sql/oracle.ts b/src/lib/db/providers/sql/oracle.ts index b2b297e14..593da4e93 100644 --- a/src/lib/db/providers/sql/oracle.ts +++ b/src/lib/db/providers/sql/oracle.ts @@ -41,6 +41,8 @@ import { } from "../../types"; import { applySourceBound, + assertObjectPathShape, + type ObjectPathShapeEngine, callerBoundTruncationReason, containerDepth, declaredKinds, @@ -985,40 +987,14 @@ function ownerSegment(capabilities: ProviderCapabilities, path: readonly string[ } /** - * That `path` has a shape this kind can legally take, refused by NAME when it does not. - * - * Derived, not counted. The depth is read through `containerDepth()` so absent and empty - * cannot be answered differently here than anywhere else, and the segment NAMES are the - * declared labels sliced to that same depth, so the message and the check cannot disagree. - * An attached kind takes EITHER depth, because a trigger's base object may be a table, a - * view, or - for a SCHEMA or DATABASE trigger - nothing at all, and standing ruling 5f - * settles that the listing wins and the path shape gives way (#789). - * - * ONE writer for `describeObject` and `readObjectSource` both. Two copies of a rule about - * path shape is how the two methods come to disagree about one engine, and the second copy - * would have been written the day the source read landed. + * Oracle: an attached kind takes either depth, so the error names both shapes, and the + * subject the message opens with is "An Oracle". */ -function assertObjectPathShape( - capabilities: ProviderCapabilities, - spec: ObjectKindSpec, - path: readonly string[], -): void { - const levels = declaredLevels(capabilities).map((level) => level.label.toLowerCase()); - const shapes = - spec.attachedTo === undefined - ? [[...levels, "name"]] - : [ - [...levels, spec.attachedTo, "name"], - [...levels, "name"], - ]; - if (!shapes.some((shape) => shape.length === path.length)) { - throw new QueryError( - `An Oracle "${spec.id}" path is ${shapes.map((shape) => `[${shape.join(", ")}]`).join(" or ")}, ` + - `received ${JSON.stringify(path)}`, - "oracle", - ); - } -} +const PATH_SHAPE_ENGINE: ObjectPathShapeEngine = { + code: "oracle", + label: "An Oracle", + attachedSegment: "optional", +}; /** * Every declared kind seeded at zero, before any row is read. @@ -2016,7 +1992,7 @@ export class OracleProvider extends SQLBaseProvider { throw new QueryError(`Oracle declares no object kind "${kind}"`, "oracle"); } - assertObjectPathShape(capabilities, spec, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); if (spec.role !== "relation") { return { path: [...path], columns: [], indexes: [], foreignKeys: [] }; @@ -2208,7 +2184,7 @@ export class OracleProvider extends SQLBaseProvider { "oracle", ); } - assertObjectPathShape(capabilities, spec, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); const owner = ownerSegment(capabilities, path); const name = path[path.length - 1]; const [head, ...rest] = sourcePartPlans(kind); diff --git a/src/lib/db/providers/sql/postgres.ts b/src/lib/db/providers/sql/postgres.ts index 3524dd1d2..7ad362f74 100644 --- a/src/lib/db/providers/sql/postgres.ts +++ b/src/lib/db/providers/sql/postgres.ts @@ -47,6 +47,8 @@ import { } from "../../types"; import { applySourceBound, + assertObjectPathShape, + type ObjectPathShapeEngine, callerBoundTruncationReason, containerDepth, declaredKinds, @@ -1365,41 +1367,14 @@ function containerSchema(capabilities: ProviderCapabilities, container: readonly } /** - * The shape one kind's object path has, refused rather than read from the wrong segment. - * - * Derived, not counted. `2` and `3` are right for a one-level engine and wrong for the five - * two-level ones in this epic, and a provider copying this file must not inherit a literal - * that refuses every valid path on a catalog-plus-schema engine. The segment names come from - * the declared level labels, so the message and the depth cannot disagree: they are the same - * array. - * - * 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 is two chances for - * the detail pane and the Source tab to disagree about what a trigger's address is. - * - * The levels come from `declaredLevels()` and never from `containerLevels.length`, which is - * what this function counted when the hoist inherited it from `describeObject`. The two agree - * at every depth `ContainerLevels` admits, and they disagree past it: `containerDepth()` - * saturates at two, so a third declared level made this check demand four segments while - * `readObjectSource` sliced the container at two and handed `containerSchema` a two-segment - * path. One reader, which is what the `declaredLevels` docblock twelve lines up already said. + * PostgreSQL: an attached kind is addressed through its table, so the attached segment + * is required and the error names one shape. */ -function assertObjectPathShape( - capabilities: ProviderCapabilities, - spec: ObjectKindSpec, - kind: string, - path: readonly string[], -): void { - const segments = declaredLevels(capabilities).map((level) => level.label.toLowerCase()); - if (spec.attachedTo !== undefined) segments.push(spec.attachedTo); - segments.push("name"); - if (path.length !== segments.length) { - throw new QueryError( - `A PostgreSQL "${kind}" path is [${segments.join(", ")}], received ${JSON.stringify(path)}`, - "postgres", - ); - } -} +const PATH_SHAPE_ENGINE: ObjectPathShapeEngine = { + code: "postgres", + label: "A PostgreSQL", + attachedSegment: "required", +}; /** * Every declared kind seeded at zero, before any row is read. @@ -2986,7 +2961,7 @@ export class PostgresProvider extends SQLBaseProvider { throw new QueryError(`PostgreSQL declares no object kind "${kind}"`, "postgres"); } - assertObjectPathShape(this.getCapabilities(), spec, kind, path); + assertObjectPathShape(this.getCapabilities(), spec, kind, path, PATH_SHAPE_ENGINE); if (RELKIND_BY_KIND[kind] === undefined) { return { path: [...path], columns: [], indexes: [], foreignKeys: [] }; @@ -3104,7 +3079,7 @@ export class PostgresProvider extends SQLBaseProvider { this.ensureConnected(); const capabilities = this.getCapabilities(); const spec = requireSourceKind(capabilities, kind, { displayName: "PostgreSQL", type: "postgres" }); - assertObjectPathShape(capabilities, spec, kind, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); const depth = containerDepth(capabilities); const schema = containerSchema(capabilities, path.slice(0, depth)); @@ -3288,7 +3263,7 @@ export class PostgresProvider extends SQLBaseProvider { this.ensureConnected(); const capabilities = this.getCapabilities(); const spec = requireEditableKind(capabilities, request.kind, { displayName: "PostgreSQL", type: "postgres" }); - assertObjectPathShape(capabilities, spec, request.kind, request.path); + assertObjectPathShape(capabilities, spec, request.kind, request.path, PATH_SHAPE_ENGINE); const { schema, prokind, name } = this.routineAddress(capabilities, request.kind, request.path); if (request.partId !== SOURCE_PART_ID) { // A part this provider never produced. It raises rather than refusing, because a refusal is diff --git a/src/lib/db/providers/sql/sqlite.ts b/src/lib/db/providers/sql/sqlite.ts index 3a65a9c66..4738f3011 100644 --- a/src/lib/db/providers/sql/sqlite.ts +++ b/src/lib/db/providers/sql/sqlite.ts @@ -52,6 +52,8 @@ import { loadSQLiteDriver, type SQLiteDatabase } from "./sqlite-driver"; import { declaredColumnTypes } from "./column-types"; import { applySourceBound, + assertObjectPathShape, + type ObjectPathShapeEngine, callerBoundTruncationReason, containerDepth, declaredKinds, @@ -457,29 +459,13 @@ function assertContainerPath(capabilities: ProviderCapabilities, container: read } /** - * Refuses a path no shape of this kind admits, 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 a trigger's address is. - * - * Derived, never counted. The depth comes from `containerDepth()` through `declaredLevels()`, - * so absent and empty cannot be answered differently here than anywhere else, and the segment - * NAMES are the declared level labels, so the message and the check are the same array. There - * is ONE shape per kind rather than MySQL's two, because every SQLite trigger has a parent: - * `sqlite_schema.tbl_name` is never null for one. + * SQLite: an attached kind requires its parent segment, so the error names one shape. */ -function assertObjectPathShape( - capabilities: ProviderCapabilities, - spec: ObjectKindSpec, - kind: string, - 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 SQLite "${kind}" path is [${shape.join(", ")}], received ${JSON.stringify(path)}`, "sqlite"); -} +const PATH_SHAPE_ENGINE: ObjectPathShapeEngine = { + code: "sqlite", + label: "A SQLite", + attachedSegment: "required", +}; /** * Every declared kind seeded at zero, before any row is read. @@ -1560,7 +1546,7 @@ export class SQLiteProvider extends SQLBaseProvider { throw new QueryError(`SQLite declares no object kind "${kind}"`, "sqlite"); } - assertObjectPathShape(capabilities, spec, kind, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); if (spec.role !== "relation") { return { path: [...path], columns: [], indexes: [], foreignKeys: [] }; @@ -1763,7 +1749,7 @@ export class SQLiteProvider extends SQLBaseProvider { this.ensureConnected(); const capabilities = this.getCapabilities(); const spec = requireSourceKind(capabilities, kind, { displayName: "SQLite", type: "sqlite" }); - assertObjectPathShape(capabilities, spec, kind, path); + assertObjectPathShape(capabilities, spec, kind, path, PATH_SHAPE_ENGINE); 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`, diff --git a/tests/api/db/objects/edit-apply.test.ts b/tests/api/db/objects/edit-apply.test.ts index a26d4d5b0..db850ab16 100644 --- a/tests/api/db/objects/edit-apply.test.ts +++ b/tests/api/db/objects/edit-apply.test.ts @@ -88,7 +88,7 @@ describe("POST /api/db/objects/edit-apply", () => { // `1c7e7b2e9f9ee023a97ad141dd3d3c92923bb03472f6b0ff0c726968c3fe28a5`, this request answered 200 // and `provider.applyObjectEdit` was called once, which is the apply landing on another server. // - // Why the URI and not the five fields: `src/lib/db/providers/sql/postgres.ts:2095-2099` returns + // Why the URI and not the five fields: `src/lib/db/providers/sql/postgres.ts:2070-2074` returns // `{ ...baseConfig, connectionString }` and NEVER reaches the host/port/user/database branch // below it, so these two records are byte-identical in every field the old frame hashed, `id` // included, and reach two different servers. diff --git a/tests/components/monitoring/PerformanceTab.test.tsx b/tests/components/monitoring/PerformanceTab.test.tsx index ac3575bf1..3e783d826 100644 --- a/tests/components/monitoring/PerformanceTab.test.tsx +++ b/tests/components/monitoring/PerformanceTab.test.tsx @@ -194,7 +194,7 @@ describe("PerformanceTab", () => { // Trino "holds no buffer pool" and "takes no locks, so there are no deadlocks to // count" (providers/sql/trino/introspect.ts:622-639), Cassandra and Druid omit the - // same two fields, and sqlite.ts:729 sets bufferPoolUsage undefined outright. The + // same two fields, and sqlite.ts:715 sets bufferPoolUsage undefined outright. The // panel used to answer those absences with "0 %"/"Poor" and "0"/"None // detected"/"Healthy" - a rating and a clean bill of health for measurements nobody // made. Same rule as the cache hit ratio card three lines above it. @@ -234,8 +234,8 @@ describe("PerformanceTab", () => { expect(card.className).not.toContain("warning"); }); - // The pin that keeps absence and zero from being collapsed back together: mongodb.ts:856, - // mysql.ts:855 and sqlite.ts:729 report a real measured 0, and that measurement must keep + // The pin that keeps absence and zero from being collapsed back together: mongodb.ts:850, + // mysql.ts:857 and sqlite.ts:715 report a real measured 0, and that measurement must keep // exactly the rendering it has today. test("keeps a measured zero rendering as a measured zero", () => { const { queryByText } = render( diff --git a/tests/components/monitoring/QueriesTab.test.tsx b/tests/components/monitoring/QueriesTab.test.tsx index a8be88c33..2b07283aa 100644 --- a/tests/components/monitoring/QueriesTab.test.tsx +++ b/tests/components/monitoring/QueriesTab.test.tsx @@ -199,7 +199,7 @@ describe("QueriesTab", () => { // "Queries 0 / Avg Time 0.00ms / Slow 0" while the list below correctly said no // statistics were available. Cassandra keeps no query log at all // (cassandra/index.ts:573-575), and neither do Druid (index.ts:486-488) or SQLite - // (sqlite.ts:734-737) - they answer `[]` by design. An average over an empty set is + // (sqlite.ts:720-723) - they answer `[]` by design. An average over an empty set is // not 0.00ms, and "Queries 0" claims a call total against the database rather than // counting visible rows, so all three figures render as absence instead. const { queryAllByText, queryByText } = render( diff --git a/tests/integration/db/oracle-provider.test.ts b/tests/integration/db/oracle-provider.test.ts index 690597ee6..e31e50023 100644 --- a/tests/integration/db/oracle-provider.test.ts +++ b/tests/integration/db/oracle-provider.test.ts @@ -2870,7 +2870,7 @@ describe("object surface", () => { // // The rows are the DRIVER'S rows and the naming is left to `oracle.ts`, which spells // a flat name BARE: the read is scoped to the connection owner by `WHERE OWNER = :1` - // (`oracle.ts:1516`), so the owner is a fact about the statement rather than a + // (`oracle.ts:1492`), so the owner is a fact about the statement rather than a // qualifier on the answer, and every name comes back unqualified against a // `[owner, name]` path. A fixture that returned `APP.APP_ORDERS` would assert a // spelling this provider never produces. diff --git a/tests/integration/db/postgres-provider.test.ts b/tests/integration/db/postgres-provider.test.ts index 9e4a448cb..32ae05ab6 100644 --- a/tests/integration/db/postgres-provider.test.ts +++ b/tests/integration/db/postgres-provider.test.ts @@ -3993,7 +3993,7 @@ describe("object surface", () => { // // The rows are the DRIVER'S rows and the naming is left to `postgres.ts`, which is // the whole point: the provider spells a name schema-qualified except in `public` - // (`postgres.ts:2047`), so a fixture that returned finished names would assert the + // (`postgres.ts:2022`), so a fixture that returned finished names would assert the // fixture's spelling rather than the engine's. `public.audit_log` is the spelling // PostgreSQL NEVER produces, and this epic has already taken a Critical for writing // it, so the `public` row is here to be stripped: it reaches the reading as a bare diff --git a/tests/unit/lib/db/connection-fingerprint.test.ts b/tests/unit/lib/db/connection-fingerprint.test.ts index 7d37ef405..731a8a891 100644 --- a/tests/unit/lib/db/connection-fingerprint.test.ts +++ b/tests/unit/lib/db/connection-fingerprint.test.ts @@ -60,7 +60,7 @@ describe("connectionFingerprint", () => { expect(await connectionFingerprint(vary({ user: "someone" }))).not.toBe(base); // The four an external review of PR #831 asked for. Each one, changed ALONE, sends the same // sealed plan somewhere else: `connectionString` overrides the field-by-field form outright - // (`postgres.ts:2095-2099`), `schema` is Trino's `X-Trino-Schema` submission header and is what + // (`postgres.ts:2070-2074`), `schema` is Trino's `X-Trino-Schema` submission header and is what // an unqualified name in the applied statement resolves against, `serviceName` is the tail of // Oracle's `host:port/service` connect string, and `instanceName` selects a MSSQL named // instance the Browser resolves to another process. @@ -168,7 +168,7 @@ describe("connectionFingerprint", () => { // // It is the population the digest exists for and the one it could not see. Every one of the // five original fields is IDENTICAL here, `id` included, so neither an id check nor the - // five-field frame separates them, while `postgres.ts:2095-2099` opens the URI and ignores all + // five-field frame separates them, while `postgres.ts:2070-2074` opens the URI and ignores all // five. The plan is sealed against the left-hand server and applied against the right-hand one. const ours = await connectionFingerprint(vary({ connectionString: "postgres://libredb@db.internal:5432/app" })); const theirs = await connectionFingerprint(vary({ connectionString: "postgres://libredb@evil.example:5432/app" })); @@ -223,8 +223,8 @@ describe("connectionFingerprint", () => { createdAt: BASE.createdAt, }; expect(await connectionFingerprint(bare)).toMatch(/^[0-9a-f]{64}$/); - // The other half: a URI-only connection, which is what `postgres.ts:2095-2099`, - // `mysql.ts:1973-1976` and `mongodb.ts:800-801` all open when the record carries one. Every + // The other half: a URI-only connection, which is what `postgres.ts:2070-2074`, + // `mysql.ts:1948-1951` and `mongodb.ts:794-795` all open when the record carries one. Every // one of the five original fields is absent on it and the URI is the entire address. const uriOnly: DatabaseConnection = { id: "conn-4",