# Serve a live schema view

Run ptah schema serve to watch in a browser how a live database differs from the desired schema while you change it.

Source: https://docs.ptah.run/v0.8.1/schema/serve/

import ProductPreview from '../../../components/ProductPreview.astro';
import schemaServeDriftFull from '../../../assets/schema-serve-drift-full.png';
import schemaServeDrift from '../../../assets/schema-serve-drift.png';
import schemaServeMatchesFull from '../../../assets/schema-serve-matches-full.png';
import schemaServeMatches from '../../../assets/schema-serve-matches.png';

export const schemaServeMatchesSample = `${import.meta.env.BASE_URL}samples/schema-serve-matches.html`;
export const schemaServeDriftSample = `${import.meta.env.BASE_URL}samples/schema-serve-drift.html`;

`ptah schema serve` serves a read-only web page carrying the desired
schema and how a live database differs from it. The page re-reads both on every
request, so where `ptah schema compare` answers once, this keeps answering while
you work.

Use it as a second window beside the editor while you are changing models. It is
not a pipeline tool: a pipeline wants an exit code, which
[`ptah schema drift`](../../direct/compare-and-drift/) gives it. Nothing here
writes to the database, and no migration depends on the server having run.

<ProductPreview
  id="schema-serve-matches"
  src={schemaServeMatches}
  alt="The live Shop schema view showing zero differing categories and the message that the database matches the declared schema."
  caption="The live view before and after one desired column diverges from the SQLite database."
  notice="Both states use the same framing: the verdict and severity counters stay above the schema diagram, while the drift state adds the exact differing category."
  fullSizeHref={schemaServeMatchesFull.src}
  downloadHref={schemaServeMatchesSample}
  sourceHref={schemaServeMatchesSample}
  reproduce="ptah schema serve --root-dir docs/site/fixtures/schema-ui/internal/models --db-url sqlite://shop.db --addr 127.0.0.1:7070 --refresh 0 --title 'Shop schema'"
  variants={[
    {
      id: 'drift',
      label: 'After drift: one warning category',
      src: schemaServeDrift,
      alt: 'The live Shop schema view after drift, showing one warning category and a Drift row for one added column.',
      fullSizeHref: schemaServeDriftFull.src,
      downloadHref: schemaServeDriftSample,
      sourceHref: schemaServeDriftSample,
    },
  ]}
/>

Prerequisites:

