feat(objects)!: a database object model with containers, kinds and a lazy tree across all 17 engines - #811
Merged
Merged
Conversation
…ed doc path Review findings on the object model types. The two new ProviderCapabilities doc comments cited docs/superpowers/specs/2026-09-11-database-object-model-design.md, which .gitignore ignores, so the path shipped in committed source and in dist/types.d.ts pointing every package consumer at a file no clone has. Both now cite issue #789. acceptsRowWrites took the whole ProviderCapabilities and answered only the per-kind half, so a caller reading it alone got a permissive answer the name did not warn about. Renamed to kindAcceptsRowWrites, which states the scope. The engine-wide supportsInlineRowEdit is deliberately not folded in: MongoDB, Couchbase and Cassandra declare it false and #789 declares a row-write kind on each of the three, so a conjunction would drop all three out of the import target list, and that flag has one reader, Studio.tsx:144, where it gates the inline row editor only. A test pins the ruling.
Four optional methods on DatabaseProvider (listContainers, countObjects,
listObjects, describeObject) and the shared helper that holds every provider
to the same four invariants, so each engine's task costs one call rather than
one copied test file.
Optional through Phase 1: providers land one at a time, and optional is also
what keeps an external implementer of the published interface compiling.
isCountUnavailable's predicate gains the readonly it was missing. Written as
`count is { unavailable: string }` it narrowed the true branch and nothing on
the false branch, because subtracting a union constituent uses the subtype
relation and that relation does check readonly modifiers. Every caller was
left holding the whole union with no `.count` on it, which is what the helper
hit first.
Refs #789
First working object surface for #789, and it closes #710: the four views docker/postgres-init/02-sample-data.sql creates in schema app were never shown, because the flat TableSchema[] model had one yes/no for "is this a table". Declares one container level (schema) and seven kinds: table, view, materialized_view, sequence, function, procedure and trigger. No index kind: pg_index is keyed by indrelid, so an index is a property of its relation and stays in describeObject's output. Implements listContainers, countObjects, listObjects and describeObject. countObjects seeds every declared kind at { count: 0 } before the read, so a folder the engine has and this schema holds none of renders a zero rather than disappearing, and a refused read carries the server's own sentence instead of a zero nobody measured. A 42703 naming pg_proc.prokind re-runs the statement without the routine arm, so a fork without that PostgreSQL 11 column loses two folders rather than the whole schema. Two measured departures from the design sketch, both on the live seed: - describeObject reads columns from pg_attribute, not CTE_COLUMNS_INFO. information_schema.columns is defined over relkinds r, v, f and p only, so it answered 0 columns for app.revenue_by_month (m) and 0 for app.invoice_number_seq (S) where pg_attribute answered 2 and 3. The primary key, foreign key and index CTEs are reused. format_type(atttypid, NULL) was checked column by column against information_schema.columns.data_type on app.orders and is identical, so both surfaces name a type the same way. - OBJECT_DETAIL_SQL strips AS MATERIALIZED. The hint stops the planner pushing $1/$2 into the CTEs, so a one-object read computes every constraint and index in the database: EXPLAIN total cost 3416.66 with the hints against 122.99 without, on the 10-table fixture. listObjects does not use the shared withoutTotalRelationSizeFn(): its literal 0 would report every relation on CockroachDB and Materialize as 0 bytes, and both are reached under the postgres type id. It retries with the size column removed instead, so the size reads as absent. The seed fixture gains a materialized view, a function, a procedure, a trigger and a sequence in schema app. It reads app.orders.total_amount; the sketch's app.orders.total does not exist. Verified end to end against a fresh postgres:18 volume with the init scripts mounted: two containers, and counts of 10 tables, 4 views, 1 materialized view, 11 sequences, 2 functions, 1 procedure and 1 trigger, matching the catalog row for row. docs/providers/postgres.md gains section 3.1.4 and loses its duplicate 3.1.1.
…sing vacuously
Two faces of one change, so they land together rather than leaving the helper
and the provider disagreeing in between.
Object identity. DatabaseObject.path's last segment is now the identifier that
is unique WITHIN ITS PARENT, and name is the display label, no longer required
to equal it. A kind declaring attachedTo nests under what it is attached to, so
a PostgreSQL trigger is [schema, table, trigger]: a trigger name is unique per
table, and [schema, trigger] gave two triggers on two tables one address. A
routine's segment carries the engine's own disambiguated form from
pg_get_function_identity_arguments(), so two overloads are two addresses instead
of one row with SELECT DISTINCT proname hiding the other. That function is used
because its output is exactly what ALTER FUNCTION and DROP FUNCTION accept, so
the segment round-trips to the engine. Measured on postgres:18: it renders the
parameter name and mode too, so the seed answers order_total(order_id integer)
and touch_order(IN order_id integer), not the bare type lists the design sketch
illustrated. Stripping the names would be assembling a form of our own.
The path is built from the ROW, not from the kind id: a parent column adds a
segment, an identity column replaces the last one. Listings now sort by path,
because two overloads share a name and a name sort left their order to the
catalog. describeObject accepts two or three segments, and answers three empty
lists for a three-segment path WITHOUT asking the server -- a correctness fix,
since the detail statement keys the last segment against pg_class.relname and a
trigger named orders on table customers would have been handed app.orders's 23
columns as its own.
Conformance helper. Invariants 3 and 4 passed vacuously when listObjects
returned nothing: the path loop iterated zero times and describeObject was
handed a path the test author typed, so a provider declaring view, reporting
{view: {count: 4}} and listing none was certified. The sample object is now
looked up in the array the provider returned and THAT object's path is what
describeObject is given, with a failure naming the path and the kind when it is
absent. The name-equals-last-segment assertion, which the identity change makes
false, is replaced by the invariant a tree actually needs: no two objects in one
listing share a path. The same hole one level up is closed by requiring at least
one declared kind and at least one expected kind, both derived from emptiness
rather than pinned to a count.
Seven tests pin the helper fix and all seven fail against the previous version.
database-compose.yml's postgres service now mounts docker/postgres-init, so the
object-tree fixture applies through this repo's own compose file instead of
being dead weight. Mount only; the container-name and port collisions stay in
GH-792. Verified end to end on a throwaway container derived from that service,
created and removed for the purpose: no running container was touched.
…t the kind Two corrections to the object identity landed in 6dd5d98. A routine's path segment no longer carries parameter names. Overloads differ by argument TYPES and never by parameter names, so a name adds nothing to identity while making the identity change when somebody renames a parameter, and a segment carrying information irrelevant to identity is wrong even where it round-trips through DROP. pg_get_function_identity_arguments() renders those names, measured on postgres:18 as order_total(order_id integer) and touch_order(IN order_id integer), so it is replaced by an explicit format_type() list over proargtypes: order_total(integer), touch_order(integer), stamp_updated_at(). That form is oid::regprocedure without the schema qualification, which matters because regprocedure prepends a schema the path already carries. Measured over all 3402 routines in pg_catalog the two agree on 3315; the 87 that differ are every case where regprocedure double-quotes a reserved-word routine NAME ("char"(integer), "position"(text,text)), with identical argument lists. Quoting is a fact about SQL text and a path segment is data. Uniqueness was checked rather than assumed: no two routines share a segment in any schema on that server. The COALESCE is load-bearing, since array_to_string over an empty array answers NULL and a zero-argument routine would otherwise have no address. describeObject now takes the kind, on DatabaseProvider and in the conformance helper. Only the kinds this provider resolves in pg_class have columns, indexes or foreign keys, so a routine and a trigger answer three empty lists with no round trip. That was already the output, but by accident: the detail statement keys the last path segment against pg_class.relname, so order_total(integer) answered nothing only because no relation is called that, and a trigger named orders on table customers would have been handed app.orders's 23 columns as its own. Path depth is derived from the declaration too, two segments plus one where the kind declares attachedTo. An undeclared kind is refused rather than described. Listing a routine's parameters is Phase 2 and is not done here. Both behaviours were checked by mutation, not just by a green run: restoring the old segment expression fails exactly one test, and deleting the kind-driven short circuit fails exactly one other.
…e what was hardcoded The vacuity a third time, one kind narrower. listObjects was called once, for the sample's kind, so invariants 3, 4 and 5 never touched the others and a provider declaring seven kinds, counting seven and returning an empty array from six was still certified. Every kind the expectation counts above zero is now listed and must answer something; a kind expected to hold zero is skipped, because listing nothing is correct there. Path uniqueness now spans the whole set of kinds rather than one listing, since a tree keys its nodes by path. The array length is deliberately not compared against the count: a count and a listing are two reads at two instants and pinning the magnitude would make the helper flaky rather than strict. Seven of the helper's tests fail when the new loop is deleted. The provider's own conformance test passes either way, which is the point: a correct provider passes a weak helper too, so the strengthening has to be pinned where the helper is tested. The brief's mock did have to change, and the new invariant is what caught it: it answered one row for every relkind query, so table, view and materialized_view all claimed the path ["app","order_summary"]. DatabaseObject.path's doc comment still prescribed pg_get_function_identity_arguments() and credited it with a form it does not produce. c9e942f removed that function for embedding parameter names, and this is the shared type doc every provider reads. describeObject no longer hardcodes the container depth. It is containerLevels.length + 1, plus one for an attached kind, and the segment names in the refusal message come from the same array as the depth so the two cannot disagree. Five of the seventeen engines are two-level and would have refused every valid path. listObjects and describeObject now answer "is this kind declared" from findKind alone. Deciding it from whether a listing statement exists made them two sources of truth, and would have reported "declares no object kind" about a kind objectKinds does declare. The size retry's own failure goes through mapDatabaseError like every other error exit in the file, quoting the rewritten statement the server actually received. Both of those lines are covered by tests: the raw lcov reports zero uncovered lines in postgres.ts, which the rounded summary reporter hid at 100.00 while two lines were still cold. Two em dashes removed from the capability rows added earlier.
Six POST routes under /api/db/objects expose the provider object surface from #789 over HTTP: containers, counts, list, describe, search and inventory. One shared handler, src/lib/api/object-route.ts, on the precedent of schema-route.ts. guardRoute runs before the body is parsed, so an unauthenticated caller never has a body parsed on its behalf and the rate limiter sees the request before any work is done for it. Three rules the routes enforce above the provider: - A container path deeper than containerDepth(capabilities) is a 400 before the provider is called, not a provider error. A path exactly at the declared depth is passed through, because "this level has no children" is an answer an engine is allowed to give. - A provider that does not implement one of the four optional methods answers 501 naming the method and the engine. Every engine except PostgreSQL is in that state today, and an empty 200 would render an unmigrated engine as an engine with no objects. - inventory reads at most 5000 objects and reports truncated: { limit, reason } when it stops. The limit is a constant in the route and never a caller parameter, and the bound is on the scan rather than only on the response. describe takes kind in its body, because describeObject(path, kind) is the pinned provider signature: without the kind a provider has to guess what it is holding from what the path's last segment matches in a catalog. search is deliberately uncapped. A cap on the scan would produce the false-negative results the server-side search exists to prevent, and a cap on the response would bound nothing, because every container and kind has already been listed by the time the filter runs.
…ne kind Asserting uniqueness across kinds was stricter than DatabaseObject.path's own contract, which promises a segment unique within its PARENT and never promised uniqueness against a different kind's namespace. It is also stricter than anything downstream needs: a tree row is identified by path plus kind id, which is the same reason describeObject takes the kind. It would have failed correct engines. MySQL keeps stored routines in a namespace separate from its tables, so a table and a procedure may share a name in one schema, and the cross-kind assertion would have reported that as a provider defect rather than as this helper's choice. Task 10 confirms it against its own fixture. The helper's comment now names the row-identity reason so nobody tightens it back. Both directions are pinned and both were mutation-checked, because a relaxation is exactly the change that can be written so nothing fails either way. Tightening it back to across-kinds fails "accepts one path answered by two different kinds"; deleting the within-kind check fails two tests, including "still rejects two objects of the SAME kind sharing a path".
The 5000-object limit stopped an unbounded read; it did not stop unbounded work. A body naming fifty thousand container paths, duplicates included, bought fifty thousand sequential listObjects round trips, each taking a pool client, under one rate-limit token, and every one of them could legitimately answer zero objects so the object budget never advanced. Nothing else in this app limits a request body. - INVENTORY_PAIR_LIMIT caps the listings one inventory read issues, and an overflow reports truncated the same way an object overflow does. Reported rather than refused because the enumerated case is not a caller mistake, and one answer shape for "this inventory is incomplete" is all its reader needs. - containers is deduplicated before the fan-out is built, so a repeated path cannot buy a second round trip per kind. - containers: [] is now a 400 naming the field. Absent, empty and non-empty are three different requests, and answering the empty one with an empty inventory and a 200 was indistinguishable from an empty database. includeColumns is removed. It was one describeObject per object, up to 5000 sequential round trips. Task 24 owns re-introducing bulk column reading with a measured design once the agent's grounding requirement is known, rather than a fifth bulk provider method invented across seventeen providers first. Also: the 501 body carries OBJECT_SURFACE_UNIMPLEMENTED, so the tree can render "not migrated yet" without keying on the HTTP status alone; requireString trims, so a kind no longer reaches a catalog lookup with its surrounding space while a search term two files away is stripped; and the inventory comment now states what the limits actually bound. One listing is still materialised in full, which needs a limit argument on the provider method and belongs to Task 24.
The pure half of the object tree (#789). One walk turns the expansion state into a flat row list, each row carrying its own aria-level, aria-setsize and aria-posinset, so the virtualised row component takes all four numbers verbatim instead of deriving them from what is mounted. A row id is its path joined with "/" plus the kind id on folders and objects, because paths are unique within a kind and deliberately not across kinds. A folder is drawn by the declaration and badged by countObjects, never by a loaded list's length, and a refused count makes the folder a leaf carrying the engine's own sentence. An object row is labelled by DatabaseObject.name, which an overloaded routine does not share with its path segment. FlattenTreeState takes the provider's containerLevels so the walk serves all three container shapes: none puts the kind folders at the root, one is the default and unchanged, two nests the schemas under their catalog and gives folders only to the deeper level.
…objects as leaves Two review findings on the flattening walk (#789). The depth was read as `containerLevels?.length ?? 1`, which contradicts the field's own contract: `ProviderCapabilities.containerLevels` says absent and empty both mean the engine has NO container level, and says to read it through `containerDepth()` so two callers cannot answer those cases differently. The tree therefore disagreed with the object routes, which already call that helper, and a provider that follows the contract by omitting the field rendered an empty tree with no error. The state now carries `containerDepth`, typed as that helper's return type and required, so this module answers nothing the helper is there to answer, and the fixtures declare their level explicitly. Object rows are leaves in Phase 1, even for a kind declaring childKinds. The declaration is true, an Oracle package does hold routines, but countObjects and listObjects are both container-scoped and nothing lists the children of one object, so a nested folder would draw, never badge and open on nothing. The nesting lands with the method that can fill it.
…confinement Closes #765. Task 9 of #789. Oracle showed exactly one schema because every dictionary read in the provider is bound to OWNER = <connecting user>, and getSchema() pulled five bulk ALL_* reads before the UI could paint. listContainers() reads ALL_USERS and is bound to nothing; countObjects() is one statement over ALL_OBJECTS that reads no column of any table. Measured on Oracle Database 21c XE against the SYS owner (1,672 tables, 113,264 columns), the nearest thing on a laptop to the reporter's PeopleSoft instance: getSchema() on connect 5 statements 121,462 rows 1,490 ms listContainers + countObjects 2 statements 12 rows 72 ms Verified from the server rather than the client: after a flushed shared pool, a connect plus first paint leaves three statements in V$SQL for the app user and zero touching ALL_TAB_COLUMNS or ALL_IND_COLUMNS. One describeObject() call then puts two there, which is the control. Nine kinds, all from ALL_OBJECTS.OBJECT_TYPE. Two exclusions inside the counting statement, both measured: PACKAGE BODY is a second dictionary row for one tree node, and CREATE MATERIALIZED VIEW writes a TABLE row for its container, so an owner with 100 materialized views would report 100 tables nobody wrote. The container is found without a second dictionary view, because a table and a materialized view cannot share a name in one owner (ORA-00955). A package's specification and body collapse into one object whose status is INVALID when either half is. The fixture ships a package whose body deliberately does not compile, which is the state Oracle is in most often. A trigger nests under the object it fires on, including the two cases that are not a table in this owner: a base table in another owner keeps its own owner's container, and a SCHEMA trigger has no base object and takes a two-segment path. No disambiguator on the last path segment. Measured: CREATE OR REPLACE FUNCTION with a different argument list REPLACES the function rather than overloading it, and a function cannot share a name with a table (ORA-00955).
The rendering half of the object tree (#789). ObjectTree draws the rows flattenTree produces, TreeRow takes all four ARIA numbers verbatim from the row model, and useTreeNodes owns the cache and the reads. Reads are derived rather than issued: a read is wanted when a row is open, its cache slot is absent and it carries no failure, and that one list drives the fetch, aria-busy and the retry. Absent and empty stay different states in every map, so a folder the engine answered as empty is loaded rather than spinning. Nothing writes state synchronously from an effect, which react/set-state-in-effect forbids here, so no loading flag is stored at all. The four absence states render as four outcomes: an undeclared kind draws no folder, a zero count draws a zero badge, a refused count draws the engine's own sentence on a row that cannot be opened, and a 501 OBJECT_SURFACE_UNIMPLEMENTED draws "this engine is not wired up yet" with no retry, which is distinct from an engine that answered nothing. Sixteen of seventeen engines are in that state. Windowed by hand with a fixed row height rather than by a library: react-window would be a new dependency and @tanstack/react-virtual has to be replaced with mock.module in component tests, which would make every row assertion here vacuous. The window is shifted to contain the focused row, so End can focus the last of forty thousand rows. Container depth comes from containerDepth(capabilities) and nothing reads containerLevels by length, so an engine with no container level is asked for the counts of its one empty-path container instead of for a container list. An object row is a leaf in Phase 1.
…owner-bound read Review round on #789 task 9. Two shape-level findings, both of which fifteen providers would have copied. Standing ruling 5f requires a listing to contain exactly what the count counted. The trigger count read ALL_OBJECTS while the listing read ALL_TRIGGERS with an inner join back, and the two views do not expose the same population: measured on 21c XE, a user holding nothing but CREATE SESSION and one SELECT grant sees 83 rows in ALL_TRIGGERS and 0 in ALL_OBJECTS, because ALL_OBJECTS answers by privilege on the object while ALL_TRIGGERS also answers by accessibility of the base table. The inner join therefore computed an intersection, which can only be a subset of the badge. The listing is now driven from ALL_OBJECTS with ALL_TRIGGERS outer joined for the parent segment alone, so a trigger whose base table is invisible lists at two segments instead of vanishing. Verified live: 4 counted, 4 listed, on both the session's owner and a foreign one. The owner bind was pinned for listObjects only, so replacing the container with this.config.user.toUpperCase() in countObjects or describeObject left the whole suite green. That is the regression #765 must never take again. One test now pins all three methods against a container that is deliberately not the connecting user, and the detail test moved to a cross-owner object; both mutations go red. Also: the object's own name in describeObject is now the LAST path segment rather than path[1], which is the same string at depth 1 and a container segment on the five two-level engines that copy this file; containerOwner reads its expected depth through containerDepth() instead of a hardcoded 1; and the two statements that name a dictionary type in their own text derive it from ORACLE_OBJECT_TYPES like every other read. The doc records that an Oracle-maintained owner other than the session user, SYSTEM included, is not browsable, with the measurement behind it.
…ved from the server Fourth provider on #789's object model, and the first whose `objectKinds` is not a constant. This provider serves MariaDB as well, `DatabaseType` has no `mariadb` entry, and MariaDB has two kinds MySQL does not have at all, so the declaration is resolved from the server's own `VERSION()` string, measured once per connect beside the EXPLAIN grammar probe. Six kinds on MySQL (table, view, procedure, function, trigger, event) and eight on MariaDB (plus package and sequence). An unconnected provider answers the MySQL six, which is what `POST /api/db/provider-meta` reads. `listContainers` reads `information_schema.SCHEMATA` bound to nothing, which ends the single-database confinement `getSchema()` has: MySQL resolves a qualified name across databases on one connection, so every database the server holds is browsable from one session. `countObjects` is one statement over four `information_schema` views, and the same text goes to both servers because the data decides which CASE arms fire. `describeObject` takes the kind and reads three narrow statements, each bound to one database and one object. No `index` kind: MySQL models an index as an attribute of its table, so it stays in `describeObject`'s output. The measurement #789 owed, taken on MySQL 26.7.0 and MariaDB 12.3.2. A table, a procedure, a function, a trigger and an event of ONE name coexist in one database; only a view collides with the table (ER 1050), and on MariaDB a sequence collides too because a sequence is a table underneath. So a path is unique within a kind and deliberately not across kinds, which is what the conformance helper asserts. The fixture ships the pair as `app.order_archive`, a table and a stored procedure. Fixtures for both servers are mounted by the compose services. Verified live end to end through mysql2 against both, plus 29 mutations run against the new logic with no survivors. Filed D52: MariaDB reports a column default as an expression where MySQL reports a value, so a nullable MariaDB column with no default reads as having the default `NULL`. `getSchema()` has it too over the same view, and repairing one surface alone would make the two disagree about one column. Refs #789
…ody it cannot render Review round 1 on #789's tree component. The tree lost its only tab stop whenever the active row scrolled out of the window: the container was permanently tabIndex -1, the sole tabIndex 0 sat on the active row, and a wheel scroll both unmounted that row and released the window's clamp, so nothing in the subtree could take focus and the arrow handler sat on an element that could not hold it. The container now holds the tab stop exactly while the active row is outside the mounted window, and focusing it re-pins that row, remounts it and hands focus on. Both bounds of "outside" are load-bearing: End then a scroll back to the top puts the active row past the window's end rather than before its start. The focus handler ignores focus that bubbled from a row, so revealing a row by focusing it is not pulled back to whatever was active. A 501 on ONE read is no longer drawn as a generic error. OBJECT_SURFACE_UNIMPLEMENTED reaches a single row whenever a provider implements one object method and not the next, which is the state each remaining provider task passes through, and it now reads as unmigrated rather than as something being wrong. A response body of the wrong shape is reported instead of rendered. The cost of trusting the cast was not a degraded row: a wrong shape throws inside flattenTree during the render, which unmounts the tree along with every panel that could have reported it. The guard checks what the walk dereferences, an array of objects carrying an array path, or a counts record whose values are objects, since Array.isArray alone passes [null] and "unavailable" in 5 throws. treeWindow is tested directly rather than only through the component, which is what its export is for: dropping the overscan is invisible to every component test and fails five of the new cases.
…ived forms Review fix round for #789's MySQL object surface. The counting CASE and the listing binds were derived from `SELECT DISTINCT TABLE_TYPE` over the seeded fixture, which enumerates the fixture. Measured on MariaDB 12.3.2, a system-versioned table reports `TABLE_TYPE = 'SYSTEM VERSIONED'`, which no CASE arm and no bind named, so such a table fell out of BOTH the count and the listing: the two still agreed and the table was invisible in the tree with every gate passing. The vocabulary now enumerates the engine, built by creating a table for each case rather than by reading a fixture, and a kind may carry several spellings. `SYSTEM VERSIONED` maps to `table`, because system versioning is a property of a table you still select from; the listing binds `TABLE_TYPE IN (?, ?)` with the placeholder count sized from the same table the CASE arms come from. `TEMPORARY` stays excluded and now has the measurement behind it: a temporary table is listed by the session that created it and by no other, and this provider hands out a different pooled connection per call, so a Temporary folder would badge what one connection held, list what another held, and hand out addresses that resolve on one connection and not the next. Two derivations written in the forbidden spelling, both behaviour-identical at depth 1 and silently wrong at depth 2. `containerSchema` now reads the depth through `containerDepth()` and names the declared levels in its message; the detail reads bind `path[path.length - 1]` for the object's own name rather than `path[1]`. Both are now PINNED here rather than deferred: one test hands this provider a two-level declaration, where the hardcoded depth accepts a path it must refuse and the positional bind narrows to a container segment. Two shapes thirteen providers would have copied. Kind membership is tested with `Object.hasOwn` rather than `in`, which walked the prototype chain, so a catalog row whose kind read `toString` would have drawn a folder. Paths sort segment by segment rather than by `JSON.stringify`, which put a deeper path first at mixed depth because `,` is below `]`, and which reordered names JSON escapes. D52 is now issue #795 and its backlog entry is removed, since the defect is live today through `getSchema()`. 165 tests, 37 mutations with no survivors, `LF:856 LH:856` from raw lcov. Refs #789
…d the catalog vocabulary live Fix round 2 for #789's MySQL object surface. Three more positional reads, the general form of the two fixed last round. `containerSchema` returned `container[0]`, `describeObject` bound `path[0]` as the schema, and the foreign-key comparison used `path[0]` to decide whether a reference leaves the container. All three are depth-identical on MySQL and wrong on the five two-level engines that copy this file, where `path[0]` is the CATALOG: binding it as the schema narrows all three detail reads to a database that does not exist, and comparing against it qualifies every reference. A level's POSITION is a property of the declaration, so `containerSegment()` now finds the segment belonging to a declared `ContainerLevelSpec.id` and raises when the declaration has no such level or the path is too short to carry it. `declaredLevels()` is the one reader of the depth. The two-level test now REACHES the binds instead of stopping at the refusal, which is why this class survived two providers and a review round. With `[catalog, schema]` declared it asserts the three detail reads bind the SCHEMA segment, and that a same-schema foreign key stays bare while a cross-schema one is qualified. A second test covers a declaration carrying no schema level at all. The TEMPORARY exclusion was right in reasoning and weak in expression: an absence from a table plus prose detects nothing, and undetected absence is exactly how SYSTEM VERSIONED hid. The rules are now a value, `CATALOG_TYPE_RULES`, whose modelled half is derived from `MYSQL_OBJECT_TYPES` and whose excluded half is a map of spelling to reason, so an exclusion cannot be added without one. `tests/live/mysql-object- vocabulary.ts` asks a live server for its own `SELECT DISTINCT TABLE_TYPE` and `ROUTINE_TYPE` and exits non-zero naming anything outside that set. It is an opt-in live script, not collected by any test runner, run by hand and in #789's live acceptance; the provider doc says so and says what it cannot see. The MariaDB fixture gained a `WITH SYSTEM VERSIONING` table, because the guard reports only the spellings a server's data exhibits. That also puts the multi-spelling `table` kind through the MariaDB conformance run end to end rather than only a unit assertion. 167 tests, 44 mutations with no survivors, `LF:893 LH:893` from raw lcov. Verified against live MySQL 26.7.0 and MariaDB 12.3.2, including the control that the guard fails by name when a modelled spelling is removed. Refs #789
#789's SQL Server provider: `listContainers`, `countObjects`, `listObjects` and `describeObject` over `sys.databases`, `sys.schemas`, `sys.objects` and `sys.triggers`, plus seven declared kinds and the two container levels a SQL Server instance really has. `getSchema()` stays live through Phase 1. SQL Server is the first engine in the epic with a catalog AND a schema, so it is the first place standing ruling 5g's three spellings are actually wrong rather than depth-identical, and this task owns pinning them for the fleet. A path becomes named segments through `containerSegments()`, which cuts it to `containerDepth()` and keys each segment by its declared `ContainerLevelSpec.id`; nothing reads `container[0]`, `path[0]` or `path[1]`, the object's name is the LAST segment, and a level the declaration does not carry raises instead of interpolating `undefined` into a three-part name. The tests reach the BINDS rather than stopping at the refusal: the four detail reads assert `{ schema, name }` for `app.orders` and again for `reporting.daily`, where the catalog, the schema and the name are three different strings. Mutating the name bind to `path[1]`, the schema bind to `path[0]`, the catalog to `path[1]`, the depth to a literal 1 or 2 and the segment lookup to "the first segment" each turn the suite red. A trigger is counted and listed from `sys.triggers`, never from `sys.objects`, because a DDL trigger is absent from `sys.objects` entirely: measured on the fixture, `sys.objects` holds 2 triggers where `sys.triggers` holds 4. Ruling 5f decides the consequence: a DATABASE-scoped DDL trigger has no schema and no base object, so it sits at `[database, name]` while a DML trigger sits at `[database, schema, table, name]`, and one folder holds both depths because the count counted both. The type vocabulary is derived from Microsoft's documented `sys.objects.type` set with a decision recorded for every spelling, not from the fixture (ruling 5a): `SELECT DISTINCT type` over the fixture server answers eighteen spellings and none of the six CLR ones, so a fixture-derived vocabulary would have dropped a CLR procedure out of the count and the listing both. Four user object types have no declared kind and are recorded as known gaps. The fixture carries a system-versioned temporal pair, which is the same shape as the MariaDB table that went missing in task 10; both halves are `type = 'U'` and both are counted and listed. Paths are ordered segment by segment and never through `JSON.stringify`, which sorts a deeper path before its own prefix and re-orders exotic names on characters JSON invented. The kind lookup is `Object.hasOwn`, so a kind id reading `toString` cannot resolve off the prototype chain. Azure SQL Database cannot run a cross-database query, so `EngineEdition = 5` lists exactly the connected database. That arm is UNVERIFIED against Azure and the provider doc says so; it was probed by inverting the edition it tests, which collapses the list to one row on a server that is not Azure. The fixture has no compose mount because the image has no init-script directory at all; the provider doc carries the two commands that apply it. 141 tests, 54 of 55 mutations killed with the survivor named in the report, `LF:944 LH:944` from raw lcov. Verified against SQL Server 2022 CU26 on Linux.
…pe hatch The sidebar renders the object tree instead of the flat schema explorer (#789), and a connection can now decline the catalog read entirely (#765). First paint makes exactly two catalog reads on an engine that names its session container: the container list, then the kind counts of that one container, which is opened. The container comes from the engine through `Container.isSessionDefault`, so PostgreSQL, which deliberately publishes no such fact because a `search_path` names several schemas, reads once and opens nothing rather than being guessed at. A two-level engine descends one level further, which is three reads and not two: the counts of the active container cannot be reached without listing the level between. `DatabaseConnection.skipObjectScan` means zero catalog reads when the connection opens. The panel names the connection and offers a load action; the editor and query execution are untouched. The rule has ONE reader, `fetchSchema` in `use-connection-manager.ts`, so the statement-refresh path and both shells cannot disagree about it, and the reader's request is held as the connection's id rather than as a boolean so the next deferred connection is not already loaded. The tree now posts `buildConnectionPayload` output rather than a bare `connectionId`, which is what every other db route does and the only way a connection the server has never heard of can be read at all. A click on an object row is gated on the kind's declared role: the click generates a query and EXECUTES it, so a routine reaching it would run `SELECT * FROM order_total(integer)` against the database. Two findings are filed rather than fixed here: U22, the six per-table actions that lost their entry point with the explorer, and U23, a container named `a/b` taking the same row id as a folder.
… found Review round 1 for #789's SQL Server object surface. The documentation defect first, because it is the only place a future reader learns the sum rule. The measured count table said 3 tables for the database and 2 for `app`, which was the fixture before the system-versioned temporal pair was added to it: the pair is exactly what the stale numbers lost, and the section forty lines below says both halves are counted and listed. It now reads 5 and 4, matching the fixture, the test expectations and the live probe, and says which four are in `app`. The same drift in a test comment above an assertion of 4 is gone with it. M52 was reported as an unmutatable derivation last round and it was a fixture I had not varied: `objectShapes` filtering for the level whose id is `catalog` differs from taking the first level only in what reaches the refusal message, and a declaration listing `schema` before `catalog` makes that difference visible. The test spies exactly that declaration and asserts the trigger refusal spells `[database, name]` rather than `[schema, name]`, so the battery now has no survivors. The same helper had a shape that is unreachable here and reachable in any one-level provider that copies it: with no catalog level declared, the filter spread to nothing and left `["name"]`, a container-less single segment accepted for an attached kind. The shape is pushed only when the filter is non-empty, and the second half of the new test covers a schema-only declaration. `measuredRowCount`'s arms were folded onto shared lines, and unfolding them was not enough: measured on bun 1.4.2, raw lcov reported `DA:...,9` for both `return undefined` lines while replacing either one changed nothing in the suite. That is standing ruling 5b's warning in a stronger form than "a folded arm is invisible" - the line counter reported hits for arms that never ran. A listing fixture now carries a NULL row count, which is engine-reachable through the `LEFT JOIN sys.partitions`, and an unparseable one, which is a driver-shape guard, and both are pinned by mutation rather than by the coverage number. `countObjects`'s catch covered the row mapping as well as the read, so a fault of ours would have rendered as SQL Server's own refusal sentence against every kind - and that sentence is shown to a person verbatim. The catch now covers the query alone, with a test driving a recordset the mapping cannot iterate. 144 tests, 59 mutations with no survivors, `LF:954 LH:954` from raw lcov. Re-verified against SQL Server 2022 CU26.
…engine SQLite declares no container level at all, so `containerLevels` is `[]`, `containerDepth()` answers 0 and `listContainers()` answers `[]`. That is the engine speaking rather than a refusal: a connection opens one database file and every object in it is addressed by a bare name, so the tree draws the kind folders at the root. No synthetic `main` container is invented to make the shape match the other engines. Four kinds: table, view, index and trigger. No routine of any spelling, because an application-defined SQLite function is registered by the host process and never written to the file. `index` IS declared, unlike on postgres, mysql, mssql and oracle: an index here is a row in `sqlite_schema` beside the tables, sharing one namespace with them (measured: `CREATE INDEX t ON u(id)` against a table `t` answers "there is already a table named t"). Counts and listings read `PRAGMA table_list` rather than scanning `sqlite_schema`, which separates a real table from the shadow tables an FTS5 or R-Tree module owns. Measured on the new fixture, a naive scan answers 12 tables where the engine holds 6. Its `type` vocabulary is taken from SQLite's own documentation and the fixture holds all four values, so a guard test fails by name the day a fifth appears instead of it falling silently out of both the count and the listing. Scope is the `main` schema. `temp` and an ATTACHed database are session state that a declaration read off an unconnected provider cannot describe, and measured, an unrestricted listing gives two objects one path when a temp table shadows a real one. The schema is bound into every pragma read: with a temp `main_only` live, the one-argument `pragma_table_info` answers about the temp table and the two-argument form answers about the file. `describeObject` reads for the two relation kinds only, through `pragma_table_xinfo` filtered to `hidden <> 1`, so a generated column is not dropped the way `table_info` drops it, and `isPrimary` is `pk > 0` because `pk` is a 1-based rank. A foreign key that names no column resolves to the parent's primary key rather than putting a null in a typed string field. Verified end to end at depth 0: the provider's own answers drive `flattenTree` to four root folders with badges and no container row, and the six HTTP object routes answer for a sqlite connection. Exercised under both drivers, bun:sqlite in process and node:sqlite in the harness subprocess. Refs #789
…erred Review round 1 on Task 7 (#789, #765). One Critical, two Important, one Minor. CRITICAL. `fetchSchema` returned before `readSchema`, which is the only writer of `schema` and `schemaError`, so opening a connection that scans and then switching to a deferred one left the first connection's tables on screen and in `schemaContext`, which is what the AI panels and the agent rail are handed. That is D31, already stated in this file, and the grounding failure class #414 measured. Both are cleared before returning. The old assertion could not catch it: `schema` is `[]` from mount, so asserting emptiness after a deferred read proved nothing. The test now scans connection A first. IMPORTANT 1. The escape hatch did nothing in the embedded shell. `WorkspaceConnection` takes `skipObjectScan`, the adapter maps it, derives the same deferral and offers `loadObjects`, and `StudioWorkspace` hands both to the tree. The rule is written twice on purpose: the two hooks share no state and no request layer, one reading studio's routes and the other calling back into the host. IMPORTANT 2. First paint's invariant is "no object names and no columns", not the literal number two: it walks the container chain to the session default at the DEEPEST declared level, which is two reads on a one-level engine and three on a two-level one. The two-level fixture was describing an engine that does not exist, so it is now SQL Server's own shape, with the session default marked at the connected catalog only. That needed one declaration change in the mssql provider, whose task is closed: its schema level now marks `isSessionDefault` from `SCHEMA_NAME()`, restricted to the connected database by `DB_NAME()`, because `SCHEMA_NAME()` answers for the database the session is in whichever catalog the statement is named at. Doc and provider test move with it. MINOR. The tree no longer sits in the sidebar's `ScrollArea` behind a fixed `h-[60vh]`: a virtualised list inside a second scroller measures a box with no bottom, which is the height Task 6's windowing reads.
DuckDB declares two container levels, catalog and schema, both real: ATTACH puts another whole catalog in the same session and a three-part name reaches into it. Four kinds, one duckdb_* table function behind each: table, view, macro and sequence. No trigger and no stored procedure, because the engine has neither, and no index kind, because duckdb_indexes() is keyed by table_oid. The macro vocabulary is derived from DuckDB's two documented macro forms and guarded against the live engine's own SELECT DISTINCT function_type, so a future spelling fails by name instead of falling out of the count and the listing together. The count and the listing for one kind are built from one record, so the listing holds exactly what the count counted by construction. Every path segment is named by its declared ContainerLevelSpec.id, never by position. The pin swaps the two levels through spyOn on getCapabilities and drives it to a bound value against a real engine over a crossed fixture, which is the case the SQL Server task recorded as unreachable there. listContainers marks isSessionDefault at BOTH levels, so first paint walks the chain to a schema instead of opening a database and stopping. Measured on DuckDB v1.5.5: duckdb_schemas().internal is TRUE for main in a user database, so the schema listing must not filter on it; a PRIMARY KEY writes no duckdb_indexes() row; a foreign key never crosses a schema; a table, a sequence and a macro can share one name in one schema; and macros are not overloaded. Refs #789
A tree row id was its path segments joined with a slash, and a container named `a/b` therefore took the same id as the `b` folder of container `a`. Both are legal quoted identifiers on PostgreSQL, MySQL and Oracle. The id is React's list key, the expansion-set member and the objects cache key, so the collision made one row's twisty open the other and React warn about duplicate keys. One encoder now builds every id and every cache key: each segment escapes `%` first and then `/`, which is what makes it injective rather than merely different in the reported case. `use-tree-nodes.ts` reads that same function instead of holding its own join, so the counts key and the row id cannot drift. An ordinary identifier holds neither character, so `app/table` is unchanged. Closes U23.
Four container-aware methods for the `libsql` type-id (#789): `listContainers`, `countObjects`, `listObjects` and `describeObject`, in a new `objects.ts` beside the existing transport and introspection modules. Zero container levels, and the same four kinds as SQLite: table, view, index, trigger. The catalog decisions are SQLite's and are reused rather than re-derived, because `PRAGMA table_list` and `sqlite_schema` answer over Hrana exactly as they do on a file. What is not reused is the SHAPE of the reads: every read here is an HTTP request, so a relation is described in two batched round trips rather than one request per index and per foreign-key parent. Measured against ghcr.io/tursodatabase/libsql-server:v0.24.33, the image database-compose.yml pins (sqld 0.24.33 40a151bd, SQLite 3.45.1, not the 3.47.0 recorded for :latest): - `PRAGMA table_list` answers over Hrana, so an FTS5 table's five shadow tables are separated from the one object a user selects from. A naive sqlite_schema scan answers 15 on the fixture where the engine holds 9. - No `function` kind is declared. `CREATE FUNCTION ... LANGUAGE wasm` is refused by the server's own parser, `libsql_wasm_func_table` does not exist and the binary carries no wasm flag, so a folder for it would be a claim the engine cannot honour. - `ATTACH DATABASE`, `CREATE TEMP TABLE` and `CREATE TEMP VIEW` are all refused, but `CREATE VIEW temp.<name>` is accepted, and `PRAGMA table_list` then publishes one name under two schemas. So the `main` restriction is load-bearing rather than defensive, and the count and the listing carry the same predicate. - Bound parameters reach the pragma table-valued functions, so these statements bind where the flat surface embeds literals. `readNumber` and `readText` are exported from introspect.ts so both surfaces read a decoded row by one rule. Provider, tests and docs land together, per the triad rule.
…on a nameless one Three corrections from review, all on the object surface added in 470a963. The engine claim was wrong. The code comment and the provider doc both said the only `sqlite`-prefixed rows `sqlite_schema` ever holds are TABLES, so the reserved-name predicate on the INDEX listing could not be distinguished by data and had to be pinned by statement shape. Measured on sqld 0.24.33, that is false: `code TEXT UNIQUE` on an ordinary ROWID table puts `sqlite_autoindex_badges_1` into `sqlite_schema` typed `index`. The fixture missed it because its only implicit index belonged to a WITHOUT ROWID table, which is the one shape whose autoindex `pragma_index_list` publishes and `sqlite_schema` omits. So the fixture gains `badges(id INTEGER PRIMARY KEY, code TEXT UNIQUE, label TEXT)`, the index count and listing are pinned behaviourally, and the comment, the doc and the statement-shape test's justification are corrected. The shape test keeps two cases that really are unreachable by data, both re-measured: the table listing's schema restriction, because a temp view is typed `view` and can never enter a `type IN ('table','virtual')` listing, and the trigger listing's reserved-name predicate, because `CREATE TRIGGER sqlite_guard ...` is refused "object name reserved for internal use" and the engine creates no trigger of its own. A catalog value that identifies something now raises instead of degrading to the empty string. `readText(...) ?? ""` would have made a nameless row an addressable object with an empty last path segment; every one of these columns is NOT NULL, which is why a silent default would never have been noticed. Three comment numerals that presented themselves as measurements were stale against the fixture, and the doc now says one thing about the embedded SQLite version: the build decides it, `:latest` and Turso Cloud answer 3.47.0 and the pinned `v0.24.33` tag answers 3.45.1.
The sidebar stopped rendering the flat explorer when the object tree replaced it, and six actions lost their only entry point on the desktop sidebar in both shells: Generate SELECT, Profile, Generate Code, Generate Test Data, per-table maintenance and Create table. The standalone app kept them on its mobile schema tab; the embedded workspace kept three modals mounted with nothing able to open them. The menu is opened by right click, by the ContextMenu key and by Shift+F10, and it is rendered outside the `role=tree` element so a menu is never a treeitem's child and the menu's own arrow keys never reach the tree. Escape returns focus to the row it was opened on. What a row is offered comes from the provider's DECLARATION and never from a kind id: `role === "relation"` for the actions that address rows, and `acceptsRowWrites` AND the engine-wide `supportsInlineRowEdit` for the one that writes them, which are different questions (standing ruling 4). Maintenance asks `maintenanceControl(..., "perEntity")`, the same gate the page it deep-links to asks, and follows `vacuumActionOperation` so an engine whose vacuum slot is OPTIMIZE keeps the item. The shells decide what they CAN do by passing a handler or not: the standalone app passes all six, the embedded workspace the four it mounts a modal for, each behind the same feature flag as its modal. `flatTargetName` is the one narrowing to the old flat `TableSchema` model and is called at the shell, so Task 25 has a single name to grep. Closes U22.
…ount claims Re-review follow-ups on the object surface. The trigger listing passed its parent through `readText`, so a missing `sqlite_schema.tbl_name` would have promoted the trigger to a top-level path instead of raising, which is the same masking the previous round removed from the name. Whether a parent is required is now read off the DECLARATION rather than off whether the statement selects the column: a kind declaring `attachedTo` nests under the object it hangs off, so its parent is half of the address, and the three unattached listings are unchanged because absent is what "this kind does not nest" looks like. A test drives both arms. Two count claims that presented themselves as measurements were stale. One is the fourth of the class the review found, in a hunk the previous commit touched: "a naive scan answers 14 where the engine holds 8", two lines above an assertion that already said 16, with the engine now holding 10. The other I found while sweeping for the rest of the class: the comment and the test both named a specific catalog sequence as the engine's page order, and replaying the same DDL into a fresh container produces a different one. The order is not reproducible, so the test now derives what it needs - that the captured order is not sorted - rather than pinning names that happened to hold for one build.
Member
Author
Member
Author
Member
Author
Member
Author
Clicking a table in the object browser generated a statement three engines refused. Reproduced in a browser against live engines first, then fixed. Three defects, and they were separate: 1. The click path handed over `object.name`. After the flat reading was removed, `name` is the LABEL and `path` is the address, so every object outside the session default container generated a bare identifier: `Invalid object name 'customers'` on SQL Server, `Code: 60 ... Maybe you meant reporting.regions?` on ClickHouse. 2. `quoteQualifiedName` took a STRING and split it on `.`, so ClickHouse's `.inner_id.fake` generated `SELECT * FROM "".inner_id.fake`, a syntax error at position 15. 3. Oracle was sent a trailing `;`, which node-oracledb answers with ORA-00933. That one predates this branch: clicking a table on Oracle had never worked. The generators now take the object's PATH and quote per segment, and the qualification is unconditional, including inside the default container: `demo.orders` and `libredb_objects.app.customers` are valid wherever the bare name is, and no capability declares which container a connection defaulted to. `quoteQualifiedName` survives as a thin wrapper over the same rule for the one caller that has a dotted name and no segments, `POST /api/db/profile`, so the two cannot disagree about what a name means. Oracle declares `statementTerminator: "none"` rather than the generator growing another branch, and the four dialects that address one key or collection keep doing so: MongoDB names the collection, Redis and LibreDB take the bare key, Couchbase keeps its whole keyspace. Verified by clicking, on all three engines, in the default container and outside it, plus the dot-named ClickHouse table.
…st (#789) The Oracle and SQL Server object fixtures created every table with no rows, and the ClickHouse one created `demo`.`.inner_id.fake` empty. Verifying the click-to-query fix therefore meant inserting rows by hand into three live containers, and a container recreated from `docker/<engine>-init/` came back empty, so nobody could repeat the measurement. A fixture is part of the deliverable. Nine rows, the same ones that were inserted by hand: two in APP.APP_CUSTOMERS and two in REPORTING.REPORT_DAILY, two each in libredb_objects.app.customers, libredb_objects.dbo.audit_trail and libredb_objects_two.warehouse.stock, and one in demo.`.inner_id.fake`. Each pair covers one address the generated statement has to write: the session's default container, another container, and on SQL Server another database. An empty table answers a correct address and a wrong one with the same zero rows, which is why the rows are what makes the click measurable at all. Oracle also gets an explicit COMMIT before EXIT. SQL*Plus commits on EXIT anyway, so it changes nothing today; these INSERTs are the only DML in the file and a fixture whose data rests on a client convention is one image upgrade away from coming back empty. Verified on a container created for this and then removed, one engine at a time, by driving the real provider: listObjects for the address, generateTableQuery for the statement, query() for the rows. All six tables return their rows from a container built out of the checked-in fixture alone. The three provider docs enumerate what their fixture creates, so each now says what it seeds and why.
Member
Author
Member
Author
… on (#789) Oracle put a VALID badge beside every table in the object tree, which is what nearly every row in a real schema says, so the badge carried no information and taught a reader to skip the field. SQL Server did the same with ENABLED on a trigger. The field's contract is now that its PRESENCE is the signal and the engine's own word is the content: absent means ordinary, not unknown. Both producers set it only for the notable state, INVALID on Oracle and DISABLED on SQL Server. The decision stays in the provider because only it knows which of its engine's words is the ordinary one; a renderer that knew the strings VALID and ENABLED would be a branch on the database type moved up a layer. TreeRow renders an icon rather than a text badge, with the engine's word carried as screen-reader text as well as a tooltip, and keeps its tree-row-status handle. The suites that asserted VALID and ENABLED assert the ABSENCE of the key instead, on Object.hasOwn rather than on toEqual, which ignores an undefined property; the presence direction is pinned beside each one.
Member
Author
This was referenced Sep 12, 2026
The menu had six actions and three ways in, all of them undiscoverable: right click, the ContextMenu key and Shift+F10. The flat explorer carried an ellipsis button and the tree did not carry it over, with no record of a decision to drop it. A fixed 20px slot at the right of EVERY row, reserved by the row's own padding, holding the trigger on hover, on focus anywhere in the row, and unconditionally where there is no hover to have. Deliberately not the flat explorer's design, which put the ellipsis and the row count in one box and swapped them on hover: reaching for the menu hid the number. Nothing moves and nothing is hidden here, and the slot is reserved on rows that have no trigger too, so a routine's number lines up with a table's. One predicate decides both entry points. `hasRowMenu` feeds `openMenu`, which every gesture goes through, and `TreeRow`'s `hasActions`, which is the same answer `aria-haspopup` already announced. Every action is gated on `role === "relation"` today, so a routine, a trigger and a sequence show no trigger rather than a control that opens an empty menu; Phase 3's source editing gives routines actions and the trigger appears there with no change here. The button is named after its row rather than "More", carries aria-haspopup and reflects aria-expanded, and only the row holding the tree's roving tabindex offers it to Tab, so the composite keeps one entry point. Click and keydown stop at the button, or the tree's delegated handlers would also activate the row behind the menu. The menu is anchored on the button's real rect, so the last row of a scrolled sidebar opens upward. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…789) The visible row menu trigger is a descendant of the treeitem, so its name folded into the row's content-derived name: Chrome announced APP_ORDERS as "APP_ORDERS Actions for APP_ORDERS", on every row and every arrow-key move. The row now names itself by reference. aria-labelledby points at the spans it already renders - label, status, unavailable, failure, count, badge - which leaves the button out of the name and takes nothing else out of it. Nothing is restated in JavaScript, so the name cannot drift from what the row shows, and the trigger keeps the per-row name it was given for a reader navigating by button. Every slot is referenced unconditionally: a slot that did not render leaves a dangling IDREF, which the name computation skips, so the conditions live only in the JSX. Ids come from the row id, which flatten.ts already guarantees unique, through a fixed-width escape of ASCII whitespace: a table may be named "MY TABLE", and a space inside an IDREF splits the token so both halves resolve to nothing. Measured in Chrome against a running dev server, before and after, on Oracle and on the sample database. dom-accessibility-api cannot see this defect - it drops a control's aria-label during recursion (w3c/accname#64) - so the unit tests assert the references and pin the exact names, and the string-level before/after is a browser measurement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#789) Profile, Generate Code, Generate Test Data and the admin maintenance deep link carried `object.name` - the display label - and resolved it with `schema.find((t) => t.name === label)`, which answers the FIRST object carrying that label. Measured on the live SQL Server, where `libredb_objects.app.customers` and `shop.dbo.customers` both exist: Profile on one opened the other's columns, with no error. It is a regression, because the deleted flat reading spelled a non-default container into the name. An object is targeted by its path and resolved by its path, which is the ruling Task 30 applied to the query generators, finished across the remaining consumers: - both shells hold `readonly string[] | null` and resolve with `objectAtPath`, the `pathKey` join `detailedObjects` already uses; - the three modals take `tablePath` instead of `tableName`, including the `onProfile` adapter and the components the package exports; - `POST /api/db/profile` takes `tablePath` segments and quotes them with `quoteObjectPath`, so a name containing a dot is no longer split into qualifiers at the last place in the product that did it; - `TestDataGenerator` writes a qualified `INSERT INTO`, and takes the provider's declaration rather than a bare `queryLanguage`, so the statement it can run addresses the object that was clicked; - the maintenance deep link carries one `path` parameter per segment and `/admin/operations` reads it back with `getAll`. There is no separator to escape, the depth is the parameter count, and a segment holding a dot, a space or a `/` survives the round trip. `quoteQualifiedName` had one caller left and now has none, so it is deleted; so is `flatTargetName`. A string-splitting name helper kept alive with no caller is how this defect returns. Closes backlog X14. Verified in the browser on the running SQL Server: Profile, Generate Code and Generate Test Data on `shop.dbo.customers` all open that table's columns rather than `libredb_objects.app.customers`, and the deep link lands on `/admin/operations?path=shop&path=dbo&path=customers`.
Task 35 moved the INSERT's target onto `quoteObjectPath` and left the column list on a hardcoded `"`, so one statement spoke two dialects. MySQL reads `"order date"` as a string literal rather than a column, so the statement did not parse at all; the component already holds the capability that decides. The test pins both directions with a control, because a column whose name needs no quoting is spelled the same everywhere and would pass for a component that quoted nothing: MySQL answers `\`order date\`` and SQL Server `[order date]`. Filed rather than fixed here, both found by measuring this path on a live SQL Server: D54, the profiler casts every column with PostgreSQL's `::text` so no column profiles on any other engine, which predates this branch and needs a per-dialect cast measured per engine; and D55, the Operations list prints no schema, so two same-labelled rows are indistinguishable.
The ER diagram keyed its whole graph on the object's bare label. `tableSet` held labels, so a foreign key whose `referencedTable` is spelled QUALIFIED because it crosses a container was dropped with no error, and two objects sharing a label in two containers took one React Flow node id. Measured on the live SQL Server: one object's key spells its target `app.customers` while the object beside it spells its own `customers`, and `libredb_objects.app.customers` sits beside `shop.dbo.customers`. Both edge builders and both anchor maps now resolve a spelling through the shared rule in `object-address.ts`, preferring the referencing object's own container the way the engine itself does, and refusing a spelling two objects answer to rather than drawing a relation nobody declared. Node ids, the expanded set, the selection and the adjacency map are the object's `pathKey`; the card keeps the label and carries the address as its tooltip; the panel readout prints the dotted address. The import modal valued its target `<option>` by the label, so two namesakes were one value and one React key, and the INSERT named a bare table the session default container answers. `generateImportSQL` now takes one `ImportTarget`: an existing object by `path`, qualified and quoted per segment for the connected dialect, or a new table by the name the operator typed. That replaces the flag, the typed name and the selected label, of which only ever one was read. Three more consumers of the same defect, found by renaming the field and reading the compiler's error list rather than by grep: the schema explorer's list key and expanded set, the documentation page's list key and its on-screen and exported headings, and the Prisma `@@map`, which addresses the table and now takes the object's own segment. Tests are the collision in every case: two objects sharing a label in different containers, the action taken on the second, with the first still reachable as the control. A one-object fixture cannot see this defect, which is why three rounds of it shipped.
…mutation (#789) Four mutants lived through the first battery. Two of them are the same blind spot: a React list key is invisible in the DOM, so `key={table.name}` for two objects sharing a label changed nothing any assertion could see. React's own duplicate-key warning is where that key IS observable, so the three collision tests capture console.error and assert it is silent. The fourth is the FK edge id, which is also the dedup key: built from labels, two namesakes referencing one object produce one string and `seen` swallows the second edge. The new case is two `orders` in two databases, both keyed to one `hub.dbo.customers`, asserting two edges with two ids.
…t a failed declaration (#789) Three independent defects an external review found, each verified against the code. DDL never refreshed the object tree. `use-query-execution.ts` re-read the flat inventory the diagram and the modals draw from, and the tree's own lazy cache of counts and listings was untouched, so after CREATE TABLE the sidebar kept the old folder contents until the connection was re-selected. `invalidateContainer` existed, had a test, and had no caller anywhere in `src/`. It is replaced by `refresh()`, which re-reads the container listing plus one read per OPEN row, in place, without emptying a slot first. Nothing is derived from the statement: `schemaRefreshPattern` is `(CREATE|DROP|ALTER|TRUNCATE)\b` and names no object, one statement can change more than one container, and the parse would have to branch on the engine's quoting to be right, where a wrong container leaves the changed one stale in silence. The cost is bounded by the tree's expansion state and not by the schema: three schemas open and two folders expanded is six reads, everything collapsed is one. The reasoning is in `refresh`'s own docblock. The embedded adapter had no generation guard, so a slow `onSchemaFetch` resolving after a connection switch wrote B's objects under A. The host supplies that callback, so its latency is not ours to bound. The rule now has one definition, `useReadGeneration`, used by both shells; the deferred path in both also clears the loading flag, which a superseded read cannot. A failed `provider-meta` read set the metadata to null and logged a warning, which is byte-identical to "not read yet" at the sidebar: the reader watched "Reading the connection..." for ever with no message and nothing to press. The hook now carries the route's own sentence and a retry, and the sidebar renders both.
… the getSchema-era drift (#789) The inventory route recorded whichever bound was assigned last, so a provider's column bound overwrote an object-limit or pair-limit reason from the same pair. The precedence is now one expression: a missing OBJECT outranks a missing COLUMN, because the agent reads an object it was not shown as one the database does not hold, while a short column read still names every object. The route's docblock also stated its two bounds as the whole story; the container walk underneath them is unbounded at every level, and that is now stated where it bites and filed as B80. Doc drift the triad rule should have caught, each checked against the provider rather than taken from the review: - mongodb.md described the deleted per-collection reading's cost model. The object surface bulk-reads a folder in one chunked $unionWith. - trino.md 3.2 argued for a pinned-catalog tree the object surface replaced. The pin decides the session default and nothing else; listContainers lists every catalog. - clickhouse.md 6 and elasticsearch.md / opensearch.md 6 described the deleted flat reading in the present tense, and the search pair cited a concurrency constant and a degradable-failure set that no longer exist. Rewritten around what the code does; the facts that outlived the deletion are kept and the rest is gone. - The "two-phase flat schema split" sentence was mangled in trino.md and druid.md. Dead code the deletion left: couchbase's listCollections cluster, the search provider's SEARCH_MAPPING_CONCURRENCY (pinned only by a test asserting its value), and twenty empty describe blocks naming surfaces that no longer exist. Stale comments, fixed where they ASSERT something false rather than where they recall history: db.schema.read's descriptor said it calls getSchema() and costed it as that reading; AgentReadingDenyCode said getSchema() is required on DatabaseProvider; the libredb provider said the row menu still reaches objects through flatTargetName. The barrel comment in src/components/object-tree/index.ts names flatTargetName correctly, in the past tense, as the reason the barrel is narrow, and is left alone. containerLevels is now a tuple union of nought, one or two levels, so a third one is a compile error where a provider would write it instead of a level nothing reads. sessionDefaultContainer's two-defaults arm has a test: every engine marks one, so no provider suite could reach it.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.










Closes #710. Closes #765. Answers https://github.com/orgs/libredb/discussions/778 and makes #773 cheap. Epic: #789.
Why
The schema surface was one flat
TableSchema[]with aname: string. It had no namespace level, no object kind and no lazy boundary, and each of the four open requests needed one of those three. On real PostgreSQL neither views nor materialised views were listed at all. Oracle hard-scoped introspection to the connecting user, PostgreSQL swept every non-system schema, and the user controlled neither. A 43,512-object Oracle schema froze the UI on connect.What this is
Providers now declare their container levels and object kinds as data. Core code never reads a kind id or a type id.
ObjectRoleis a closed set the UI derives behaviour from:relation,routine,group,attached,config.ObjectKindSpec.idis an open string, so a ClickHouse dictionary, a Druid lookup and an Oracle package reach the tree through a provider-local declaration and no core change.ContainerLevelSpecgives zero, one or two levels, each carrying the engine's own word.DatabaseObject.pathis areadonly string[], never a joined string, which retires the dot-splitting defect that made a table nameda.binpublicgenerate"a"."b".KindCountkeeps four facts apart: the engine has no such concept (kind undeclared, no folder drawn), it holds none (count: 0), the read was refused (the engine's own sentence), and the count is a floor from a bounded scan rather than a total.Five provider methods replace
getSchema/getSchemaList/getSchemaRelations:listContainers,countObjects,listObjects,describeObject,describeObjects. The fifth was not in the approved design. Implementation found that nothing else served the agent's bulk column read, and that deletinggetSchema()without it would have cost the agent every column on fifteen engines.Breaking changes
This is why the version moves to 0.16.0. Pre-1.0 the minor is the breaking position, which is what this repo's own tag history does.
TableSchemaandTableRelationsare deleted, including fromsrc/exports/types.ts. Every entry now comes from the object surface.DatabaseProvider.GET /api/db/schema,/api/db/schema/list,/api/db/schema/relationsandPOST /api/db/schema-snapshotare removed./api/db/objects/*replaces them.StudioWorkspaceProps.onObjectsFetchis a new required prop. Every embedding host must implementWorkspaceObjectReaderto compile. Credentials cannot substitute for it: the package ships no API routes, so/api/db/objects/*is a path on whatever server the host mounted the workspace in and need not answer at all.onSchemaFetchis unchanged and still required, feeding the ER diagram, the profiler, both generators and the editor's schema context, so neither prop supersedes the other.SchemaSnapshot.schemaisStoredObject[]for that reason, anddiffSchemasstill compares by name: keying on kind would report every object in an old snapshot as removed and every object in the current reading as added, the first time a user opened one. The stated cost is that the diff still cannot say a table became a view.Behaviour changes a user will notice
TABLE_SCHEMA = 'druid'and could not see a lookup or a system table. The object surface answers for every schema, so the agent now also sees lookups and system tables. Measured live on 37.0.0: 16 objects over 5 containers where it was 4.PRIMARY KEYandORDER BYentries that were not indexes. Whether the sparse primary index deserves an entry of its own is a product question, not an oversight; the sorting key is not reported where it differs from the primary key, and that is a stated Phase 1 limit.Acceptance
All 17 type-ids measured against a live instance, through the real provider, not a double: PostgreSQL 18.4, MySQL 26.7.0, MariaDB 12.3.2, SQLite, MongoDB 8.2.12, Redis 8.10.0, Oracle XE at 27,646
dba_objects, SQL Server 2022, LibreDB, Couchbase CE 8.0.2, ClickHouse 26.7.1.1315, Druid 37.0.0, Elasticsearch 9.1.4, OpenSearch 3.8.0, Cassandra 5.0.9, Trino 476, libSQL sqld 0.24.33, DuckDB. Count agreed with listing on every kind on every engine, and an unboundeddescribeObjectslefttruncatedabsent everywhere.psql.SYSat 9,186 objects: connect 46 ms,listContainers5 ms,countObjects63 ms, largest listing 142 ms.V$SQLcatalog-statement count went 1 to 1 after askipObjectScanconnect and 1 to 3 after a real first paint, so opening a deferred connection issues zero catalog queries, measured at the engine. In the browser: zero/api/db/objects/*requests on a deferred connection, three on the press, and 5,001 objects in one folder rendering as 24 DOM rows in 51 ms.truncated limit 1000; 5,200 tables answers 5,000 withtruncated limit 5000.KindCountstates render differently, each with a control; the{unavailable}state has no fixture anywhere, so it was produced with a Redis ACL user denied-function, giving two connections to one database differing in exactly one folder..workflow-data, not on screen. Plan mode on all eight engines, auto mode on SQLite and PostgreSQL. The grounding inventory carries kinds, so a view no longer reaches the model described as a table, which is what feat(agent): ground plan mode on every engine, from the provider's own schema #414 measured. The read-only envelope holds:log_statement='all'shows the agent backend with 5BEGIN READ ONLYenvelopes and zero statements outside one, while two other backends in the same log do have statements outside.Two caveats not papered over: the 5,000-object Oracle owner is synthetic, because
SYSis excluded from the container listing; and SQLite's write refusal landed at the policy layer, so the engine-levelquery_onlyboundary was not itself exercised, and the honest claim is that the file is byte-for-byte unchanged.Tests
100% line coverage on the merged lcov, as the required job enforces. Every provider's integration suite asserts a shared object-surface contract, including that
describeObjectsdescribes exactly whatlistObjectsnames and that an unbounded call reports no truncation. The four schema routes' tests are deleted with the routes.One known local-only red, invisible to CI:
bun run testas one invocation hitsmock.module()cross-file contamination. CI runstest:coverage, which gives each core file its own process, and that is green.Open, filed rather than absorbed
docs/BACKLOG.mdD52 (a Couchbase node behind a port mapping is unreachable, measured at 38091/38093 failing where 8091/8093 worked), D53 (the libSQL fixture exists only as prose in its provider doc), B78 and B79.Not in this PR
Phase 2 is reading object source. Phase 3 is editing and DDL apply, which is what discussion #778 asked for. #773's data preview becomes cheap now that the tree exists, and is its own change.
Notes: