Object view

The ⊞ affordance, or any structure entry in the tree's context menu, opens one object's structure as an editor tab.

Status
current
Phase
9

This is an engineering document. Specs are written for people building DataGripe: they record what a subsystem does and why the alternatives were rejected, and some of them describe things that are not built yet. If you are using DataGripe, the documentation is the place to be — it is maintained for you and this is not.

Supersedes: nothing (implements docs/brand/brand-system.md "Object view — the structure" and "Danger zone"; the tab strip follows docs/brand/mocks/tree-interactions.html)

Goal

The ⊞ affordance, or any structure entry in the tree's context menu, opens one object's structure as an editor tab. What that means depends on the object:

Kind Tabs Double click opens
table columns · indexes · constraints · triggers · grants · statistics · ddl · danger zone the table view (rows)
view the same, minus editing the table view (rows)
function, procedure arguments · grants · statistics · ddl the ddl tab
sequence statistics · ddl the ddl tab

A routine has no rows to browse — its definition is the object — so double-clicking one lands on the ddl tab rather than opening a grid. The columns tab of a base table is editable (see Editing columns).

Non-goals

  • A data tab. Rows are the table view's job (docs/spec/table-view.md). That separation is what lets both surfaces be generous with space, and it is the reason the object view can grow tabs without a redesign.
  • Editing a routine's or view's body. The ddl tab shows a definition; it does not save one back.
  • Index, constraint and trigger changes. Only columns are editable.
  • Executing the danger zone. The gating interaction is real and the consequences it states are real, but truncate and drop do not run yet — see Danger zone.
  • Redis keys. There is no structure to describe; the tab says so.

Design

Interface

payments                       table · public · ~41,203,882 rows   ↻  ▤ rows
 columns  indexes  constraints  triggers  grants  statistics  ddl │ danger zone
──────────────────────────────────────────────────────────────────────────────
 name          type      null       default   comment
 id            uuid      not null   —         —
 …
  • The header states the object, its kind, its namespace and its row count, prefixed ~ when the count is an estimate.
  • The selected tab carries a 2px Function Cyan underline; the danger zone is dimmed magenta until hovered and underlines magenta when selected.
  • Primary keys are Snark Magenta, types and definitions Function Cyan, metadata Ink mute. Cell tone is a closed set of four rather than a free class name, so the tabs cannot drift apart visually.
  • ▤ rows is the one cross-link between the two surfaces: structure knows where the data lives and says so, without containing it.

One call, every tab

object.describe returns all seven tabs at once. The tabs are cheap catalog reads, and a per-tab round trip would make switching tabs feel like loading a page. The refresh button re-reads the whole thing.

Deep links from the context menu (indexes, danger zone, …) retarget an already-open panel, but only when the parameter actually changes — otherwise the deep link would override every manual tab switch.

Three kinds of nothing

A tab can be empty for three different reasons, and collapsing them would be a lie:

State How it is expressed What the user sees
The object has no such tab absent from tabs no tab at all
The engine cannot answer it listed in unsupported "sqlite does not expose grants for this object"
There is genuinely nothing present, empty array "No triggers on this object."

A function does not have an indexes tab; SQLite cannot answer grants; a table can simply have no triggers. tabs comes from the object's kind and is known from the panel parameters before the describe lands, so the tab strip does not reshuffle when data arrives.

Statistics

Engines disagree about which numbers exist at all, so statistics are a label/value list rather than a fixed struct: each adapter reports what it has, formatting happens server-side so the three engines agree on what "1.2 GB" means, and tiles the engine could not fill are dropped instead of padded with nulls.

DDL

Engine Source Verbatim
PostgreSQL (view) pg_get_viewdef ✓
PostgreSQL (routine) pg_get_functiondef ✓
PostgreSQL (table) rebuilt from the catalog ✗
PostgreSQL (sequence) rebuilt from pg_sequences ✗
MySQL SHOW CREATE TABLE / VIEW / FUNCTION / PROCEDURE ✓
SQLite sqlite_master.sql ✓

A routine is the one thing PostgreSQL does export verbatim, body and all, which is exactly why double-clicking a function goes straight there.

