Schema management, DB_SYNC and the one-off migration scripts

How the schema is applied without migration files, what the .sync-hash manifest compares, and the version-gated repair scripts you must run by hand around 6.4.9, 6.5.8 and 6.6.3.

13 min readUpdated 9 August 2026schema, migrations, db-sync, seeders, sequelize

There are no migration files. Nothing in this product resembles a numbered migrations/ directory you can replay. The schema is applied by Sequelize's auto-sync during boot, and the small number of changes auto-sync physically cannot make ship as standalone scripts you run by hand, gated on the release that needs them.

That second half is the part that is easy to miss, and missing it is silent: the platform starts, the panel loads, and one table is a shape the code no longer expects.

How the schema is applied

Sequelize compares the model definitions against a fingerprint manifest and emits DDL for whatever moved. The mode is DB_SYNC in .env.

DB_SYNC What happens at boot
none Authenticate only. No DDL of any kind.
lazy (default) Read backend/.sync-hash, diff it against the live model definitions, and alter-sync only the models that changed — emitting column DDL only for the columns that changed.
always A full stock alter sync of every model, nothing skipped. The escape hatch for a schema that drifted outside Sequelize.
force DROPS and recreates every table. All data is lost.

It drops every table and recreates it empty. If the schema no longer matches the models, the setting you want is always. There is no confirmation prompt and no undo.

An unset DB_SYNC behaves as lazy.

What the manifest actually compares

backend/.sync-hash is a JSON manifest — version 2 — holding, per model: the physical table name, a hash of the model's declared indexes, and a hash per column of everything Sequelize can turn into DDL (type and its options, ENUM members, allowNull, default, primary key, auto-increment, uniqueness, comment, and the foreign-key reference with its ON DELETE / ON UPDATE rules).

Two things follow from that, and both matter operationally:

  • It fingerprints the model definitions, not the database. A column hand-altered in MySQL does not change the fingerprint, so lazy concludes nothing moved and skips it. This is exactly why restoring a database underneath an install needs one DB_SYNC=always run — see Backup and restore.
  • A comment is not a schema change. The v1 format hashed the text of every model file, so any edit anywhere forced a full alter sync of the whole schema: on 229 tables and roughly 2 800 columns that is about three minutes, because Sequelize's alter path re-emits ALTER TABLE … CHANGE COLUMN for every column of every table without diffing, plus a drop and re-add of all ~280 foreign keys. Version 2 makes the skip granular.

A manifest that is missing, unreadable or still in the v1 format is treated as "nothing can be vouched for" and falls back to a full alter sync. Deleting backend/.sync-hash therefore costs you one slow boot and nothing else.

A stock alter sync re-emits ADD FOREIGN KEY for association targets without checking whether an equivalent constraint already exists, so every pass appends another anonymous <table>_ibfk_<n>, and column-level unique constraints come back as <column>_2, <column>_3, and so on. Unchecked, both grow without bound: this schema had reached 62 copies of a single index against MySQL's hard limit of 64 keys per table, at which point ico_token_detail could not take another index at all.

The boot now drops constraints that are redundant with one that survives — same columns, same uniqueness, same referenced table — after every alter or force sync. But running DB_SYNC=always on every boot means paying for that churn and its cleanup on every boot. Set it for one run, then take it out.

An alter sync on MySQL also rewrites foreign keys in an order that is not stable, so a pass can try to drop a constraint an earlier statement in the same pass already removed:

Can't DROP FOREIGN KEY `user_ibfk_1`; check that it exists

That is the sync tripping over its own bookkeeping, not a broken schema. It is retried three times and usually converges. If it still cannot finish, the server starts anyway and logs that the schema may be behind the models — a running server you can fix beats a boot loop.

Where the schema step sits in an update

pnpm updator is a single &&-joined chain:

pnpm stop  →  ensure-deps  →  pnpm updator:migrate  →  pnpm seed  →  pnpm build:frontend  →  pnpm start

pnpm updator:migrate runs scripts/updator-migrate.js. Because Sequelize applies the schema during boot, the migration step is a boot: it runs backend/dist/index.js directly — not under PM2, so nothing can restart it behind your back — with CRON_MODE=off so it schedules nothing, with BICRYPTO_SCHEMA_SYNC_ONLY=true so the boot stops after the database phase, on the first port from NEXT_PUBLIC_BACKEND_PORT + 1 upward that is both bindable and not already accepting connections (4001–4020 on a default install). It then waits for GET /api/settings to answer — the observable that says initialisation finished — and kills the process.

