Object view
The ⊞ affordance, or any structure entry in the tree's context menu, opens one object's structure as an editor tab.
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
truncateanddropdo 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.
▤ rowsis 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 identityis a column clause, not aDEFAULT, 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 COLUMNrestates 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)andtext[]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:
- Reveal — a click expands the action.
- Type the object name — free text, not a checkbox.
- 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 aDROP TABLEis 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 (aVOLATILEmarking on a body that only reads, aSECURITY DEFINERwith nosearch_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 TABLEmeans 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
statisticsis not. - SQLite check constraints are not exposed by any PRAGMA. Parsing them
out of the stored
CREATE TABLEis 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.