Database Migrations

Adapted from docs/admin/DatabaseMigrations.md in the Wikantik repository; current as of release 2.4.56.

This guide is for operators who apply schema changes to a Wikantik PostgreSQL database and for developers who add them. It covers how the migration runner works, how to apply and check migrations, the role model for least-privilege production deployments, how to add a migration, what the runner does not do, and the migration history.

The schema is defined only by the numbered files in bin/db/migrations/ (the conventions are in its README.md). The postgresql*.ddl snapshots were retired on 2026-08-22, and the unit-test fixture PostgresTestDb applies the same files to a throwaway pgvector container, so there is no second schema definition to keep in step. For bootstrapping a server, see Developing with PostgreSQL.

How migrations run

bin/db/migrate.sh applies every V*.sql file in bin/db/migrations/ that is not yet recorded in the schema_migrations ledger (version VARCHAR(64) PRIMARY KEY, applied_at TIMESTAMP), in version order. Each migration runs in its own transaction (psql --single-transaction), and its version is recorded only after it succeeds; a failure rolls that migration back and stops the run. Re-running against an up-to-date database is a no-op.

VariableDefaultMeaning
DB_NAMEwikantikTarget database
DB_APP_USERwikantikRole granted DML access; exposed to migrations as the psql variable :app_user
PGHOST / PGPORTlocalhost / 5432Connection
PGUSERmigrateRole that runs the migrations. Set PGUSER=postgres if you have not provisioned the migrate role.
PGPASSWORDunsetOptional; ~/.pgpass works too
DB_EXPORTER_PASSWORDunsetPassed to V031 as :exporter_password. When unset, V031 leaves the wikantik_exporter role NOLOGIN.

Which role runs migrate.sh depends on how you deploy:

Apply and check migrations

bin/db/migrate.sh              # apply pending migrations
bin/db/migrate.sh --status     # list applied versions

# Production, with connection variables set
DB_NAME=wikantik PGHOST=db.example.com PGUSER=migrate PGPASSWORD='…' bin/db/migrate.sh

For a running container, without restarting Tomcat:

bin/container.sh -e prod migrate            # apply pending migrations
bin/container.sh -e prod migrate --status
bin/remote.sh migrate [--status]            # same, on the remote host over ssh

bin/db/install-fresh.sh is the fresh-database path: it creates the database and application role, runs migrate.sh, and optionally provisions the migrate role. It requires either DB_MIGRATE_PASSWORD or --no-migrate-role.

Roles

Production deployments split database privileges so the application role never issues DDL at runtime:

RoleUsed whenPrivileges
postgres (superuser)Initial provisioningCreates the database, the app role and the vector extension
migrateEvery manual or production migration runCREATE/USAGE on schema public, CREATEROLE, pg_monitor WITH ADMIN OPTION, membership in the app role, and ownership of the public-schema tables
wikantik (app role)Application runtimeSELECT/INSERT/UPDATE/DELETE on tables, USAGE/SELECT on sequences
wikantik_exporterMetrics scraping (V031)pg_monitor membership; NOLOGIN unless DB_EXPORTER_PASSWORD was set when V031 ran

The migrate role needs CREATEROLE and pg_monitor WITH ADMIN OPTION because V031 creates wikantik_exporter and grants it pg_monitor. It needs table ownership so later ALTER TABLE migrations succeed and so it can write the schema_migrations ledger.

Provision the migrate role

Run as a PostgreSQL superuser. Either pass DB_MIGRATE_PASSWORD to install-fresh.sh (which runs the provisioning step for you), or run the step on its own:

sudo -u postgres DB_NAME=wikantik DB_APP_USER=wikantik \
    DB_MIGRATE_PASSWORD='<strong-password>' \
    bin/db/create-migrate-user.sh

create-migrate-user.sh is idempotent: re-running it after a password rotation refreshes the password and re-applies the grants. It requires DB_MIGRATE_PASSWORD and refuses to set a default. It does not install extensions (the superuser installs vector once, before V004) and does not create the database. It transfers ownership of existing public-schema tables and sequences with a targeted loop, not a blanket REASSIGN OWNED.

