Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ change — every other command is read-only or fully offline.
| `migrate` | required | Resolve the target table, preflight it (privileges, partitioning, size and catalog facts), classify and route the change, then **execute** the routed SQL under bounded budgets |
| `migrate --dry-run` | required | The same introspection as a real run — server version, target resolution, table facts — so the printed plan reflects the actual target; executes nothing |
| `diff` | required | Introspect the live table (read-only) and materialize the desired-state file on a scratch schema inside a transaction that is always rolled back; prints the plan, changes nothing |
| `pull` | required | Introspect each supported table in a schema and create one desired-state file per table; existing files are never overwritten |
| `status` | required | Read-only view over `pg_stat_activity` for live pg-sprite sessions on the connected database |
| `fmt` | none | Canonicalize a schema file — parser only |
| `lint` | none | Flag patterns the engine would refuse, rewrite, or gate, from the DDL text alone |
Expand Down
3 changes: 2 additions & 1 deletion docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Support differs by front door, so the matrix marks the exceptions:
file against the live table. This door depends on the canonical table *model*, which
is deliberately narrower: a table the model cannot fully describe gets a typed
refusal rather than a silently lossy description. Today that means tables that are
partitioned (or are partitions), own or are referenced by foreign keys, are unlogged,
partitioned (or are partitions), participate in classic table inheritance, own or are referenced by foreign keys, are unlogged,
carry explicit collations, or take defaults from sequences they do not own.

