import { Class } from '@spinajs/di'; import { Log } from '@spinajs/log-common'; import { DateTime } from 'luxon'; import type { TableQueryBuilder } from './builders.js'; import { OrmDriver } from './driver.js'; import { MigrationTransactionMode, OrmMigration } from './interfaces.js'; export declare const MIGRATION_TABLE_NAME = "spinajs_migration"; export declare const MIGRATION_LOCK_POLL_INTERVAL = 500; export declare const MIGRATION_LOCK_TIMEOUT = 30000; export declare const MIGRATION_LOCK_STALE_AFTER = 600000; /** * How many times one `acquireLock()` call may remove a lock row it judged stale. A steal is * not proof the row is gone - a DELETE can succeed and remove nothing - so without a cap the * stale branch is free to warn and retry forever. */ export declare const MIGRATION_LOCK_MAX_STEALS = 3; export type MigrationResolveAction = 'applied' | 'rolled-back'; /** * One row of the migration tracking table. */ export interface IMigrationRecord { Migration: string; CreatedAt: Date; StartedAt: Date; FinishedAt: Date | null; RolledBackAt: Date | null; Logs: string | null; Checksum: string | null; Batch: number; } /** * A migration class paired with the timestamp parsed out of its name. */ export interface IMigrationUnit { name: string; created: DateTime; type: Class; } export interface IMigrationRunOptions { /** * Record the migration as applied without running its `up()`. */ fake?: boolean; /** * Record into the connection's latest batch instead of opening a new one. `MigrationRunner.up` * passes it when one run returns to a connection it already migrated, so a run stays one batch * per connection - which is what `down()` rolls back. */ continueBatch?: boolean; } export interface IMigrationDownOptions extends IMigrationRunOptions { /** * Roll every applied migration back instead of only the last batch. */ all?: boolean; } export interface IMigrationStatusEntry { name: string; connection: string; applied: boolean; failed: boolean; rolledBack: boolean; pending: boolean; /** * The row was opened by a run that never reached either outcome: `StartedAt` is set while both * `FinishedAt` and `Logs` are NULL, and no run is currently holding the migration lock. A * process killed between the start and the outcome ( OOM, SIGKILL, a lost connection that took * the failure write down with it ) leaves exactly this. * * Orthogonal to `pending`, like `rolledBack`: such a migration IS pending and the next `up()` * WILL re-run it from the top. What the flag adds is that nobody knows how much of it already * reached the database. It never blocks a run - see `warnOnInterrupted` for why. */ interrupted: boolean; batch: number | null; startedAt: Date | null; finishedAt: Date | null; checksumMismatch: boolean; } /** * Fingerprint of a migration's source, used to detect a migration that was edited * after it had already been applied. */ export declare function migrationChecksum(type: Class): string; /** * Per-connection migration execution contract. Configure an alternative * implementation with db.Connections[n].Migration.Service (DI token). */ export declare abstract class OrmMigrationService { protected driver: OrmDriver; constructor(driver: OrmDriver); /** * Creates or upgrades the tracking tables this connection needs. * * NOTE on what is NOT here: `applied()`. It was part of this contract and had no production * caller - `status()` answers "what is applied", per unit and per connection, and is what the * runner, the CLI and every deploy gate go through. An abstract method that every custom * implementation must write and nothing ever calls is a tax with no payer, so it is a concrete * helper on `DefaultMigrationService` instead. */ abstract ensureStorage(): Promise; abstract up(units: IMigrationUnit[], options?: IMigrationRunOptions): Promise; abstract down(units: IMigrationUnit[], options?: IMigrationDownOptions): Promise; abstract status(units: IMigrationUnit[]): Promise; /** * Forces a migration's recorded state without running it - the escape hatch for a * run that died halfway and left the table lying. * * `unit` is optional so callers that only know a name (the CLI, the runner facade) keep * working; passing it lets an `'applied'` resolution stamp the checksum as a real run would. */ abstract resolve(name: string, action: MigrationResolveAction, unit?: IMigrationUnit): Promise; } export declare class DefaultMigrationService extends OrmMigrationService { protected Log: Log; protected get table(): string; protected get lockTable(): string; /** * Creates `name` unless it is already there, tolerating a second process that creates it in * the window between the probe and the CREATE. * * That window cannot be closed with a lock: the lock table is one of the tables being created * here, so it cannot guard its own creation. Two processes booting together therefore both see * "absent" and both issue a CREATE, and the loser must not take the whole boot down with it. * Only a table that really is present afterwards excuses the failure - anything else ( no * permission, bad DDL, dead connection ) is a genuine error and is rethrown. * * Returns true when the table was *absent at probe time* - which is not the same as "this * process created it", since the lost-race path returns true too. Callers use it to skip the * legacy upgrade path: a table that appeared inside the race window was created by a peer * running this same DDL, so it already carries the current shape. */ protected createTableIfAbsent(name: string, columns: (t: TableQueryBuilder) => void): Promise; ensureStorage(): Promise; protected records(): Promise; /** * Fills the columns the upgrade above has just added. A row written before they existed carries * nothing but `CreatedAt`, and a NULL `FinishedAt` reads as "never applied" - so without this * every migration the deployment ran years ago would run again over a schema that already has * it. `CreatedAt` is the only timestamp such a row has, so it is treated as both start and * finish. * * Row by row through the update builder rather than as three set-based `UPDATE`s, and that is * the point of the method: a set-based statement has to name the table itself, and the only * way to do that here is raw SQL. `Migration.Table` is configuration - a name that needs * quoting ( a reserved word, a dot, a space ) would then break this path alone, and only on a * deployment that already has rows, which is the least reachable corner in the file. The * builder quotes it exactly as every other statement in this class does. The cost is one UPDATE * per legacy row, on the single boot that performs the upgrade and never again. */ protected backfillLegacyRows(): Promise; /** * Migrations that finished successfully and were not rolled back - the raw rows, unmerged with * the registry. * * A convenience on this class rather than part of `OrmMigrationService`: nothing in the ORM, * the runner or the CLI calls it, because they all need the registry merged in and go through * `status()`. It is kept because a subclass, a script or a health check reaching for "what does * this connection think it has applied?" should not have to reimplement the applied-gate, and * getting that gate subtly wrong ( "a row exists" rather than the FinishedAt NOT NULL and * RolledBackAt NULL pair ) is the classic way to re-run a migration. */ applied(): Promise; /** * Opens a migration's row: a fresh one, or a reset of whatever a previous failed or * rolled-back attempt left behind. */ protected upsertStart(name: string, existing: IMigrationRecord | undefined): Promise; /** * Closes a migration's row as applied. The batch number is stamped here rather than at * insert time, so a row that never finishes carries no batch to be rolled back later. */ protected markFinished(name: string, batch: number, checksum: string): Promise; /** * Records why a migration died. Failed state is `FinishedAt` NULL *and* `Logs` set - the pair * `assertNoFailed` matches on - so this write establishes both rather than assuming the row * already carries a NULL `FinishedAt`. * * It cannot assume it: a migration that was applied and later rolled back is pending again * while still holding the old `FinishedAt`/`RolledBackAt` timestamps, and the reset * `upsertStart` issued for the retry is inside the transaction that just unwound. Writing only * `Logs` would leave `FinishedAt` set, and a half-applied migration would slip past the block. */ protected markFailed(name: string, err: Error): Promise; /** * A half-applied migration means the database is in a state nobody described. Refuse to * pile more schema changes on top of it. */ protected assertNoFailed(records: IMigrationRecord[]): void; /** * The shape of a row whose run never reached an outcome: `StartedAt`, written by `upsertStart`, * and neither of the two writes that close it - `markFinished`'s `FinishedAt` or `markFailed`'s * `Logs`. Nothing in this class produces it deliberately; a process killed between the start and * the outcome does. * * `RolledBackAt` is excluded on purpose. `resolve('rolled-back')` also leaves `FinishedAt` and * `Logs` NULL with `StartedAt` set, and that row is pending because somebody said so - not * abandoned. * * The predicate says nothing about how much of the migration reached the database. It says only * that nobody recorded the answer, which is exactly why it is worth surfacing. */ protected isInterrupted(rec: IMigrationRecord): boolean; /** * Is a migration run in flight on this connection right now? Read, never acquired: the caller is * `status()`, which must not block behind the run it is reporting on. * * The lock row is the only honest signal available, and it is judged exactly as `acquireLock` * judges it - a row younger than `StaleAfter` means somebody is inside a run, an older one means * the holder is presumed dead. Freshness rather than mere presence is what makes this usable * here: a process killed mid-migration leaves BOTH its open tracking row and its lock row * behind, so "a lock row exists" would hide every crash this is meant to surface, permanently. * * Two deliberate consequences. For `StaleAfter` after a crash the answer is "running" and the * open row is not yet reported as interrupted - the same window in which `acquireLock` still * waits for the holder, and with the same client-clock caveat documented there. And * `Lock.Enabled: false` removes the signal altogether, so the answer is "not running": an open * row then always reads as interrupted, which is right for the crash and wrong only for a report * taken while a run is genuinely in progress. */ protected runInProgress(): Promise; /** * Warns about every migration this run is about to re-run whose row says a previous attempt was * started and never closed. No lock check is needed here, unlike in `status()`: this runs inside * `withLock`, so the only run in flight on this connection is this one. * * It warns rather than blocks, and that is a judgement call worth stating. The row records that * a run STARTED, not that anything reached the database, so blocking would escalate "unknown" to * "refuse to migrate" - and it would do so for the common, harmless shapes too: an idempotent * `CREATE TABLE` that had not run yet, or any migration on a `PerMigration` / `PerRun` * connection, whose transaction unwound the partial work when the process died. In those cases * re-running from the top is exactly right, and a block would turn every OOM kill during a long * migration into an operator ticket. * * The case that is genuinely dangerous is `Transaction.Mode: None` ( the default ) plus * non-idempotent DML: half the INSERTs are already in, nothing recorded which half, and the * re-run applies them again. Non-idempotent DDL is the recoverable version of the same thing - * it fails, and the failed row then blocks properly. Neither is detectable from here, so the * warning describes them and leaves the decision with the operator, who is also the only party * that can look at the data. */ protected warnOnInterrupted(records: IMigrationRecord[], pending: IMigrationUnit[]): void; protected transactionMode(): MigrationTransactionMode; /** * True when this migration must run outside any wrapping transaction ( TypeORM parity: * `public transaction = false` on the migration class - needed for DDL that cannot be * transacted, such as MySQL index rebuilds ). * * That declaration is an *instance* field, assigned in the constructor, so it never reaches * the prototype - the resolved instance is the only place it can be read from. A prototype * getter or a static property is honoured too, so a migration may also opt out without * being constructed. */ protected optedOutOfTransaction(u: IMigrationUnit, instance?: OrmMigration): boolean; /** * Advisory only: transpilation differences move the checksum as readily as an edit does, * so this warns and never blocks. */ protected warnOnChecksumDrift(u: IMigrationUnit, records: IMigrationRecord[]): void; protected lockOptions(): { enabled: boolean; timeout: number; staleAfter: number; }; /** * Identity written into the lock row. It exists to answer "who is holding this?" when a run * blocks, so it has to survive being read on another machine. */ protected lockOwner(): string; /** * Takes the single row of the lock table, waiting for whoever has it. * * The row is claimed by INSERT rather than by "SELECT then INSERT": `Id` is unique, so the * database decides the winner in one statement and two processes racing here cannot both * succeed. A refused insert is therefore read as "somebody else holds it" - which is also why * the holder is re-read afterwards rather than guessed at. * * Staleness is judged against the *client* clock: `AcquiredAt` is written here as * `new Date()` and compared to this host's `Date.now()`. That is sound for the case this * lock is built for - one process migrating, crashing, and restarting to find its own * abandoned row - but on hosts whose clocks disagree the window is off by the skew, which * shows up as stealing too early or waiting too long. Stamping `AcquiredAt` from the * database ( a driver-level `CURRENT_TIMESTAMP` default and a server-side comparison ) would * remove the assumption; it needs dialect support that does not exist here yet. */ protected acquireLock(): Promise; /** * Drops the lock row unconditionally rather than only the row this process wrote. A run whose * lock was stolen as stale would otherwise have nothing to release, and the alternative - * deleting only `Owner = ours` - leaves the table holding a row nobody will clear if the owner * string ever changes underneath a run. Losing a stolen lock is the lesser harm: the thief * already assumed the run was dead. */ protected releaseLock(): Promise; /** * Concurrency guard around a whole run: one migration run per connection at a time, across * processes. Note the release is `finally` - a run that throws must not leave the connection * locked until the staleness window expires. */ protected withLock(fn: () => Promise): Promise; up(units: IMigrationUnit[], options?: IMigrationRunOptions): Promise; down(units: IMigrationUnit[], options?: IMigrationDownOptions): Promise; status(units: IMigrationUnit[]): Promise; resolve(name: string, action: MigrationResolveAction, unit?: IMigrationUnit): Promise; } //# sourceMappingURL=migration-service.d.ts.map