The cap is 180 seconds. A genuinely large schema needs longer:

node scripts/updator-migrate.js --timeout=600000

The chain is joined by &&, so a failure at the migration step means nothing is seeded, nothing is rebuilt and the platform stays on the maintenance page. That is the correct place to stop — the alternative is seeders writing through models the database does not match yet — but nothing in the admin panel can tell you, because the admin panel is not running. The message is on the terminal that ran pnpm updator, and only there.

To bring the site back up on whatever code is on disk: pnpm start.

The BICRYPTO_SCHEMA_SYNC_ONLY flag exists because CRON_MODE=off alone was not enough. The boot still ran the extensions phase — the ecosystem matching engine loading every open order into memory, the futures matcher, forex venue connections, the Hummingbot supervisor — and on a large install that reached the 7.7 GB heap cap and was killed. An install could grow too large to update, while the update was the thing that would have fixed it. None of that phase has any bearing on a schema sync, which takes about 38 ms.

The version-gated one-off scripts

Some changes auto-sync cannot make at all. sync({ alter: true }) never drops a column, never drops an index, never drops a table, and cannot modify an existing foreign key's ON DELETE rule. Others it would make but only after data is cleaned up first — you cannot add a UNIQUE index to a table that already holds duplicates.

Those ship as pairs of package scripts, one per release that needs them.

The reporting form is the default and changes nothing. The :apply form runs the DDL. Take a database dump before running an :apply chain — these write to production tables and there is no undo built into any of them.

Report (safe) Apply
pnpm db:migrate:6.4.9:before pnpm db:migrate:6.4.9:before:apply
pnpm db:migrate:6.4.9:after pnpm db:migrate:6.4.9:after:apply
pnpm db:migrate:6.5.8:after pnpm db:migrate:6.5.8:after:apply
pnpm db:migrate:6.6.3 pnpm db:migrate:6.6.3:apply

The 6.6.3 pair carries no :before or :after because it changes no schema at all — it repairs the contents of two columns and can be run at any point, before or after the sync, with the platform up or down.

"Before" means before the schema sync — the data has to be cleaned up so the sync can then create the index or tighten the column. "After" means after it — these drop or rewrite things the sync has just declined to touch. In terms of the update chain: run a :before chain after pnpm stop and before pnpm updator:migrate, and an :after chain once the platform is back up.

Every script in every chain is idempotent. Each guards its DDL with an INFORMATION_SCHEMA existence check and discovers real constraint and index names at runtime rather than hardcoding them, so a second run — with or without --apply — is a clean no-op. Run the report form freely.

What each script fixes

All live in backend/scripts/ and can also be run individually, with the same --apply rule.

migration-ecosys06-broadcast-hash-lock.mjs — the broadcast-before-commit race. Ecosystem and UTXO withdrawals broadcast on-chain before the database reservation committed, so a crash in between left UTXOs re-selectable (a double-spend) and the broadcast hash lost (no idempotent retry). Converts ecosystem_utxo.status from a boolean to ENUM('UNSPENT','LOCKED','SPENT') preserving 0 → UNSPENT and 1 → SPENT, adds the transaction_ledger_applied idempotency bridge table, and adds two recovery-scan indexes on transaction and ecosystem_utxo.

deduplicate-kyc-applications.mjs — the kyc_application model now declares a compound unique index on (userId, levelId). If duplicates exist when the sync runs, index creation fails with a duplicate-key error. Keeps the row with the latest updatedAt per group and soft-deletes the rest — the table is paranoid, so nothing is destroyed.

dedupe-nft-sale-hashes.mjsnft_sale.transactionHash had no unique constraint, so two concurrent settles for the same blockchain transaction could each create a sale row. Soft-deletes the duplicates, keeping the oldest, then adds the unique index.

drop-investment-unique-index.mjs — the investment model declared a partial unique index on (userId, planId, status) with where: { status: "ACTIVE" }. MySQL and MariaDB silently ignore the WHERE clause on a unique index, so the constraint was unconditional — and because the model is paranoid, a CANCELLED row kept occupying the slot and blocked the user from ever re-investing in that plan. Drops it; "one active per plan" is now enforced in the creation handler.

fix-gateway-merchant-indexes.mjs — removes the accumulated duplicate single-column unique indexes on gateway_merchant.slug, .apiKey and .secretKey, keeping exactly one canonical index per column. Never touches the primary key, the FK helper index or any multi-column index.