Provisioning the role is optional for local development. It matters when you want to exercise the same least-privilege path as production.

What is and is not implemented

PieceStatus
migrate role and create-migrate-user.shImplemented
migrate.sh defaulting to PGUSER=migrateImplemented
wikantik_exporter monitoring role (V031)Implemented
migrate.sh --baseline <version> (record migrations as applied without running them)Not implemented; migrate.sh accepts only --status and --help and exits 2 on anything else
Checksum verification of applied migrationsNot implemented; schema_migrations has only version and applied_at
Secret-store integration for the migrate passwordNot implemented; the password comes from ~/.pgpass or PGPASSWORD on the deploy host

Because there is no baseline flag, a database that predates the ledger is brought forward by relying on migrations being idempotent, so running one against a database that already matches it is a no-op. A migration that inserts seed data or drops a column without a guard would break that assumption, so write every migration to be safe to re-run.

Add a migration

Every commit that changes the database schema must add the next numbered migration.

  1. Pick the next number: the highest V*.sql in bin/db/migrations/ plus one.
  2. Create V<NNN>__<snake_case_description>.sql. One migration is one logical change.
  3. Write idempotent DDL: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, INSERT … ON CONFLICT DO NOTHING, INSERT … WHERE NOT EXISTS.
  4. Use the :app_user psql variable for grants; never hard-code a role name.
  5. Document prerequisites (an extension, an earlier migration) at the top of the file.
  6. Run bin/db/migrate.sh against a local database, then run it a second time and confirm it is a no-op.
  7. Commit the migration in the same commit as the code that needs it.

Rules that follow from the ledger:

Migration history

The migration directory is the source of truth; this table summarises it through V061.

MigrationWhat it adds
V001schema_migrations ledger, which bootstraps the runner itself
V002Core auth: users, roles, groups, group_members, plus the default admin account and Admin group
V003policy_grants: database-backed authorisation
V004Knowledge Graph baseline: kg_nodes, kg_edges, kg_proposals, kg_rejections; installs the vector extension
V005hub_centroids, hub_proposals
V006, V007hub_discovery_proposals and status tracking
V008kg_content_chunks: page-passage chunking for retrieval
V009content_chunk_embeddings: Ollama-backed dense embeddings, stored as BYTEA little-endian float32 (V032 later adds the pgvector column alongside it)
V010api_keys: bearer-token auth for the MCP and tools servers
V011chunk_entity_mentions: joins KG nodes to chunks
V012Purge the links_to edges written by the retired graph projector
V013page_canonical_ids, page_slug_history: rename-stable identifiers
V014page_verification and trusted_authors: page verification and the trusted-author registry
V015Deduplicate user profiles (one-time data fix)
V016retrieval_query_sets, retrieval_queries, retrieval_runs: retrieval-quality CI
V017Seeds the default retrieval query set
V018kg_cluster_policy, kg_policy_audit, kg_excluded_pages: KG inclusion policy
V019Drop the legacy kg_embeddings and kg_content_embeddings tables
V020kg_proposals.signature: dedupe column for the entity extractor
V021, V022kg_node_embeddings and model_code
V023Drop page_relations; typed relations frontmatter was retired in favour of canonical_id, cluster and tags
V024KG staged validation tables
V025KG judge timeout tracking
V026Seed additional retrieval query-set rows
V027kg_edges_relationship_type_check: restrict relationship_type to the allowed vocabulary
V028kg_edge_audit: audit trail for KG edge create, update and delete
V029Add the CONFIRM action to the kg_edge_audit CHECK
V030Allow generalizes in the kg_edges relationship-type CHECK
V031wikantik_exporter role for metrics scraping
V032content_chunk_embeddings.embedding vector(1024) and an HNSW index; the pgvector dual-write
V033comment_threads, comments
V034page_owners, comment_mentions
V035Seed the agents service account, the default owner of agent-authored pages
V036audit_log: tamper-evident hash-chained action record, monthly-partitioned
V037Widen audit_log.detail to TEXT
V038drift_sweeps, drift_snapshot_counts: the /admin/drift burn-down
V039users.password_must_change; flags the seeded default admin
V040citations: version-pinned, span-hashed citation edges from cite:// markup
V041retrieval_query_log: append-only capture of real retrieval queries
V042users.last_login
V043Canonicalise the admin AllPermission policy grant
V044briefing_log: context-briefing telemetry
V045bundle_eval_run: scheduled bundle-eval results
V046connector_sync_state: per-connector cursor and hash state
V047connector_credentials: encrypted connector secret store
V048connector_configs: admin-managed connector definitions
V049connector_sync_run: per-run connector sync history
V050search_visibility_snapshot: Content Intelligence search-visibility facts
V051retrieval_query_log demand-signal columns
V052content_change_log: effect-measurement ledger for applied content changes
V053content_opportunity_snooze
V054content_opportunity_seen
V055content_change_log calibration columns
V056imported_opportunity
V057expected_ctr_curve
V058Widen the api_keys.scope CHECK to admit the mcp_read scope
V059Pre-create audit_log monthly partitions through 2028-12, so a least-privilege app role never has to create one
V060Add the export wiki permission to the Authenticated grant (only while that row still holds the stock actions)
V061Widen the api_keys.scope CHECK to admit mcp_admin (the renamed full-admin MCP scope); existing mcp rows are kept and read as an alias