PostgreSQL has no server-side DDL export — pg_dump is a client program, not a function — so a table's DDL is reconstructed from columns, constraints and index definitions. The result carries ddlReconstructed: true and the tab says so. Reconstruction is deliberately careful about two things that a naive rebuild gets wrong:

  • Identity columns. generated always as identity is a column clause, not a DEFAULT, so it is emitted without the keyword.
  • Constraint-backed indexes. A unique constraint and its index are the same object; emitting both would produce DDL that creates the index twice. Only indexes with no owning constraint are appended.

Attached triggers follow the table or view definition in the DDL tab and in domain sync. PostgreSQL uses pg_get_triggerdef, excludes internal constraint triggers, and appends the statements needed to preserve disabled, replica or always modes. MySQL/MariaDB appends SHOW CREATE TRIGGER definitions in firing order; SQLite appends stored trigger SQL. Trigger functions remain separate routines and need their own domain tags to be exported.

Per-engine differences

postgres mysql sqlite
column comments col_description column_comment none
index sort order indoption bitmask not exposed not exposed
index size pg_relation_size unknown unknown
check constraints pg_get_constraintdef 8.0.16+ only not exposed
trigger timing tgtype bitmask action_timing parsed from stored SQL
disabled triggers tgenabled cannot be disabled cannot be disabled
grants role_table_grants table_privileges unsupported
row count reltuples (estimate) table_rows (estimate) COUNT(*) (exact)
dependents views + foreign keys foreign keys foreign keys
routines functions + procedures functions + procedures none
sequences pg_sequences none none

Routine identity. A PostgreSQL routine's tree name carries its identity arguments — order_total(integer, numeric) — and that whole string is what selects the row, so overloads stay distinct end to end. Arguments are read by unnesting the three parallel arrays in pg_proc rather than by parsing pg_get_function_arguments: a default value can contain a comma, so splitting that string is wrong for exactly the functions people care about.

Two notes on the harder ones:

Index sort order. pg_get_indexdef(oid, k, true) returns the column expression without its DESC, and slicing the whole definition instead does not work — a partial index's WHERE clause has its own parentheses and gets swallowed. The direction comes from pg_index.indoption instead, which is also why a partial index reports only its key columns.

PostgreSQL row counts. reltuples and n_live_tup are both estimates that go stale differently: reltuples is the planner's, refreshed by ANALYZE, and is -1 before the first one; n_live_tup is the stats collector's and reads 0 until it flushes. reltuples wins so the header agrees with the table view's footer, which estimates from the same column — and the header always marks it as an estimate.

Editing columns

The columns tab of a base table is editable: rename, retype, toggle nullability, set or drop a default, set a comment, add a column, drop a column. Cells are inputs that read as plain text until touched, so the tab looks like the read-only grid it replaced; a touched field goes Gripe Green like a dirty table-view cell.

The gating is the preview, not a confirmation box. Edits gather into a pending set; review asks the server for the exact SQL and shows it; apply runs that same batch. A typed confirmation on every field edit would be noise people learn to dismiss, but reading the statement before it runs is not — and it teaches what the tool is doing.

object.alter serves both steps: dryRun: true builds the statements and stops. One action, one code path, so the SQL shown cannot drift from the SQL that executes. Dropping a column is the exception and still types the object name, like the danger zone — it is the one change here that destroys data.

Change order is fixed by the client, not left to the user: attribute changes, then adds, then renames, then drops. A rename invalidates the name every other change refers to, and a drop makes the column unreachable. Touching a column again after renaming it in the same batch is refused by name rather than sent to fail.

Change postgres mysql sqlite
add ✓ ✓ ✓
rename ✓ ✓ ✓
drop ✓ ✓ ✓
type ✓ ✓ –
nullability ✓ ✓ –
default ✓ ✓ –
comment ✓ ✓ –

ADAPTER_CAPABILITIES.columnChanges drives this per operation rather than all-or-nothing, and the tab disables the fields it cannot change with the reason in the title. SQLite's ALTER TABLE genuinely does only three things; the rest need the table rebuilt, which this view does not do (see Open questions).

Two engine differences worth knowing:

  • MySQL has no per-attribute column ALTER. MODIFY COLUMN restates the whole definition, so anything not being changed is carried over from the column as it currently is — otherwise changing a type would silently drop the comment, the default and the nullability.
  • MySQL does not roll DDL back. Each statement auto-commits, so a mid-batch failure leaves the earlier statements applied. The error says how far it got rather than implying a rollback that did not happen. PostgreSQL and SQLite are transactional and the batch is all-or-nothing.