fix-fk-on-delete.mjstransaction and wallet are soft-delete tables whose foreign keys to user were created ON DELETE CASCADE, so a hard user delete physically wiped the financial ledger. Re-points them to RESTRICT, and switches user.roleId → role to SET NULL so deleting a role no longer cascade-deletes its users. Auto-sync cannot change an existing FK's rule — it has to be dropped and re-added in raw SQL, which is what this does.

retire-staking-pool-legacy.mjs — drops the unused singular staking_pool table (the canonical one is staking_pools) and the duplicate foreign-key constraints on staking_positions.

backfill-method-status.mjs — sets status = true wherever it is NULL on deposit_method and withdraw_method, so the column can be tightened to NOT NULL DEFAULT true. A NULL and a false both meant "inactive"; the ALTER fails while any NULL remains.

add-wallet-address-lookup.mjs — provisions wallet.addressLookupKey and its unique index and backfills the hash for existing ecosystem wallets. The old findWalletByAddress() loaded every ecosystem wallet and searched the parsed JSON linearly, on the hot withdrawal path. Without the backfill, the new indexed lookup finds no legacy row.

add-transfer-spread-setting.mjs — inserts the walletTransferSpread settings row if absent, default 0.5 (%). It is the safety margin applied to the cross-currency wallet-transfer rate. INSERT-only, so an admin-tuned value is never overwritten.

backfill-binary-position-profit.mjs — writes the realised platformProfit on already-settled binary_ai_engine_position rows, using exactly the formula the settlement reconciler uses. The column was added after the addon, so older positions carry the 0 default and every historical cohort and time-of-day report read as zero profit. Only touches rows with an outcome of WIN or LOSS and a platformProfit of 0.

drop-binary-engine-payout-multiplier.mjs — drops binary_ai_engine.payoutMultiplier. It was labelled "Payout Multiplier" with an (85%) readout in the engine form, so it read as "what my users get paid", and it never was — payouts come from the per-type profitPercentage in binary settings, stamped on each order at placement. Three analytics modules used it to project profit; they now sum the realised figure the script above backfills, so run that one first.

migration-hash-recovery-codes.mjstwo_factor.recoveryCodes held the twelve recovery codes exactly as they were shown to the user. Each one is a complete second-factor bypass on its own: the 2FA login prompt takes one instead of the authenticator code, and so do the password-change, withdrawal and P2P escrow confirmations. So one SELECT on that table — from a backup, a reporting replica, a support export — yielded a working second factor for every 2FA-protected account on the install. Replaces each with an argon2id hash. The codes users wrote down keep working; nobody, including you, can read them out of the database again.

Skipping it does not lock anyone out — the login path reads both shapes and re-hashes whatever is left the first time a user redeems a code. But a recovery code is redeemed roughly once in an account's lifetime, so without this script the plaintext stays in the table indefinitely.

A row is refused whole, and reported, if any of its codes is not the twelve hex characters this platform generates. The verify line at the end counts what is genuinely still unhashed rather than what the run intended to write, so a refusal never reads as success.

migration-eco-token-json-unwrap.mjsecosystem_token.fee and .limits were stored as a JSON string containing JSON, on every row, because three admin writers handed an already-serialised string to a JSON column. The model's getter parses up to twice, so the withdrawal fee has always been charged correctly — this is not a live money defect. It is a trap with no margin: a third layer (which the token importer could add) makes the getter return null, which the withdraw path reads as "no fee configured" and charges zero. Any reader that bypasses the model — a raw query, a JSON export — already sees a string today.

Both are idempotent and refuse to write anything whose repaired form is not what they expect, so a second run is a clean no-op.

Seed data

pnpm seed

That is sequelize-cli db:seed:all --config ./config.js inside backend/. It runs every seeder in backend/seeders/ on every invocation — there is no "already applied" ledger — which is why each one is written as an idempotent insert: it reads what is already there and inserts only what is missing. The permissions seeder, for example, selects the existing permission rows and bulk inserts only the names not present.

Seeders carry the reference data the code expects to exist: the permission list, the four roles and the Super Admin account, fiat currencies, deposit gateways, notification templates, ecosystem blockchains and tokens, exchange rows, KYC services, the extension catalogue, blog and ecommerce slugs, reward conditions and the newer per-release rows.

It runs after the schema step and never before it, because it writes through the new models.

