cassandraorm-js
Version:
The most advanced ORM for Apache Cassandra and ScyllaDB with native TypeScript support, AI/ML integration, and enterprise-grade features
404 lines (340 loc) • 11.2 kB
text/typescript
import type { Client } from "cassandra-driver";
export interface MigrationStep {
id: string;
description: string;
up: () => Promise<void>;
down?: () => Promise<void>;
validate?: () => Promise<boolean>;
}
export interface SchemaEvolutionConfig {
enabled?: boolean;
migrationsTable?: string;
autoRun?: boolean;
backupBeforeMigration?: boolean;
validateAfterMigration?: boolean;
}
export interface MigrationRecord {
id: string;
description: string;
applied_at: Date;
execution_time_ms: number;
checksum?: string;
}
export class SchemaEvolution {
private client: Client;
private keyspace: string;
private config: Required<SchemaEvolutionConfig>;
private migrations: MigrationStep[] = [];
constructor(client: Client, keyspace: string, config: SchemaEvolutionConfig = {}) {
this.client = client;
this.keyspace = keyspace;
this.config = {
enabled: true,
migrationsTable: 'schema_migrations',
autoRun: false,
backupBeforeMigration: false,
validateAfterMigration: true,
...config
};
}
async initialize(): Promise<void> {
if (!this.config.enabled) return;
await this.createMigrationsTable();
}
private async createMigrationsTable(): Promise<void> {
await this.client.execute(`
CREATE TABLE IF NOT EXISTS ${this.keyspace}.${this.config.migrationsTable} (
id text PRIMARY KEY,
description text,
applied_at timestamp,
execution_time_ms int,
checksum text
)
`);
}
// Add migration step
addMigration(migration: MigrationStep): this {
this.migrations.push(migration);
return this;
}
// Fluent API for building migrations
migration(id: string, description: string): MigrationBuilder {
return new MigrationBuilder(this, id, description);
}
// Get applied migrations
async getAppliedMigrations(): Promise<MigrationRecord[]> {
const result = await this.client.execute(
`SELECT * FROM ${this.keyspace}.${this.config.migrationsTable}`
);
return result.rows.map(row => ({
id: row.id,
description: row.description,
applied_at: row.applied_at,
execution_time_ms: row.execution_time_ms,
checksum: row.checksum
})).sort((a, b) => a.applied_at.getTime() - b.applied_at.getTime());
}
// Get pending migrations
async getPendingMigrations(): Promise<MigrationStep[]> {
const applied = await this.getAppliedMigrations();
const appliedIds = new Set(applied.map(m => m.id));
return this.migrations.filter(m => !appliedIds.has(m.id));
}
// Run all pending migrations
async migrate(): Promise<MigrationRecord[]> {
if (!this.config.enabled) {
throw new Error('Schema evolution is disabled');
}
const pending = await this.getPendingMigrations();
const results: MigrationRecord[] = [];
for (const migration of pending) {
const result = await this.runMigration(migration);
results.push(result);
}
return results;
}
// Run specific migration
async runMigration(migration: MigrationStep): Promise<MigrationRecord> {
const startTime = Date.now();
try {
// Validate before running
if (migration.validate) {
const isValid = await migration.validate();
if (!isValid) {
throw new Error(`Migration ${migration.id} validation failed`);
}
}
// Run the migration
await migration.up();
const executionTime = Date.now() - startTime;
const record: MigrationRecord = {
id: migration.id,
description: migration.description,
applied_at: new Date(),
execution_time_ms: executionTime
};
// Record the migration
await this.client.execute(
`INSERT INTO ${this.keyspace}.${this.config.migrationsTable}
(id, description, applied_at, execution_time_ms)
VALUES (?, ?, ?, ?)`,
[record.id, record.description, record.applied_at, record.execution_time_ms],
{ prepare: true }
);
return record;
} catch (error) {
throw new Error(`Migration ${migration.id} failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Rollback migration
async rollback(migrationId: string): Promise<void> {
const migration = this.migrations.find(m => m.id === migrationId);
if (!migration) {
throw new Error(`Migration ${migrationId} not found`);
}
if (!migration.down) {
throw new Error(`Migration ${migrationId} has no rollback function`);
}
try {
await migration.down();
// Remove from migrations table
await this.client.execute(
`DELETE FROM ${this.keyspace}.${this.config.migrationsTable} WHERE id = ?`,
[migrationId],
{ prepare: true }
);
} catch (error) {
throw new Error(`Rollback of ${migrationId} failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Get migration status
async getStatus(): Promise<{
applied: number;
pending: number;
total: number;
lastMigration?: MigrationRecord;
}> {
const applied = await this.getAppliedMigrations();
const pending = await this.getPendingMigrations();
return {
applied: applied.length,
pending: pending.length,
total: this.migrations.length,
lastMigration: applied[applied.length - 1]
};
}
}
export class MigrationBuilder {
private evolution: SchemaEvolution;
private migration: Partial<MigrationStep>;
constructor(evolution: SchemaEvolution, id: string, description: string) {
this.evolution = evolution;
this.migration = { id, description };
}
// Add column to table
addColumn(tableName: string, columnName: string, columnType: string): this {
const currentUp = this.migration.up;
this.migration.up = async () => {
if (currentUp) await currentUp();
await this.evolution['client'].execute(
`ALTER TABLE ${this.evolution['keyspace']}.${tableName} ADD ${columnName} ${columnType}`
);
};
const currentDown = this.migration.down;
this.migration.down = async () => {
await this.evolution['client'].execute(
`ALTER TABLE ${this.evolution['keyspace']}.${tableName} DROP ${columnName}`
);
if (currentDown) await currentDown();
};
return this;
}
// Drop column from table
dropColumn(tableName: string, columnName: string): this {
const currentUp = this.migration.up;
this.migration.up = async () => {
if (currentUp) await currentUp();
await this.evolution['client'].execute(
`ALTER TABLE ${this.evolution['keyspace']}.${tableName} DROP ${columnName}`
);
};
return this;
}
// Create table
createTable(tableName: string, schema: string): this {
const currentUp = this.migration.up;
this.migration.up = async () => {
if (currentUp) await currentUp();
await this.evolution['client'].execute(
`CREATE TABLE IF NOT EXISTS ${this.evolution['keyspace']}.${tableName} ${schema}`
);
};
const currentDown = this.migration.down;
this.migration.down = async () => {
await this.evolution['client'].execute(
`DROP TABLE IF EXISTS ${this.evolution['keyspace']}.${tableName}`
);
if (currentDown) await currentDown();
};
return this;
}
// Drop table
dropTable(tableName: string): this {
const currentUp = this.migration.up;
this.migration.up = async () => {
if (currentUp) await currentUp();
await this.evolution['client'].execute(
`DROP TABLE IF EXISTS ${this.evolution['keyspace']}.${tableName}`
);
};
return this;
}
// Create index
createIndex(indexName: string, tableName: string, column: string): this {
const currentUp = this.migration.up;
this.migration.up = async () => {
if (currentUp) await currentUp();
await this.evolution['client'].execute(
`CREATE INDEX IF NOT EXISTS ${indexName} ON ${this.evolution['keyspace']}.${tableName} (${column})`
);
};
const currentDown = this.migration.down;
this.migration.down = async () => {
await this.evolution['client'].execute(`DROP INDEX IF EXISTS ${indexName}`);
if (currentDown) await currentDown();
};
return this;
}
// Execute custom SQL
execute(sql: string): this {
const currentUp = this.migration.up;
this.migration.up = async () => {
if (currentUp) await currentUp();
await this.evolution['client'].execute(sql);
};
return this;
}
// Add custom up function
up(fn: () => Promise<void>): this {
this.migration.up = fn;
return this;
}
// Add custom down function
down(fn: () => Promise<void>): this {
this.migration.down = fn;
return this;
}
// Add validation function
validate(fn: () => Promise<boolean>): this {
this.migration.validate = fn;
return this;
}
// Finalize and add to evolution
build(): SchemaEvolution {
if (!this.migration.up) {
throw new Error('Migration must have an up function');
}
this.evolution.addMigration(this.migration as MigrationStep);
return this.evolution;
}
}
// Built-in migration helpers
export class MigrationHelpers {
constructor(private client: Client, private keyspace: string) {}
async tableExists(tableName: string): Promise<boolean> {
try {
await this.client.execute(
`SELECT table_name FROM system_schema.tables WHERE keyspace_name = ? AND table_name = ?`,
[this.keyspace, tableName]
);
return true;
} catch {
return false;
}
}
async columnExists(tableName: string, columnName: string): Promise<boolean> {
try {
const result = await this.client.execute(
`SELECT column_name FROM system_schema.columns WHERE keyspace_name = ? AND table_name = ? AND column_name = ?`,
[this.keyspace, tableName, columnName]
);
return result.rows.length > 0;
} catch {
return false;
}
}
async indexExists(indexName: string): Promise<boolean> {
try {
const result = await this.client.execute(
`SELECT index_name FROM system_schema.indexes WHERE keyspace_name = ? AND index_name = ?`,
[this.keyspace, indexName]
);
return result.rows.length > 0;
} catch {
return false;
}
}
async backfillColumn(
tableName: string,
columnName: string,
defaultValue: any,
batchSize: number = 1000
): Promise<void> {
let pageState: string | undefined;
do {
const result = await this.client.execute(
`SELECT * FROM ${this.keyspace}.${tableName}`,
[],
{ prepare: true, fetchSize: batchSize, pageState }
);
if (result.rows.length > 0) {
const batch = result.rows.map(row => ({
query: `UPDATE ${this.keyspace}.${tableName} SET ${columnName} = ? WHERE id = ?`,
params: [defaultValue, row.id]
}));
await this.client.batch(batch, { prepare: true });
}
pageState = result.pageState;
} while (pageState);
}
}