This repo runs two independent Node processes against the same per-network
Postgres database (db-testnet, db-mainnet, db-devnet in docker-compose.yml,
each backing both *_DATABASE_URL and the plain DATABASE_URL the two services
read):
- The API service (
src/) uses Prisma (prisma/schema.prisma,prisma/migrations/) and applies its migrations withprisma migrate deploy(see theapi-*servicecommand:indocker-compose.yml). - The indexer service (
indexer/) uses a small hand-rolled SQL runner (indexer/src/migrate.js) that applies every*.sqlfile inindexer/migrations/, tracked in its ownschema_migrationstable. It runs automatically on everydb.init()call (indexer startup, and the handlers/tests listed inindexer/src/index.js,indexer/src/api.js, etc.).
Because two systems write DDL to one physical database with no documented ownership boundary, it was previously unclear which system owned which tables, what order they needed to run in, or how to bring up a fresh database for both at once. This document is the source of truth for that boundary.
Consolidating onto a single migration system was considered and rejected for this change: the Prisma schema has 218 models backing the API/GraphQL/billing surface, and the indexer's raw-SQL tables are written and read through hand-tuned queries (full-text search, keyset pagination, partitioned audit tables) that don't map cleanly onto Prisma's query builder. A rewrite of either side is a separate, much larger effort with its own risk profile.
Instead, this change documents and enforces a non-overlapping table boundary: each system owns a fixed, disjoint set of tables in the same database, and neither may create a table the other owns.
Auditing "which tables does Prisma own" against prisma/migrations/ surfaced
that fresh bring-up on the Prisma side had never actually worked: the
previous 17 migrations only ever created 68 of the 218 tables now in
prisma/schema.prisma (everything else, including base models like
Ledger, Contract, Transaction, and Event, had no migration at all),
and roughly half of the 68 they did create used table names that no longer
match their models after later refactors (e.g. a migration created
"emergency_states", but the current EmergencyState model has no
@@map, so Prisma expects "EmergencyState"). Given docker-compose.yml
runs prisma migrate deploy as its production start command, this means a
genuinely fresh deployment of the checked-in migrations would already have
been broken before this change, unrelated to indexer ownership.
This change replaces those 17 fragmented, partially-stale migrations with a
single baseline migration (prisma/migrations/20260617000000_baseline_schema/)
generated directly from prisma/schema.prisma via
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script,
so the migration history matches the schema exactly (verified 1:1: 218
CREATE TABLE statements, 7 CREATE TYPE statements) instead of by hand.
Going forward, treat this migration like any other Prisma migration — don't
edit it — and generate new ones with prisma migrate dev as usual.
Every model in prisma/schema.prisma — all 218 of them — is Prisma-owned.
Models without an explicit @@map(...) get their physical table named after
the model itself (e.g. Contract, Event, Ledger, Transaction), which
Prisma always double-quotes, making them case-sensitive identifiers distinct
from any lowercase, unquoted name the indexer could create. Models with
@@map(...) use that literal lowercase name instead — see the full list
below, since those are the ones that could actually collide with an indexer
table.
Prisma also owns its own migration bookkeeping table, _prisma_migrations.
Models with an explicit @@map:
abi_community_contributions archival_appeals archival_epochs
archival_nodes archival_slashes auth_events
auth_sessions auth_webhooks bridge_alerts
bridge_transactions bridge_volumes call_graph_edges
call_graph_vertices contract_risk_scores data_retrievals
monitored_addresses multisig_wallets nl_alerts
nl_embeddings nl_queries nl_query_contexts
nl_query_templates nl_report_history nl_reports
nl_sessions oauth_apps oauth_codes
reentrancy_alerts reentrancy_findings reentrancy_stats
saved_queries sla_acceptances sla_offers
storage_challenges wallet_users wasm_abi_extracts
Only *.sql files in indexer/migrations/ are live — indexer/src/migrate.js
reads and applies exactly that set, ordered by filename, and nothing else. The
tables they create:
| Table | Created by |
|---|---|
sandboxes |
001_create_sandboxes.sql |
events, contracts, ledger_hashes, daemon_state |
002_core_schema.sql |
sub_invocations, source_verifications, storage_state_diffs, quorum_freezes |
003_invocations_and_verifications.sql |
vaults, vault_snapshots, token_holders, privileged_roles, wasm_build_metadata |
004_vaults_and_tokens.sql |
api_keys, api_key_usage_daily, api_audit_log (+ its monthly partitions) |
005_api_auth_rate_limiting.sql |
contract_versions |
006_abi_versioning.sql |
(005_add_contract_version.sql, 005_performance_indexes.sql and
006_decoder_validation_constraints.sql only ALTER TABLE / add indexes on
tables above — no new tables.)
The indexer also owns its own migration bookkeeping table,
schema_migrations.
No table name may be owned by both systems. Concretely:
- Before adding
@@map("some_name")to a Prisma model, check it isn't in the indexer table list above. - Before adding a
CREATE TABLEto a newindexer/migrations/*.sqlfile, check it isn't in the Prisma@@maplist above (or a bare Prisma model name, though those are quoted/PascalCase and effectively can't collide with the indexer's lowercase, unquoted names).
This is a manual, documented rule rather than an automated check — the two systems have no shared schema-introspection point to check against automatically without one system depending on the other's tooling.
IndexerState (Prisma) and daemon_state (indexer SQL) are two separate
tables that both track "last indexed ledger" for their respective
process — one for the in-process TS indexer under src/indexer/, one for the
standalone indexer/ service. They don't collide physically, but the
duplication is a pre-existing design overlap between the "two-service
architecture" described in the README, not a migration-ownership problem, and
is left untouched here.
Within one database, apply migrations in this order:
- Prisma —
npx prisma migrate deploy(creates every Prisma-owned table plus_prisma_migrations). - Indexer —
node indexer/src/migrate.js(creates every indexer-owned table plusschema_migrations).
The two systems don't have foreign keys into each other's tables, so this
order isn't strictly required for correctness today, but it's the order both
docker-compose.yml (Prisma runs in each api-* container's command:
before the indexer container starts serving traffic) and CI use, and keeps
bring-up deterministic.
DATABASE_URL=postgresql://user:pass@host:5432/dbname npm run db:bringupThis runs scripts/db-bringup.ts, which performs both steps above against
one DATABASE_URL and fails fast (non-zero exit) if either step fails. The
script takes no flags — it only reads DATABASE_URL from the environment.
The db-bringup job in .github/workflows/ci.yml starts a fresh Postgres
16 service container and runs npm run db:bringup against it on every push
and pull request, so a schema change that breaks fresh bring-up for either
system fails CI.