The other .sql files directly under bin/db/ (migration-1.0-to-1.1.sql, cleanup-2026-04-30-stale-canonical-ids.sql, normalize-relationship-types.sql) are historical one-offs, not part of the ledger.

Known unused index

idx_kg_nodes_properties (a GIN index on kg_nodes.properties, created by V004) is not used by application queries: KgNodeRepository searches with LOWER( n.properties::text ) LIKE ?, which bypasses a GIN index. Using it would need JSONB containment (@>); until then it only costs writes. The other V004 edge indexes are not flagged here: idx_kg_edges_type backs the relationship_type filter in KgEdgeRepository.

Troubleshoot

SymptomLikely causeFix
Could not connect to database … as migrate@…The migrate role does not exist yet, or connection variables are wrongProvision it (above), or run with PGUSER=postgres; check PGHOST/PGPORT/PGPASSWORD/DB_NAME
permission denied to create role on V031; deploy aborts and Tomcat does not startThe role running migrate.sh lacks CREATEROLERun bin/db/create-migrate-user.sh as a superuser
must have admin option on role "pg_monitor" on V031migrate is not a pg_monitor adminSame provisioning step; it grants pg_monitor WITH ADMIN OPTION
permission denied for table schema_migrations after a migration's DDL ranmigrate cannot write the ledger (table owned by another role)Run create-migrate-user.sh (its ownership transfer covers schema_migrations), or as a superuser GRANT INSERT,SELECT,UPDATE,DELETE ON schema_migrations TO migrate;
must be owner of table X during an ALTER migrationThe table is owned by a role other than the one running migrationsRun create-migrate-user.sh, or run migrate.sh as the table owner
extension "vector" is not available on V004pgvector is not installed on the serverSee the pgvector installation section of Developing with PostgreSQL
A migration fails midwayThe migration's transaction rolled back and nothing was recordedRead the error, fix the cause, and re-run migrate.sh; the retry is safe
Unknown argument: --baseline (exit 2)--baseline was never implementedOnly --status and --help exist

When to consider Flyway, Liquibase or Sqitch

The bash runner is enough until one of these becomes true: you need repeatable migrations (views or functions reapplied on change), you want a pending report that also validates checksums without executing, a second application shares the database and needs coordinated history, or the team grows to the point where a human runbook stops scaling. Until then the runner means no new dependency and a script a newcomer can read top-to-bottom in a few minutes.


Cluster Hub: Wikantik Platform Development Hub: Architecture, MCP Protocols, and Agent Briefings (wikantik-development)