The seeder is the only writer to the permission table, so a key it does not list cannot be granted to any role and the gate answers 403 for every non-Super Admin, forever and silently. pnpm check:permission reports the drift. See Roles and permissions.

The package script is still listed in the root package.json, but the file it calls — scripts/update-notification-templates.js — is not in the repository. Running it fails immediately with MODULE_NOT_FOUND and touches nothing. There is no command that re-runs the notification templates on their own.

Run pnpm seed instead. Every seeder is idempotent, so re-running the whole set to restore one table is safe. Note what that restores: the notification-template seeder selects the name values already in notification_template and inserts only the ones missing, so it puts back a template you deleted but will not overwrite one you edited. To discard your edits to a template, delete the row first, then seed.

Types must regenerate before a build

pnpm --filter backend types:generate

This parses every model in backend/models/ with a TypeScript AST and writes backend/types/models.ts, caching a hash of the model files in backend/.types-hash so an unchanged tree is a no-op. types:generate:force ignores the cache. It runs automatically as prebuild and predev, so a normal pnpm build:backend already does it.

The extractor used to pattern-match the ENUM argument list. A closing parenthesis inside a comment ended the list early and silently dropped the remaining members; an apostrophe in prose ("the buyer's") opened a string literal that closed on the next apostrophe several lines down, emitting an unterminated string literal into types/models.ts. The result was not a bad type — it was a generated file that did not parse, and every tsx entry point in the repository died with a transform error pointing at code nobody had edited.

It now strips comments string-aware and walks the argument list counting parentheses. If you ever see a transform error naming types/models.ts, that is the shape to look for: delete backend/.types-hash and regenerate before assuming anything else is wrong.

MySQL strict mode, and why some changes are a script

Under STRICT_TRANS_TABLES, a direct CHANGE COLUMN from tinyint to an ENUM is rejected — Data truncated for column 'status' — because the existing 0 and 1 are not members of the target ENUM. Sequelize's alter sync emits exactly that statement and fails.

The conversion has to go in three steps: widen to VARCHAR (so 0/1 become the strings '0'/'1'), rewrite every value to a valid ENUM label, and only then tighten to the ENUM. That sequence cannot be expressed as a model definition, which is why migration-ecosys06-broadcast-hash-lock.mjs exists as a script rather than as a column change — and why it snapshots the raw values first and can resume a run interrupted mid-conversion.

The same reasoning is behind every other script in the list: dropping something, rewriting a foreign-key rule, or cleaning data so a constraint can be added are all outside what an alter sync can do.

Before you migrate: take the dump

mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p \
  --single-transaction --quick --routines --triggers --events \
  --default-character-set=utf8mb4 \
  "$DB_NAME" | gzip > /var/backups/pre-migration-$(date +%F-%H%M).sql.gz

--single-transaction is what the built-in backup screen is missing — it gives an InnoDB-consistent dump without locking the platform. --default-character-set=utf8mb4 is what keeps emoji in tickets and display names intact. Full detail in Backup and restore.

Telling drift from a failed migration

They present the same way — a column or index the code expects is not there — and the fix is different.

Symptom Reading
The migration step exited non-zero and the site is on the maintenance page Failed migration. Nothing was seeded and nothing was rebuilt. Read the step's own output; it names the cause.
The boot logged "Schema sync could not finish after 3 attempts" and the server started anyway Foreign-key ordering, not drift. Drop the duplicates the log's query finds and restart.
Everything started cleanly, but one column is the old shape Drift. The fingerprint says the model was already applied, so lazy skipped it. Almost always a restored dump, a hand-written ALTER, or a one-off script that was never run with --apply.
A feature complains about a column that is not in the table Check the version-gated list above first — one of those scripts is probably outstanding.

For drift, force one full reconcile and then take the flag back out:

DB_SYNC=always pnpm start

To see the duplicate constraints an alter sync is tripping over:

SELECT TABLE_NAME, COLUMN_NAME, COUNT(*)
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL
GROUP BY TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME
HAVING COUNT(*) > 1;

The symptom-first version of this is under The schema migration fails in Troubleshooting. For repair scripts that fix data rather than schema, see Repair and reconcile scripts.

A fresh install imports initial.sql

initial.sql at the project root is the schema a brand-new install loads before the seeders run, and it is generated from the models rather than hand-kept — a table missing from it is not a slow first boot, it is a failed install. pnpm initial-sql regenerates it; pnpm initial-sql:check compares it against the models but needs a reachable MySQL, which is why it is not part of the routine gate. Neither is something an operator runs on a live install.