Role is editor, matching table.mutate. The real gate is the target database's own permissions: a workspace role says who may drive the tool, not who may alter a schema.

Safety

Everything here is a catalog read on the describe path. On PostgreSQL and MySQL the whole describe runs inside one read-only transaction with a statement timeout; SQLite needs neither, since its PRAGMAs cannot write. Schema and object names are bound parameters everywhere except SQLite's PRAGMAs, which take no binds and get the same defensive quoting the introspection path already uses.

object.describe is open to viewers: structure is read-only introspection, and unlike the table view's where … box there is no raw fragment for a viewer to smuggle SQL through.

Column changes are a write path and have their own surfaces to guard, because DDL takes no bind parameters where a type or an identifier goes:

  • Identifiers are quoted per dialect, quote character doubled.
  • Type names are validated to a type-shaped pattern — letters, digits, spaces, an optional precision, an optional array suffix. That admits numeric(10,2) and text[] and rejects anything containing a quote, a semicolon or a call.
  • Default expressions cannot be allowlisted, since they are real expressions. They go through the same dialect-aware statement splitter as the table view's filter: a semicolon inside a literal is fine, a top-level one is not.
  • Column names in a change must exist on the relation, read fresh from the catalog in the same request.

Danger zone

The gating is the specification, and it is implemented:

  1. Reveal — a click expands the action.
  2. Type the object name — free text, not a checkbox.
  3. Execute — enabled only on an exact match.

The consequences each action states are now real: actual row counts, the actual index count, and the actual dependent objects a DROP would take with it, named. Project-class gating is implemented client-side per the brand spec: production confirms with the project name rather than the table name, and analytics renders the tab disabled with the reason stated.

Neither action executes yet, and the panel says so rather than pretending. Two reasons, both deliberate:

  • The brand spec's build order puts the danger zone after the object view, "deliberately late — it is the one surface where a bug is destructive."
  • Project class currently lives in localStorage (apps/web/src/stores/branding.ts, marked MOCK). Client-side gating on a DROP TABLE is theatre: the server cannot enforce "production confirms with the project name" while it does not know the class. Promoting project class to the workspace model is the prerequisite, and it belongs to the projects feature rather than to this one.

Grants are direct grants

The grants tab reads information_schema.role_table_grants, which reports direct grants only: a GRANT ... TO PUBLIC appears once as grantee PUBLIC, and a grant inherited through a role appears against the parent role's name. Scan the table for a role and you can find nothing while that role holds the privilege.

The tab therefore carries a one-line caveat and points at docs/spec/access-report.md, which resolves effective access with has_*_privilege. The tab is not "wrong" — per-object direct grants are a useful thing to see — but an empty grants tab must never be read as "nobody can reach this".

Open questions

  • Object-scoped gripes are designed in docs/spec/gripes.md: structural rules annotate the tab their subject lives in, above that tab's table. The ddl tab of a routine is the most promising surface in the product for them — a body is dense with checkable things (a VOLATILE marking on a body that only reads, a SECURITY DEFINER with no search_path) and, unlike a table's shape, the text is right there to annotate. Which rules actually ship is still reserved.
  • SQLite's narrow ALTER TABLE means type, nullability and default changes need the 12-step table rebuild (new table, copy, drop, rename). It is well documented and entirely doable, but it is a data migration wearing a schema change's clothes, and it deserves deciding on rather than sliding in.
  • Index, constraint and trigger tabs are read-only. The columns tab proves the preview-then-apply shape works; extending it is mostly more dialect SQL.
  • The spec lists partitions, dependencies, policies, comments and bloat estimates as natural future tabs. Adding them is easy; deciding which earn a tab rather than a row in statistics is not.
  • SQLite check constraints are not exposed by any PRAGMA. Parsing them out of the stored CREATE TABLE is possible and would make the constraints tab complete, at the cost of a SQL parser in the adapter.
  • MySQL per-index size needs innodb_index_stats, which needs privileges the connection may not have. Reporting unknown is honest; attempting it and degrading is also defensible.

This page as Markdown, for an agent or a diff.