Developer: s4gor
Github: https://github.com/s4gor
This document explains the architectural decisions and design rationale for the schema-sync crate. It focuses on the extension layer first approach, explaining why each abstraction exists and how it enables future features.
Principle: All database operations go through traits, never direct implementations.
Rationale:
- Enables multi-database support without changing core logic
- Allows testing with mock implementations
- Supports different migration strategies per database
- Makes the codebase maintainable as it grows
Example: DatabaseAdapter trait allows PostgreSQL, MySQL, and SQLite implementations to coexist.
Principle: Clear boundaries between reading, planning, and executing.
Components:
- SchemaInspector: Read-only schema introspection
- MigrationRunner: Write operations for applying migrations
- Planner: Determines what changes need to be made
- Executor: Orchestrates the execution of planned changes
Rationale:
- Audit mode can use inspector without runner (no write permissions needed)
- Dry-run mode can plan without executing
- Different strategies can be plugged in for each concern
- Easier to test each component independently
Principle: Every operation is explicitly scoped to a tenant context.
Implementation: TenantContext is required for all operations.
Rationale:
- Prevents accidental cross-tenant operations
- Makes tenant scoping explicit in the type system
- Enables per-tenant locking strategies
- Supports batch operations across tenants
Principle: The engine doesn't know about CLI modes (dry-run, validation, etc.).
Rationale:
- Same engine can be used for sync, dry-run, validation, and audit
- Mode-specific behavior is handled at the CLI layer
- Keeps core logic simple and focused
- Enables programmatic usage without CLI
Example: Engine::sync_tenant() takes an execute parameter. The CLI layer sets this based on the mode.
Responsibility: User interface, mode handling, output formatting.
Not in core crate: The CLI will be implemented separately (either in this crate's bin/ or a separate crate).
Modes:
sync: Apply changes (execute=true)diff: Preview changes (execute=false)validate: Check if schemas match (execute=false, exit non-zero if mismatch)audit: Read-only inspection (uses inspector only)
Responsibility: Orchestrates all components to provide high-level API.
Components:
Engine: Main entry point- Coordinates inspector, planner, executor, diff calculator
- Handles snapshot management
- Provides unified interface
Why it exists: Hides complexity of coordinating multiple components. Provides a simple API for common operations.
Responsibility: Database-specific operations through traits.
Traits:
DatabaseAdapter: Connection management, factory methodsSchemaInspector: Read schema from databaseMigrationRunner: Execute schema changes
Why separate traits:
- Inspector can be used without runner (audit mode)
- Different migration strategies can be implemented
- Testing is easier with mock implementations
Responsibility: Concrete implementations for each database type.
Future implementations:
PostgresAdapter,PostgresInspector,PostgresMigrationRunnerMysqlAdapter,MysqlInspector,MysqlMigrationRunnerSqliteAdapter,SqliteInspector,SqliteMigrationRunner
Why separate: Each database has different SQL dialects, connection methods, and capabilities.
Purpose: Normalized, database-agnostic representation of a schema.
Key properties:
- Deterministic: Same schema always produces same snapshot
- Order-independent where possible (uses HashMaps)
- Database-agnostic: Not tied to PostgreSQL, MySQL, etc.
Why it exists:
- Enables diffing between different database types (theoretical)
- Supports snapshot storage and versioning
- Allows planning without database connection
Purpose: Represents differences between two snapshots.
Structure: Hierarchical (schema → table → column → constraint).
Why it exists:
- Enables human-readable diff output
- Supports machine-readable JSON output
- Allows planning to understand what needs to change
Purpose: Executable sequence of operations to transform schema.
Structure: Ordered steps with dependencies.
Why it exists:
- Separates "what needs to change" (diff) from "how to change it" (plan)
- Enables validation before execution
- Supports dry-run mode
- Allows different planning strategies
Purpose: Explicit tenant scoping for all operations.
Why it exists:
- Type safety: Can't accidentally operate on wrong tenant
- Makes tenant isolation explicit
- Supports batch operations
- Enables per-tenant locking
Steps:
- Implement
DatabaseAdaptertrait - Implement
SchemaInspectortrait (convert database schema toSchemaSnapshot) - Implement
MigrationRunnertrait (convertMigrationPlanto database SQL) - Register in factory or use directly
No changes needed to: Engine, planner, executor, diff calculator.
Steps:
- Implement
MigrationRunnertrait - Convert
MigrationPlanto your migration format - Execute migrations using your tool (diesel, sqlx, etc.)
Example: SQL file-based migrations would read SQL files and execute them in order.
Steps:
- Implement
SnapshotStoretrait - Store/retrieve
SchemaSnapshotin your backend (database, S3, etc.) - Use with engine
No changes needed to: Engine, adapters, planner, executor.
Steps:
- Implement
Plannertrait - Create
MigrationPlanfromSchemaDiffusing your strategy - Use with engine
Example: A "safe" planner might add extra steps to ensure zero-downtime migrations.
Steps:
- Implement
DiffCalculatortrait - Calculate
SchemaDifffrom twoSchemaSnapshots - Use with engine
Example: A three-way merge calculator for handling conflicts.
Implementation: Already supported via execute=false parameter.
Output formats:
- Text: Format
SchemaDiffas human-readable text - JSON: Serialize
SchemaDiffto JSON
CLI command: schema-sync diff --tenant tenant_123 --format json
Implementation:
- Use
Engine::sync_tenant()withexecute=falsefor all tenants - Check
already_in_syncflag - Exit non-zero if any tenant has
already_in_sync=false
CLI command: schema-sync validate --all-tenants
Implementation: Implement adapter traits for each database type.
Challenges:
- Different SQL dialects
- Different schema introspection queries
- Different migration execution methods
- Different locking mechanisms
Solution: Traits abstract these differences. Each implementation handles database-specific details.
Implementation: Use SchemaInspector directly, no MigrationRunner needed.
CLI command: schema-sync audit --tenant tenant_123
Safety: No write operations, no locks, safe for production.
Implementation: Different MigrationRunner implementations.
Examples:
SqlFileMigrationRunner: Executes SQL files in orderDieselMigrationRunner: Uses diesel migration systemSqlxMigrationRunner: Uses sqlx migration systemRustCodeMigrationRunner: Executes Rust code migrations
Implementation: SnapshotStore trait implementations.
Storage backends:
FileSnapshotStore: Store in filesystemDatabaseSnapshotStore: Store in databaseGitSnapshotStore: Store in version controlS3SnapshotStore: Store in object storage
Versioning: Deterministic hash of snapshot content.
Implementation:
- All operations require
TenantContext MigrationRunner::acquire_lock()ensures per-tenant locking- Database adapters enforce tenant scoping in SQL
Safety: Type system prevents cross-tenant operations.
Mock implementations: Create mock implementations of all traits for testing.
Example: MockSchemaInspector that returns predefined snapshots.
Test database: Use testcontainers or in-memory databases.
Test scenarios:
- Sync empty schema to target
- Sync existing schema to target
- Handle conflicts
- Rollback on failure
Deterministic snapshots: Same schema always produces same snapshot.
Reversibility: Plan + execution should result in target schema.
Optimization: Cache snapshots, only recalculate when schema changes.
Implementation: Store snapshot hash, compare before full calculation.
Multiple tenants: Process tenants in parallel where possible.
Implementation: Use Engine::list_tenants() and process concurrently.
Per-tenant locks: Don't block other tenants.
Implementation: MigrationRunner::acquire_lock() is per-tenant.
Enforcement: Database adapters must enforce tenant scoping in SQL.
Example: PostgreSQL adapter uses schema-qualified table names.
Credentials: Adapters handle connection security (TLS, authentication).
Not in core: Core crate doesn't handle credentials, adapters do.
Future: Log all schema changes with tenant, timestamp, and user.
Implementation: MigrationRunner implementations can add logging.
Structured errors: Error enum with context.
Why: Provides actionable error messages with tenant context.
Rollback: MigrationRunner implementations handle rollback on failure.
Partial failures: ExecutionResult indicates how many steps succeeded.
This architecture provides a solid foundation for schema synchronization that can grow over years. The trait-based design enables extensibility without breaking changes, and the separation of concerns makes the codebase maintainable.
The key insight is designing for extension first: every abstraction exists to enable a future feature, not just to solve the current problem.