Skip to content

Add initialization methods to ILockingService interface #140

Description

@vlavrynovych

Problem

The ILockingService interface in v0.8.0 lacks lifecycle management methods for lock storage initialization. This forces adapters to handle setup implicitly during first lock acquisition, which:

  • Violates fail-fast principle - Setup errors surface during migration, not during handler initialization
  • Creates unclear lifecycle - When is lock storage created? First use? Constructor?
  • Leads to inconsistent implementations - Each adapter handles setup differently
  • Makes confusing error messages - Is it a permission issue or a lock conflict?
  • Reduces testability - Can't test initialization separately from locking logic

Evidence

The Firebase adapter already recognized this gap and implemented these methods as custom extensions beyond the interface:

// FirebaseLockingService.ts (lines 262-304)
async initLockStorage(): Promise<void> {
    // Validates path access
}

async ensureLockStorageAccessible(): Promise<boolean> {
    // Verifies connection and permissions
}

Both methods include this comment:

"This method extends beyond the ILockingService interface. See issue #67 for proposal to add this to MSR Core."

Proposed Solution

Add two optional methods to ILockingService<DB> for backwards compatibility:

interface ILockingService<DB extends IDB> {
    // Existing methods (v0.8.0)
    acquireLock(executorId: string, timeout: number): Promise<boolean>;
    verifyLockOwnership(executorId: string): Promise<boolean>;
    releaseLock(executorId: string): Promise<void>;
    forceReleaseLock(): Promise<void>;
    checkAndReleaseExpiredLock(): Promise<void>;
    getLockStatus(): Promise<ILockStatus | null>;
    
    // New optional methods (v0.8.1) - NON-BREAKING
    /**
     * Initialize lock storage (create tables, indexes, paths, etc.).
     * 
     * Called during handler initialization to set up lock storage.
     * Implementations should create necessary database structures and
     * verify required permissions.
     * 
     * @throws Error if storage cannot be created (permissions, network, etc.)
     * @example
     * ```typescript
     * // PostgreSQL
     * async initLockStorage() {
     *   await this.db.query(`
     *     CREATE TABLE IF NOT EXISTS migration_locks (
     *       id SERIAL PRIMARY KEY,
     *       executor_id VARCHAR(255) UNIQUE NOT NULL,
     *       locked_at TIMESTAMP NOT NULL,
     *       expires_at TIMESTAMP NOT NULL
     *     )
     *   `);
     * }
     * 
     * // Firebase
     * async initLockStorage() {
     *   const ref = this.db.database.ref(this.lockPath);
     *   await ref.once('value'); // Verify read access
     * }
     * 
     * // MongoDB
     * async initLockStorage() {
     *   await this.db.createCollection('migration_locks');
     *   await this.db.collection('migration_locks').createIndex(
     *     { executor_id: 1 }, 
     *     { unique: true }
     *   );
     * }
     * ```
     */
    initLockStorage?(): Promise<void>;
    
    /**
     * Verify lock storage is accessible.
     * 
     * Used for pre-flight checks before migrations to ensure database
     * connection is active and lock storage has proper read/write permissions.
     * 
     * @returns true if accessible, false otherwise
     * @note Does NOT throw - returns false for any access issues
     * @example
     * ```typescript
     * // Pre-flight check before migration
     * if (handler.lockingService?.ensureLockStorageAccessible) {
     *   const accessible = await handler.lockingService.ensureLockStorageAccessible();
     *   if (\!accessible) {
     *     throw new Error('Lock storage is not accessible');
     *   }
     * }
     * ```
     */
    ensureLockStorageAccessible?(): Promise<boolean>;
}

Benefits

  1. Fail-Fast - Setup errors caught during initialization, not migration
  2. Explicit Lifecycle - Clear when storage is created
  3. Standard Pattern - Consistent across all adapters
  4. Better Errors - Setup vs runtime failures are distinct
  5. Testability - Can test initialization separately
  6. Backwards Compatible - Optional methods, no breaking changes

Implementation Notes

When to Call

Option 1: Handler initialization (recommended)

class MyDatabaseHandler implements IDatabaseMigrationHandler<DB> {
    constructor(db: DB) {
        this.db = db;
        this.lockingService = new MyLockingService(db);
    }
    
    async initialize(): Promise<void> {
        await this.schemaVersion.initTable();
        
        if (this.lockingService?.initLockStorage) {
            await this.lockingService.initLockStorage();
        }
    }
}

const handler = new MyDatabaseHandler(db);
await handler.initialize();

Option 2: MigrationWorkflowOrchestrator (automatic)

// In executeBeforeMigrate() or similar
if (this.handler.lockingService?.initLockStorage) {
    await this.handler.lockingService.initLockStorage();
}

Error Handling

  • initLockStorage()Throws on failure (fail-fast setup)
  • ensureLockStorageAccessible()Returns boolean (pre-flight check)

Databases That Don't Need Setup

class InMemoryLockingService implements ILockingService<IDB> {
    async initLockStorage(): Promise<void> {
        // No-op - nothing to initialize
    }
    
    async ensureLockStorageAccessible(): Promise<boolean> {
        return true; // Always accessible
    }
}

Tasks

  • Add optional methods to ILockingService<DB> interface
  • Update interface documentation with examples
  • Update locking-settings.md documentation
  • Add example implementations to migration guide
  • Update Firebase adapter to use standard interface (remove workaround comment)
  • Add tests for new methods

Related

Future Considerations

In v1.0.0, consider making these methods required (breaking change) for cleaner API and enforced consistency across all adapters.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions