name: writing-migrations description: >- Creates and modifies PostgreSQL database migrations for xmtpd using golang-migrate. Use when adding or altering tables, columns, indexes, functions, triggers, constraints, or partitions, or when the user mentions migrations or schema changes.
Writing Database Migrations
Creating a Migration
Run dev/gen/migration {name} to produce:
pkg/db/migrations/NNNNN_name.up.sqlpkg/db/migrations/NNNNN_name.down.sql
5-digit sequential numbering. Hyphen-separated lowercase names (e.g., add-latest-block, payer-nonces, add-dead-letter-box).
How Migrations Run
- Embedded via
//go:embed *.sqlinpkg/db/migrations/migrations.go - Uses golang-migrate with PostgreSQL driver
- Each migration runs in a transaction
- Tracked in
schema_migrationstable
SQL Formatting
Same as queries: tab indentation, UPPERCASE keywords/functions/types, lowercase identifiers.
Additional conventions:
- Comment at top explains purpose when non-obvious
SET statement_timeout = 0for long-running DDL (00006, 00018)IF NOT EXISTS/IF EXISTSfor idempotency
Up Migration Patterns
CREATE TABLE
Project data type conventions:
| Type | Usage |
|---|---|
BYTEA |
Binary data (keys, hashes, envelopes, topics) |
TEXT |
String identifiers (addresses) |
BIGINT |
Sequence IDs, unix timestamps, picodollar amounts |
INTEGER |
Node IDs, payer IDs |
SMALLINT |
Enum/status codes |
SERIAL / BIGSERIAL |
Auto-increment PKs |
TIMESTAMP |
With DEFAULT NOW() or DEFAULT CURRENT_TIMESTAMP |
BOOLEAN |
Flags |
INT[] |
Integer arrays |
Primary keys
Composite PKs are common:
PRIMARY KEY (originator_node_id, originator_sequence_id)
CHECK constraints
Singleton pattern:
CONSTRAINT is_singleton CHECK (singleton_id = 1)
CREATE INDEX
Naming: {table_prefix}_{columns}_idx (e.g., gem_time_node_seq_idx, gem_expiry_idx).
Use INCLUDE for covering indexes, filtered indexes with WHERE.
PL/pgSQL functions
CREATE OR REPLACE FUNCTION name(params)
RETURNS type AS $$
BEGIN
...
END;
$$ LANGUAGE plpgsql;
SQL functions
CREATE OR REPLACE FUNCTION name(params)
RETURNS TABLE(...)
LANGUAGE SQL AS $$
...
$$;
Triggers
Row-level:
FOR EACH ROW EXECUTE FUNCTION func()
Statement-level (preferred for bulk):
REFERENCING NEW TABLE AS new FOR EACH STATEMENT EXECUTE FUNCTION func()
Views
CREATE OR REPLACE VIEW name AS SELECT ...
Partitioning
Two-level: LIST by originator_node_id, then RANGE by originator_sequence_id bands.
ALTER TABLE
For adding constraints, FKs:
ADD CONSTRAINT fk_name FOREIGN KEY (col) REFERENCES table(col)
Data seeding
INSERT INTO table VALUES (...), (...);
Versioning
Functions versioned with _v2 suffix rather than dropped/recreated (e.g., ensure_gateway_parts_v2, update_latest_envelope_v2).
Down Migration Patterns
Complete reversal in reverse dependency order:
DROP TRIGGER IF EXISTS name ON table(triggers first)DROP FUNCTION IF EXISTS name(then functions)DROP VIEW IF EXISTS nameDROP INDEX IF EXISTS nameALTER TABLE ... DROP CONSTRAINT IF EXISTS nameDROP TABLE IF EXISTS name CASCADE(parent tables last)
Always use IF EXISTS for safety.
Updating Migration Tests
In pkg/db/migrations/migrations_test.go:
- Increment
const currentMigrationto match new migration number - Add a
checkXxxfunction asserting new schema objects exist using helpers:tableExists(),indexExists(),functionExists(),triggerExists(),viewExists(),constraintExists() - Add corresponding
t.Run("NNNNN_name", ...)call inTestMigrations - Run
dev/test ./pkg/db/migrations/...
After Writing Migrations
- Run
dev/gen/allto regenerate sqlc (migrations are the schema source) - Run
dev/testto verify