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.
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
lazyconcludes nothing moved and skips it. This is exactly why restoring a database underneath an install needs oneDB_SYNC=alwaysrun — 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 COLUMNfor 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 existsThat 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 startpnpm 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=600000The 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.mjs — nft_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.mjs — transaction 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.mjs — two_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.mjs — ecosystem_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 seedThat 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:generateThis 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 startTo 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.