- A `ptah` binary on your machine ([Install Ptah](../../start/install/)).
- A desired schema, as a `--schema-file` in SQL, YAML, HCL or DBML, or as
  [Go annotations](../go-annotations/) under a `--root-dir`. An `oci://`
  artifact is the one source this command refuses; see
  [Limitations](#limitations).
- The URL of the database to compare against.

## Starting state

The examples use two tables and a local SQLite database that already matches
them. Save this as `schema.sql`:

```sql
-- schema.sql
CREATE TABLE customers (
    id INTEGER PRIMARY KEY,
    email TEXT NOT NULL UNIQUE
);

CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    customer_id INTEGER NOT NULL REFERENCES customers(id)
);
```

Create the database from it:

```bash
ptah schema apply --schema-file schema.sql --db-url "sqlite://$PWD/shop.db" --auto-approve
```

Substitute your own schema and database URL throughout. The same two tables
written as [Go annotations](../go-annotations/) serve identically: pass
`--root-dir ./models` in place of `--schema-file`, or pass both to merge them.

## Start the server

```bash
ptah schema serve --schema-file schema.sql --db-url "sqlite://$PWD/shop.db"
```

Expected output includes:

```text
Serving a read-only schema view on http://127.0.0.1:7070
```

Open the printed address in a browser. The address is read back from the
listener rather than echoed from the flag, so `--addr 127.0.0.1:0` prints the
port that was actually chosen.

## What the page shows

The page has two parts: a status panel this command adds, and the schema itself.

The status panel carries four counters — differing categories, destructive,
warning, safe — followed either by the sentence
`The database matches the declared schema.` or by a **Drift** table. Under it
sits a timestamp reading `compared` and a UTC time, because a live view whose
age is unknown is not a live view.

The matching view keeps the status decision above the diagram and table
reference, so the first viewport answers whether action is needed.

| Drift column | What it holds |
| --- | --- |
| Category | The difference category, for example `columns_added`. |
| Objects | How many objects fall in that category. |
| Severity | `safe`, `warning`, or `destructive`. |

Those are the categories and severities
[`ptah schema drift`](../../direct/compare-and-drift/) reports, from the same
classification.

Below the panel is the schema, rendered by the same code as
[`ptah schema export --to html`](../document/): an entity diagram, then one
section per table with its columns and indexes. The sidebar names the page, the
database address with any credentials removed, and each table.

## Watch drift appear

The desired schema and the database are read again for each request, so a change
to either shows up on the next page load with no restart. Replace the `orders`
table in `schema.sql` with one carrying an extra column:

```sql
CREATE TABLE orders (
    id INTEGER PRIMARY KEY,
    customer_id INTEGER NOT NULL REFERENCES customers(id),
    placed_at TEXT
);
```

Reload the page. The counters read one differing category, of which one is a
warning, and the Drift table gains a row: `columns_added`, 1 object, severity
`warning`. The new column also appears in the `orders` section below, because
that section renders the desired schema rather than the database.

The page reloads itself every 30 seconds through a `meta refresh` tag and
carries no JavaScript. `--refresh 15s` shortens the interval; `--refresh 0`
serves a page that does not reload.

## Shape the page

- `--title "Shop schema"` replaces the default title, `Schema dashboard`, in the
  browser tab, the heading and the sidebar.
- `--addr` selects the listen address. The default is `127.0.0.1:7070`.
- `--refresh` sets the self-reload interval as a Go duration, and `0` disables
  it.
- `--root-dir` is repeatable, so a schema split across directories is served
  from one page.
- `--schema-file` is repeatable too, and combines with `--root-dir`: a schema
  split across files, or across files and annotations, is served from one page.
- `--schemas` limits the database read to the named schemas, comma-separated.

:::caution
`--addr 0.0.0.0:7070` publishes the page to the network, and the server has no
authentication. Keep the default address unless the machine is closed by other
means.
:::

Each of these also reads an environment variable, printed on its `--help` line:
`PTAH_TITLE`, `PTAH_ADDR`, `PTAH_REFRESH`, `PTAH_ROOT_DIR`, `PTAH_SCHEMA_FILE`,
`PTAH_SCHEMAS`.

## Read the page from a script

There is no JSON endpoint. The HTML is the only machine-readable answer, and
every path returns the same page, so `curl` on `/` is a complete client:

```bash
curl -s -o dashboard.html -w '%{http_code}\n' http://127.0.0.1:7070/
curl -s -X POST http://127.0.0.1:7070/
```

Expected output includes:

```text
200
this dashboard is read-only
```

`GET` and `HEAD` answer; every other method gets `405` with the header
`Allow: GET, HEAD`. The refusal is applied before any route runs, so the surface
stays read-only whatever is added to it. Responses carry `Cache-Control:
no-store`, since a cached copy would show drift that has since been fixed.

For a page you scrape, add `--refresh 0`. The meta tag is then absent and
nothing else about the response changes.

## Stop the server

Press Ctrl-C. Ptah writes one line to stderr and exits `130`:

```text
interrupt received, releasing resources; interrupt again to stop immediately
```

## Failure modes

**The database cannot be reached.** The response stays at HTTP 200 and the
counters are replaced by a banner headed `The database could not be compared`,
the driver's error, and `last attempt` with a UTC time. Zero drift is not
rendered in that case, because a page reading zero would tell a reader their
schema is in sync when nothing was measured. The schema section keeps the last
comparison that succeeded, and before the first success there is no schema
section at all.

Three inputs stop the command before it listens, each with exit code `2`:

| Message on stderr | Cause |
| --- | --- |
| `error: database URL is required` | No `--db-url`, and no `url:` in a project config in the working directory. |
| `error: listen on 127.0.0.1:7070: listen tcp 127.0.0.1:7070: bind: address already in use` | Another process holds the address. Pass a different `--addr`. |
| `error: oci:// schema source cannot be served: ...` | An `oci://` reference passed to `--schema-file`. The rest of the message names [`ptah schema drift`](../../direct/compare-and-drift/), which reads that source and answers once. |

## Limitations

- **An `oci://` artifact cannot be served.** Reading the source again on every
  request is this command's whole contract, and a registry artifact fits neither
  reading of it: pulling on every request puts a registry on a schedule nobody
  asked for, and pulling once shows a copy that has stopped matching the
  reference. The refusal happens before the server listens.
  [`ptah schema drift`](../../direct/compare-and-drift/) takes the reference and
  answers once. An external loader is absent for a separate reason: the command
  registers no `--schema-cmd`, so render that program's output to a file first.
- **With neither `--schema-file` nor `--root-dir`, the working directory is
  scanned for Go annotations.** Started where no annotated Go file exists, the
  desired schema is empty and the whole database
  reads as drift to remove: against the database built above, the panel reports
  `2 differing categories` with `2 destructive`, and rows `constraints_removed`
  (4 objects) and `tables_removed` (2 objects). No schema section is drawn, and
  the sidebar carries the title and the database address alone. Nothing on the
  page says the desired side was empty.
- **The drift panel counts only the categories the safety classification
  names.** A view declared in the models and missing from the database renders
  `0 differing categories` here, while `ptah schema compare` reports
  `views_added (1)` for the same pair.
- **The schema section draws tables, columns, indexes, enums and foreign-key
  relations.** Views, functions, triggers, sequences, row-level security (RLS)
  policies, roles and grants are not drawn.
- **Every path serves the dashboard.** There is no health endpoint and no static
  asset path, so a monitoring probe on `/healthz` receives a full schema render
  at 200, and a reverse proxy cannot route by path.
- **The server has no authentication.** The default `127.0.0.1:7070` keeps the
  page on the machine that runs it, and binding wider publishes an
  unauthenticated read of the schema.
- **There is no `--config` or `--env` flag.** A `ptah.yaml` or `atlas.hcl` in the
  working directory is still read, and a malformed one stops the command before
  it listens, but neither can be pointed elsewhere.

## Exact reference

Run `ptah schema serve --help` for every flag with its default and its
environment variable, and for the long-form note on `--schema-file`. The process
runs until it is interrupted: the inputs above exit `2` before it listens, and
Ctrl-C exits `130`. [Exit codes](../../reference/exit-codes/) carries the verb's
row and the convention behind those numbers, and
[A live view](../../reference/native-commands/#a-live-view) carries the flag
table.

## Next steps

- Need the same comparison as a pipeline check with an exit code? Use
  [Compare and drift](../../direct/compare-and-drift/).
- Ready to reconcile what the panel reports? Use
  [Apply directly](../../direct/apply/).
- Want the schema page as a file to commit or attach? Use
  [Generate schema documentation](../document/).