An operation can therefore be T1 imperatively and T2 declaratively — foreign keys are
Expand Down Expand Up @@ -193,6 +193,7 @@ Status legend: ✅ T1 (supported today) · 🟡 T2 (planned; typed refusal today
| Table shape | Status | Engine path | Online-safety problem? | Behavior and why |
| --- | --- | --- | --- | --- |
| Plain tables + their indexes | ✅ | native, as-is | Yes | `diff`, `pull`, and desired-file rendering round-trip the canonical model |
| Classic table inheritance (`INHERITS`) | 🟡 | native, planned flow | Yes | Typed refusal for both parents and children: the model cannot express inheritance edges, and flattening inherited columns would produce a silently lossy baseline |
| Tables that own **or are referenced by** foreign keys | 🟡 | native, planned flow | Yes | Typed refusal on both sides — an incoming FK cannot be expressed in the table's own desired file, and a lossy description would be worse than none. Declarative FK support (composite keys as the primary case, two-phase `NOT VALID` → `VALIDATE` execution) is planned |
| Unlogged tables | 🟡 | native, planned flow | Yes | Typed refusal: persistence is not modeled, converging it (`SET LOGGED`) is a full rewrite, and rendering the table as plain `CREATE TABLE` would silently change crash-safety |
| Explicit column collations | 🟡 | native, planned flow | Yes | Typed refusal: dropping a `COLLATE` clause from a rendered baseline silently changes sort order and index semantics; a collation delta cannot converge without a rewrite |
Expand Down
3 changes: 3 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ with a typed refusal — never a silently wrong or incomplete result:
| --- | --- |
| Foreign keys (either direction) | Unsupported in the declarative model, in both directions. A desired file cannot declare a `REFERENCES` clause (refused at parse), and export refuses both sides of a foreign-key relationship: a table whose definition carries foreign-key constraints surfaces the parse gate's typed error, and a table that other tables reference refuses with its own typed error — a single-table baseline cannot carry incoming foreign-key topology, so rendering one would silently drop the relationship. Foreign-key **DDL is still supported through the statement front door** (`ADD FOREIGN KEY` routes to the online `NOT VALID` + `VALIDATE` sequence). Because a desired file can never declare a foreign key, `diff` on a live table that carries one plans a **destructive** `DROP CONSTRAINT` for it — gated like every destructive change, never auto-executed — and incoming foreign keys are invisible to a single-table diff entirely, so tables participating in foreign-key relationships in either direction should not be managed declaratively yet. |
| Partitioned tables | Partitioned parents and their partitions are introspectable, and the statement front door supports in-place changes on them (see the partitioned-parent rows above), but they cannot be expressed in or exported to a desired file: the model captures the partition key and attachment only to refuse — it does not carry partition bounds or the parent/partition topology. A partitioning mismatch between live and desired is a typed `diff` refusal, never a zero diff. |
| Classic table inheritance (`INHERITS`) | Both parents and children are refused during export. The model records classic inheritance edges in both directions only to fail closed: rendering a child would flatten inherited columns and rendering a parent would omit its children. Declarative partitions share `pg_inherits` but remain classified by `relispartition` and use the partition refusal above. |
| Unlogged tables | Persistence is not managed: converging it (`SET LOGGED` / `SET UNLOGGED`) is a full table rewrite. Export refuses an unlogged table — a plain `CREATE TABLE` baseline would silently change its crash-safety and replication behavior — and a persistence mismatch between live and desired is a typed `diff` refusal, never a zero diff. |
| Table and column comments | Comments are metadata outside the table-shape model. Desired files and exports do not carry them; manage them with owner tooling. |
| Storage parameters (`fillfactor`, etc.) | Storage parameters are not represented in desired files or exports. Manage them separately until the declarative model can classify their convergence and rewrite implications. |
| Column collations | An explicit `COLLATE` on a column is not managed: converging a collation delta rewrites the column and its indexes. Export refuses a collated column — a baseline without the clause would silently change sort order and index semantics — and a collation delta (including on an added column) is a typed `diff` refusal. |
| Non-table objects | Views, materialized views, standalone sequences, enums, domains, extensions, functions, and triggers are outside the model. A serial column's owned sequence is the one exception: it round-trips through the `serial` pseudo-types — and ownership is verified through the catalog (`pg_depend`), so a hand-written `nextval` default on a standalone sequence that merely carries the serial-style name refuses rather than exporting as `serial` and silently privatizing a shared sequence. A column may *use* an unmanaged type (an enum, a domain) — the type text round-trips — but the type's definition is not managed. |
| Multiple tables per file | A desired file is single-table scoped: exactly one `CREATE TABLE` plus `CREATE INDEX` statements on it. Multi-table schemas are managed as one file per table. |
Expand Down
16 changes: 14 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package cli defines the pg-sprite command tree (Kong): migrate and
// status (the optimistic front door), diff and fmt (the declarative front
// door), and lint and suggest (the offline checker and advisor).
// status (the optimistic front door), pull, diff, and fmt (the declarative
// front door), and lint and suggest (the offline checker and advisor).
package cli

import (
Expand All @@ -21,6 +21,7 @@ type CLI struct {
Version kong.VersionFlag `help:"Print version and exit."`

Migrate MigrateCmd `cmd:"" help:"Run a schema change safely."`
Pull PullCmd `cmd:"" help:"Export live tables to new desired-state schema files. Output is create-only: a second run into a populated directory fails per table; delete or move existing files to refresh them."`
Diff DiffCmd `cmd:"" help:"Diff a desired-state schema file against the live schema."`
Fmt FmtCmd `cmd:"" help:"Canonicalize a schema file."`
Lint LintCmd `cmd:"" help:"Lint DDL for unsafe patterns."`
Expand Down Expand Up @@ -122,6 +123,17 @@ func (c *MigrateCmd) Validate() error {
// Run implements the migrate subcommand.
func (c *MigrateCmd) Run() error { return c.run(context.Background(), os.Stdout) }

// PullCmd exports every table in one schema as a desired-state schema file.
type PullCmd struct {
DBFlags `embed:""`

Schema string `help:"Schema containing the tables to export." default:"public"`
Out string `help:"Directory for create-only exported .sql files; delete or move existing files before refreshing." short:"o" default:"schema" type:"path"`
}

// Run implements the pull subcommand.
func (c *PullCmd) Run() error { return c.run(context.Background(), os.Stdout) }

// DiffCmd derives statements from a desired-state schema (declarative
// front-end): introspect the live table, materialize the desired state on a
// rolled-back scratch schema, and print the ordered plan without executing
Expand Down
218 changes: 218 additions & 0 deletions internal/cli/pull.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package cli

import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/jackc/pgx/v5/pgxpool"

"github.com/block/pg-sprite/pkg/dbconn"
"github.com/block/pg-sprite/pkg/schemadiff"
"github.com/block/pg-sprite/pkg/verdict"
)

// ErrPullFailed is returned when at least one table could not be
// introspected or written. Render refusals use verdict.ErrRefused instead.
var ErrPullFailed = errors.New("one or more tables could not be pulled")

type pullStatus string

const (
pullStatusPulled pullStatus = "pulled"
pullStatusRefused pullStatus = "refused"
pullStatusError pullStatus = "error"
)

type pullResult struct {
table string
path string
status pullStatus
err error
}

type tablePuller func(context.Context, *pgxpool.Pool, string, string, string) error

func (c *PullCmd) run(ctx context.Context, out io.Writer) error {
pool, err := dbconn.NewPool(ctx, c.Config())
if err != nil {
return err
}
defer pool.Close()

var schemaExists bool
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1)`, c.Schema).Scan(&schemaExists); err != nil {
return fmt.Errorf("check schema %s: %w", c.Schema, err)
}
if !schemaExists {
return fmt.Errorf("schema %q does not exist", c.Schema)
}
tables, err := listTables(ctx, pool, c.Schema)
if err != nil {
return err
}
if err := os.MkdirAll(c.Out, 0o755); err != nil {
return fmt.Errorf("create output directory %s: %w", c.Out, err)
}
results := pullTables(ctx, pool, c.Schema, c.Out, tables, pullOneTable)
if err := writePullText(out, results); err != nil {
return err
}
return pullResultsError(results)
}

func listTables(ctx context.Context, pool *pgxpool.Pool, schema string) ([]string, error) {
rows, err := pool.Query(ctx, `
SELECT c.relname
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p')
AND NOT c.relispartition
AND NOT EXISTS (
SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.classid = 'pg_class'::regclass
AND d.objid = c.oid AND d.deptype = 'e'
)
ORDER BY c.relname`, schema)
if err != nil {
return nil, fmt.Errorf("list tables in schema %s: %w", schema, err)
}
defer rows.Close()
var tables []string
for rows.Next() {
var table string
if err := rows.Scan(&table); err != nil {
return nil, fmt.Errorf("scan table in schema %s: %w", schema, err)
}
tables = append(tables, table)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read tables in schema %s: %w", schema, err)
}
return tables, nil
}

func pullTables(ctx context.Context, pool *pgxpool.Pool, schema, outDir string, tables []string, pull tablePuller) []pullResult {
results := make([]pullResult, 0, len(tables))
seenPaths := make(map[string]string, len(tables))
for _, table := range tables {
if err := ctx.Err(); err != nil {
results = append(results, pullResult{table: table, status: pullStatusError, err: err})
break
}
path, err := tableOutputPath(outDir, table)
if err != nil {
results = append(results, pullResult{table: table, status: pullStatusError, err: err})
continue
}
pathKey := strings.ToLower(filepath.Base(path))
if other, ok := seenPaths[pathKey]; ok {
results = append(results, pullResult{
table: table, status: pullStatusError,
err: fmt.Errorf("output file name has a case collision with table %q", other),
})
continue
}
seenPaths[pathKey] = table
err = pull(ctx, pool, schema, table, path)
result := pullResult{table: table, path: path, status: pullStatusPulled}
if err != nil {
result.status = pullStatusError
result.err = err
var refusal *renderRefusal
if errors.As(err, &refusal) {
result.status = pullStatusRefused
}
}
results = append(results, result)
}
return results
}

func tableOutputPath(outDir, table string) (string, error) {
name := table + ".sql"
if filepath.Base(name) != name || name == ".sql" {
return "", fmt.Errorf("table %q cannot be represented as a safe file name", table)
}
return filepath.Join(outDir, name), nil
}

type renderRefusal struct{ err error }

func (e *renderRefusal) Error() string { return e.err.Error() }
func (e *renderRefusal) Unwrap() error { return e.err }

func pullOneTable(ctx context.Context, pool *pgxpool.Pool, schema, table, path string) error {
model, err := schemadiff.Introspect(ctx, pool, schema, table)
if err != nil {
return fmt.Errorf("introspect %s.%s: %w", schema, table, err)
}
rendered, err := schemadiff.Render(model)
if err != nil {
return &renderRefusal{err: err}
}
return pullRenderedFile(path, rendered)
}

func pullRenderedFile(path, rendered string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
if _, err := io.WriteString(file, rendered); err != nil {
closeErr := file.Close()
removeErr := os.Remove(path)
return errors.Join(fmt.Errorf("write %s: %w", path, err), closeErr, removeErr)
}
if err := file.Close(); err != nil {
removeErr := os.Remove(path)
return errors.Join(fmt.Errorf("close %s: %w", path, err), removeErr)
}
return nil
}

func writePullText(out io.Writer, results []pullResult) error {
counts := map[pullStatus]int{}
for _, result := range results {
var err error
switch result.status {
case pullStatusPulled:
counts[result.status]++
_, err = fmt.Fprintf(out, "PULLED %s -> %s\n", result.table, result.path)
case pullStatusRefused:
counts[result.status]++
_, err = fmt.Fprintf(out, "REFUSED %s: %v\n", result.table, result.err)
case pullStatusError:
counts[result.status]++
_, err = fmt.Fprintf(out, "ERROR %s: %v\n", result.table, result.err)
default:
return fmt.Errorf("write pull report: unexpected pull status %q for table %q", result.status, result.table)
}
if err != nil {
return fmt.Errorf("write pull report: %w", err)
}
}
if _, err := fmt.Fprintf(out, "Summary: %d pulled, %d refused, %d errors\n",
counts[pullStatusPulled], counts[pullStatusRefused], counts[pullStatusError]); err != nil {
return fmt.Errorf("write pull report: %w", err)
}
return nil
}

func pullResultsError(results []pullResult) error {
refused := false
for _, result := range results {
if result.status == pullStatusError {
return ErrPullFailed
}
refused = refused || result.status == pullStatusRefused
}
if refused {
return verdict.ErrRefused
}
return nil
}
90 changes: 90 additions & 0 deletions internal/cli/pull_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package cli

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/block/pg-sprite/internal/testutil"
"github.com/block/pg-sprite/pkg/dbconn"
"github.com/block/pg-sprite/pkg/lint"
"github.com/block/pg-sprite/pkg/verdict"
)

func TestPullExportsAllRenderableTablesAndReportsRefusals(t *testing.T) {
url := testutil.StartPostgres(t)
pool, err := dbconn.NewPool(t.Context(), dbconn.Config{URL: url})
require.NoError(t, err)
t.Cleanup(pool.Close)
schema := testutil.NewSchema(t, pool)

for _, ddl := range []string{
fmt.Sprintf("CREATE TABLE %s.accounts (id bigint PRIMARY KEY, name text NOT NULL)", schema),
fmt.Sprintf("CREATE TABLE %s.events (id bigint PRIMARY KEY, created_at timestamptz DEFAULT now())", schema),
fmt.Sprintf("CREATE INDEX events_created_at_idx ON %s.events (created_at)", schema),
fmt.Sprintf("CREATE TABLE %s.metrics (id bigint, day date) PARTITION BY RANGE (day)", schema),
fmt.Sprintf("CREATE TABLE %s.metrics_2026 PARTITION OF %s.metrics FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')", schema, schema),
} {
_, err := pool.Exec(t.Context(), ddl)
require.NoError(t, err)
}

outDir := filepath.Join(t.TempDir(), "pulled")
cmd := &PullCmd{DBFlags: DBFlags{URL: url}, Schema: schema, Out: outDir}
var out strings.Builder
err = cmd.run(t.Context(), &out)
require.ErrorIs(t, err, verdict.ErrRefused)

assert.Contains(t, out.String(), "PULLED accounts -> ")
assert.Contains(t, out.String(), "PULLED events -> ")
assert.Contains(t, out.String(), "REFUSED metrics: render table \"metrics\": partitioned parent")
assert.NotContains(t, out.String(), "metrics_2026")
assert.Contains(t, out.String(), "Summary: 2 pulled, 1 refused, 0 errors")

entries, err := os.ReadDir(outDir)
require.NoError(t, err)
require.Len(t, entries, 2)
assert.Equal(t, "accounts.sql", entries[0].Name())
assert.Equal(t, "events.sql", entries[1].Name())
for _, entry := range entries {
raw, err := os.ReadFile(filepath.Join(outDir, entry.Name()))
require.NoError(t, err)
report, err := lint.Check(string(raw))
require.NoError(t, err, entry.Name())
assert.Zero(t, report.Errors, entry.Name())
}
}

func TestPullRefusesBothSidesOfClassicInheritance(t *testing.T) {
url := testutil.StartPostgres(t)
pool, err := dbconn.NewPool(t.Context(), dbconn.Config{URL: url})
require.NoError(t, err)
t.Cleanup(pool.Close)
schema := testutil.NewSchema(t, pool)
_, err = pool.Exec(t.Context(), fmt.Sprintf("CREATE TABLE %s.parent (id bigint NOT NULL)", schema))
require.NoError(t, err)
_, err = pool.Exec(t.Context(), fmt.Sprintf("CREATE TABLE %s.child (note text) INHERITS (%s.parent)", schema, schema))
require.NoError(t, err)

var out strings.Builder
err = (&PullCmd{DBFlags: DBFlags{URL: url}, Schema: schema, Out: t.TempDir()}).run(t.Context(), &out)
require.ErrorIs(t, err, verdict.ErrRefused)
assert.Contains(t, out.String(), "REFUSED child:")
assert.Contains(t, out.String(), "REFUSED parent:")
assert.Contains(t, out.String(), "Summary: 0 pulled, 2 refused, 0 errors")
}

func TestPullRejectsMissingSchema(t *testing.T) {
url := testutil.StartPostgres(t)
outDir := filepath.Join(t.TempDir(), "must-not-exist")
var out strings.Builder
err := (&PullCmd{DBFlags: DBFlags{URL: url}, Schema: "missing_schema", Out: outDir}).run(t.Context(), &out)
require.ErrorContains(t, err, `schema "missing_schema" does not exist`)
assert.NoDirExists(t, outDir)
assert.Empty(t, out.String())
}
Loading
Loading