diff --git a/SCHEMA_DRIFT_AUDIT.md b/SCHEMA_DRIFT_AUDIT.md index 485b1b6..0ca252e 100644 --- a/SCHEMA_DRIFT_AUDIT.md +++ b/SCHEMA_DRIFT_AUDIT.md @@ -1,186 +1,49 @@ -# Schema Drift Audit & Fixes +# Schema Drift Audit (Ownership Boundary) -This document outlines the schema drift issues identified in the Callora-Backend project and provides fixes to ensure data integrity and consistency. +This document records **which database tables are owned by which schema system** in this repo, and how we prevent drift between them. The goal is to ensure **no table is silently defined in two ORMs with conflicting types**, and to catch regressions in CI via `src/__tests__/schema-drift.test.ts`. -## Issues Identified +## Ownership boundary (source of truth) -### 🚨 Critical Issues +### Drizzle + SQLite (schema: `src/db/schema.ts`, migrations: `migrations/*.sql`) -1. **Multiple ORM Systems**: The project uses both Drizzle (SQLite) and Prisma (PostgreSQL) with completely different schemas -2. **Database Provider Mismatch**: Drizzle configured for SQLite, Prisma for PostgreSQL -3. **Entity Definition Conflicts**: - - Drizzle has: `developers`, `apis`, `apiEndpoints` - - Prisma has: only `User` with `stellar_address` -4. **Unused Configuration**: Prisma client initialized but not actively used in main application flow +These tables are **SQLite-owned** and must be represented in **both**: +- Drizzle schema (`src/db/schema.ts`) and +- Raw SQLite migrations (`migrations/*.sql`) -### ⚠️ Configuration Issues +Owned tables: +- `developers` +- `apis` +- `api_endpoints` -1. **Multiple Database Connection Patterns**: SQLite (Drizzle), PostgreSQL (pg pool), and Prisma connections -2. **Migration Gaps**: Schema entities exist without corresponding migrations -3. **Type Safety Gaps**: Missing type exports for some entities +### Prisma + PostgreSQL (schema: `prisma/schema.prisma`) -## Fixes Applied +These tables are **PostgreSQL-owned** by Prisma and are represented in: +- Prisma schema (`prisma/schema.prisma`) with an explicit `@@map("...")` -### 1. Schema Drift Detection Tests +Owned tables: +- `users` (Prisma model `User @@map("users")`) -Created comprehensive tests in `src/__tests__/schema-drift.test.ts` that: -- Detect ORM configuration conflicts -- Validate entity consistency across schemas -- Check for unused imports and connection patterns -- Verify migration and type safety consistency +### Raw PostgreSQL (not owned by Drizzle/Prisma) -### 2. Validation Script +Some services use `pg` directly (see `src/db.ts`) and have their own raw SQL / operational ownership. These tables are **not checked by the SQLite drift test**. -Added `scripts/schema-drift-validator.mjs` to: -- Automatically detect schema drift issues -- Provide detailed reports with recommendations -- Exit with error codes for CI/CD integration +## Drift prevention rules (enforced by tests) -### 3. Consolidation Script +The Jest drift test (`src/__tests__/schema-drift.test.ts`) enforces: +- **Exact Drizzle table set**: Drizzle may only define `developers`, `apis`, `api_endpoints` +- **Exact Prisma table set (via `@@map`)**: Prisma may only define `users` +- **No overlap**: a table name may not appear as owned by both ORMs +- **SQLite migrations consistency**: SQL migrations must not create tables outside the SQLite-owned set, and every created table must exist in Drizzle +- **Cross-domain compatibility check**: `developers.user_id` remains a UUID-shaped string compatible with Prisma `User.id` (UUID string) -Added `scripts/consolidate-schema.mjs` to: -- Safely remove unused Prisma configuration -- Consolidate to Drizzle + SQLite (primary ORM) -- Update package.json and clean up imports -- Create backups before making changes +## Notes about generated Prisma artifacts -## Recommended Actions +`/src/generated/prisma` is intentionally ignored in `.gitignore`. The drift test validates Prisma ownership and key field types from `prisma/schema.prisma` directly, so CI doesn’t depend on generated files being present in the repo. -### Immediate (Required) +## How to verify -1. **Run the validation script**: - ```bash - node scripts/schema-drift-validator.mjs - ``` +Run: -2. **Consolidate schema configuration**: - ```bash - node scripts/consolidate-schema.mjs - ``` - -3. **Update dependencies**: - ```bash - npm install - ``` - -4. **Run tests**: - ```bash - npm test - ``` - -### Manual Review Required - -1. **Database Connection Strings**: Ensure `DATABASE_URL` points to SQLite database -2. **Test Coverage**: Verify all database operations work with consolidated schema -3. **Documentation**: Update any references to Prisma in documentation -4. **CI/CD**: Update deployment scripts to use Drizzle migrations - -## Schema Consolidation Details - -### Before (Problematic) -``` -β”œβ”€β”€ drizzle.config.ts # SQLite configuration -β”œβ”€β”€ prisma.config.ts # PostgreSQL configuration -β”œβ”€β”€ src/db/schema.ts # Drizzle entities -β”œβ”€β”€ prisma/schema.prisma # Prisma entities (different) -β”œβ”€β”€ src/db/index.ts # Drizzle connection -β”œβ”€β”€ src/db.ts # PostgreSQL pool -└── src/lib/prisma.ts # Prisma client -``` - -### After (Consolidated) -``` -β”œβ”€β”€ drizzle.config.ts # SQLite configuration -β”œβ”€β”€ src/db/schema.ts # Unified schema definitions -β”œβ”€β”€ src/db/index.ts # Single database connection -└── scripts/schema-drift-validator.mjs # Ongoing validation -``` - -## Security & Data Integrity Notes - -### Critical Security Considerations - -1. **Database Access**: Ensure SQLite file has proper permissions -2. **Migration Safety**: Always backup database before running migrations -3. **Connection Pooling**: SQLite doesn't need pooling, remove any pool configurations - -### Data Integrity Safeguards - -1. **Type Safety**: All entities now have proper TypeScript exports -2. **Migration Validation**: Scripts validate schema before applying changes -3. **Backup Protection**: All changes create automatic backups - -## Testing Strategy - -### Unit Tests -- Schema drift detection tests run on every commit -- Type safety validation for all entities -- Import consistency checks - -### Integration Tests -- Database connection validation -- Migration testing with rollback capabilities -- Cross-ORM compatibility tests (if needed) - -### CI/CD Integration -Add to your CI pipeline: -```yaml -- name: Validate Schema Drift - run: node scripts/schema-drift-validator.mjs -``` - -## Migration Commands - -### Generate New Migrations -```bash -npm run db:generate -``` - -### Apply Migrations ```bash -npm run db:migrate +npm test -- src/__tests__/schema-drift.test.ts ``` - -### Open Database Studio -```bash -npm run db:studio -``` - -## Troubleshooting - -### Common Issues - -1. **Import Errors**: Run consolidation script to clean up imports -2. **Type Errors**: Check schema drift test output for missing types -3. **Connection Issues**: Verify DATABASE_URL environment variable - -### Recovery - -If issues occur after consolidation: -1. Restore from `.schema-backup/` directory -2. Re-run validation script -3. Test database operations manually - -## Future Considerations - -1. **ORM Choice**: Drizzle + SQLite is recommended for simplicity -2. **Database Scaling**: Consider PostgreSQL if scaling requirements change -3. **Schema Evolution**: Use validation script to prevent future drift - -## Files Modified - -- βœ… `src/__tests__/schema-drift.test.ts` - New comprehensive tests -- βœ… `scripts/schema-drift-validator.mjs` - Validation script -- βœ… `scripts/consolidate-schema.mjs` - Consolidation script -- βœ… `package.json` - Updated dependencies and scripts -- πŸ”„ `src/db/index.ts` - Cleaned up imports (if consolidation run) -- πŸ”„ `src/index.ts` - Removed Prisma references (if consolidation run) - -## Validation Results - -After running the validation script, you should see: -``` -βœ… No schema drift issues detected! -``` - -If issues are found, the script will provide detailed recommendations for fixing them. diff --git a/src/__tests__/schema-drift.test.ts b/src/__tests__/schema-drift.test.ts index 070b202..aa85c43 100644 --- a/src/__tests__/schema-drift.test.ts +++ b/src/__tests__/schema-drift.test.ts @@ -15,6 +15,23 @@ describe('Schema Drift Audit', () => { const prismaSchemaPath = path.join(projectRoot, 'prisma/schema.prisma'); const drizzleConfigPath = path.join(projectRoot, 'drizzle.config.ts'); const prismaConfigPath = path.join(projectRoot, 'prisma.config.ts'); + const sqliteMigrationsDir = path.join(projectRoot, 'migrations'); + + /** + * Ownership boundary (single source of truth per table). + * + * - Drizzle + raw SQLite migrations own the developer dashboard entities. + * - Prisma owns the PostgreSQL auth/users domain. + * - Other Postgres tables (usage, settlements, etc.) are owned by raw SQL in code + * and are out-of-scope for the SQLite drift checks here. + * + * Any future change MUST update SCHEMA_DRIFT_AUDIT.md and these expectations. + */ + const OWNERSHIP = { + drizzleSqliteTables: new Set(['developers', 'apis', 'api_endpoints']), + prismaTables: new Set(['users']), + sqliteMigrationsOwnedTables: new Set(['developers', 'apis', 'api_endpoints']), + } as const; describe('ORM Configuration Consistency', () => { // KNOWN: This project intentionally uses Drizzle+SQLite for dev/test and @@ -41,41 +58,46 @@ describe('Schema Drift Audit', () => { }); describe('Entity Definition Consistency', () => { - // KNOWN: Drizzle defines SQLite entities; Prisma defines PostgreSQL models. - // They intentionally use different naming conventions and don't share entity names. - // This test is skipped because zero common entities is expected and correct. - it.skip('should have matching entity definitions across ORMs', () => { + it('should define only the expected Drizzle SQLite tables', () => { const drizzleSchema = fs.readFileSync(drizzleSchemaPath, 'utf8'); - const drizzleEntities = extractDrizzleEntities(drizzleSchema); - + const drizzleTableNames = extractDrizzleTableNames(drizzleSchema); + + expect(new Set(drizzleTableNames)).toEqual(OWNERSHIP.drizzleSqliteTables); + }); + + it('should define only the expected Prisma tables (via @@map)', () => { const prismaSchema = fs.readFileSync(prismaSchemaPath, 'utf8'); - const prismaEntities = extractPrismaEntities(prismaSchema); + const prismaMappedTables = extractPrismaMappedTableNames(prismaSchema); - const commonEntities = findCommonEntities(drizzleEntities, prismaEntities); - - if (drizzleEntities.length > 0 && prismaEntities.length > 0) { - expect(commonEntities.length).toBeGreaterThan(0); - } + expect(new Set(prismaMappedTables)).toEqual(OWNERSHIP.prismaTables); }); - it('should not have orphaned schema definitions', () => { - // Check for schema definitions without corresponding usage + it('should not define the same table in both ORMs', () => { const drizzleSchema = fs.readFileSync(drizzleSchemaPath, 'utf8'); const prismaSchema = fs.readFileSync(prismaSchemaPath, 'utf8'); + const drizzleTables = new Set(extractDrizzleTableNames(drizzleSchema)); + const prismaTables = new Set(extractPrismaMappedTableNames(prismaSchema)); - // Extract table/model names - const drizzleTables = extractDrizzleEntities(drizzleSchema); - const prismaModels = extractPrismaEntities(prismaSchema); + const overlap = [...drizzleTables].filter((t) => prismaTables.has(t)); + expect(overlap).toEqual([]); + }); - // Both schemas should be used or one should be removed - const hasUsage = checkSchemaUsage(drizzleTables, prismaModels, projectRoot); - - expect(hasUsage).toBe(true); + it('should keep the developer user_id shape compatible with Prisma User.id (UUID string)', () => { + const drizzleSchema = fs.readFileSync(drizzleSchemaPath, 'utf8'); + const prismaSchema = fs.readFileSync(prismaSchemaPath, 'utf8'); + + const prismaUserId = extractPrismaModelField(prismaSchema, 'User', 'id'); + // Prisma: id String @id ... @db.Uuid + expect(prismaUserId).toContain('String'); + expect(prismaUserId).toContain('@db.Uuid'); + + // Drizzle: developers.user_id is stored as text (UUID string). + expect(drizzleSchema).toMatch(/developers\s*=\s*sqliteTable\(\s*'developers'[\s\S]*user_id:\s*text\('user_id'\)\.notNull\(\)\.unique\(\)/); }); }); describe('Runtime Usage Consistency', () => { - it('should not import unused ORM clients', () => { + it('should align runtime ORM usage with the documented ownership boundary', () => { const srcDir = path.join(projectRoot, 'src'); // Check for Prisma imports @@ -84,14 +106,14 @@ describe('Schema Drift Audit', () => { // Check for Drizzle imports const drizzleImports = findFileImports(srcDir, ['drizzle-orm']); - // If both are imported, both should be used - if (prismaImports.length > 0 && drizzleImports.length > 0) { - // This indicates potential drift - both ORMs being used - console.warn('WARNING: Both Prisma and Drizzle are imported. Consider consolidating to one ORM.'); - } + // This repo intentionally uses both systems (SQLite+Drizzle for dashboard entities, + // Prisma+Postgres for users). The drift protection is enforced by the ownership + // tests above, not by prohibiting either import. + expect(drizzleImports.length).toBeGreaterThan(0); + expect(prismaImports.length).toBeGreaterThan(0); }); - it('should have consistent database connection patterns', () => { + it('should have explicit database connection patterns (no accidental extra clients)', () => { const dbIndexPath = path.join(projectRoot, 'src/db/index.ts'); const dbTsPath = path.join(projectRoot, 'src/db.ts'); const prismaLibPath = path.join(projectRoot, 'src/lib/prisma.ts'); @@ -115,30 +137,43 @@ describe('Schema Drift Audit', () => { if (content.includes('PrismaClient')) connections.push('prisma'); } - // Multiple connection patterns indicate drift - if (connections.length > 1) { - console.warn(`WARNING: Multiple database connection patterns detected: ${connections.join(', ')}`); - } + // By design, we expect these connection patterns to exist in this repo. + // This assertion prevents new/accidental clients from being introduced silently. + expect(new Set(connections)).toEqual(new Set(['drizzle', 'sqlite', 'postgresql', 'prisma'])); }); }); describe('Migration Consistency', () => { - it('should have matching migrations with schema definitions', () => { - const migrationsDir = path.join(projectRoot, 'migrations'); - - if (fs.existsSync(migrationsDir)) { - const migrationFiles = fs.readdirSync(migrationsDir) - .filter(file => file.endsWith('.sql')); - - // Check if migrations reference the correct tables - const drizzleSchema = fs.readFileSync(drizzleSchemaPath, 'utf8'); - const drizzleTables = extractDrizzleEntities(drizzleSchema); - - // At minimum, migrations should exist for the main entities - if (drizzleTables.length > 0 && migrationFiles.length === 0) { - console.warn('WARNING: Schema tables exist but no migrations found'); + it('should have SQLite migrations that only create expected owned tables', () => { + if (!fs.existsSync(sqliteMigrationsDir)) { + // Repository can still be valid without migrations in some test contexts. + return; + } + + const drizzleSchema = fs.readFileSync(drizzleSchemaPath, 'utf8'); + const drizzleTables = new Set(extractDrizzleTableNames(drizzleSchema)); + + const migrationFiles = fs + .readdirSync(sqliteMigrationsDir) + .filter((file) => file.endsWith('.sql')); + + // Defensive: if we have schema tables, we expect some migrations present. + expect(migrationFiles.length).toBeGreaterThan(0); + + const createdTables = new Set(); + for (const file of migrationFiles) { + const sql = fs.readFileSync(path.join(sqliteMigrationsDir, file), 'utf8'); + for (const table of extractSqliteCreatedTableNames(sql)) { + createdTables.add(table); } } + + // All "owned" SQLite migrations must create only tables that are explicitly + // owned by Drizzle+SQLite and represented in the Drizzle schema. + for (const table of createdTables) { + expect(OWNERSHIP.sqliteMigrationsOwnedTables.has(table)).toBe(true); + expect(drizzleTables.has(table)).toBe(true); + } }); }); @@ -150,8 +185,8 @@ describe('Schema Drift Audit', () => { const typeExports = drizzleSchema.match(/export type \w+/g) || []; // Types should be exported for all main entities - const entities = extractDrizzleEntities(drizzleSchema); - const expectedTypeExports = entities.map(entity => `export type ${entity}`); + const entities = extractDrizzleEntityConstNames(drizzleSchema); + const expectedTypeExports = entities.map((entity) => `export type ${entity}`); // Ensure type consistency expect(typeExports.length).toBeGreaterThanOrEqual(entities.length); @@ -161,7 +196,7 @@ describe('Schema Drift Audit', () => { // Helper functions for schema analysis -function extractDrizzleEntities(schema: string): string[] { +function extractDrizzleEntityConstNames(schema: string): string[] { const entities: string[] = []; const tableMatches = schema.match(/export const \w+ = sqliteTable/g) || []; @@ -175,54 +210,51 @@ function extractDrizzleEntities(schema: string): string[] { return entities; } -function extractPrismaEntities(schema: string): string[] { - const entities: string[] = []; - const modelMatches = schema.match(/model \w+ \{/g) || []; - - for (const match of modelMatches) { - const entityName = match.match(/model (\w+) \{/)?.[1]; - if (entityName) { - entities.push(entityName); - } +function extractDrizzleTableNames(schema: string): string[] { + const tables: string[] = []; + const matches = schema.match(/sqliteTable\(\s*'[^']+'\s*,/g) || []; + for (const match of matches) { + const tableName = match.match(/sqliteTable\(\s*'([^']+)'\s*,/)?.[1]; + if (tableName) tables.push(tableName); } - - return entities; + return tables; } -function findCommonEntities(drizzleEntities: string[], prismaEntities: string[]): string[] { - return drizzleEntities.filter(entity => - prismaEntities.some(pEntity => - entity.toLowerCase() === pEntity.toLowerCase() - ) - ); +function extractPrismaMappedTableNames(schema: string): string[] { + // We intentionally only treat models with an explicit @@map("table") as "owned", + // so renames and mapping decisions are visible and testable. + const mapped: string[] = []; + const modelBlocks = schema.match(/model\s+\w+\s+\{[\s\S]*?\n\}/g) || []; + for (const block of modelBlocks) { + const map = block.match(/@@map\("([^"]+)"\)/)?.[1]; + if (map) mapped.push(map); + } + return mapped; } -function checkSchemaUsage(drizzleTables: string[], prismaModels: string[], projectRoot: string): boolean { - const srcDir = path.join(projectRoot, 'src'); - - // Check if schemas are actually used in the codebase - let drizzleUsed = false; - let prismaUsed = false; - - // Simple usage check - look for imports and references - const files = getAllTsFiles(srcDir); - - for (const file of files) { - const content = fs.readFileSync(file, 'utf8'); - - // Check Drizzle usage - if (content.includes('from \'./db/schema.js\'') || content.includes('from \'./db/index.js\'')) { - drizzleUsed = true; - } - - // Check Prisma usage - if (content.includes('PrismaClient') || content.includes('from \'../lib/prisma.js\'')) { - prismaUsed = true; - } +function extractPrismaModelField(schema: string, modelName: string, fieldName: string): string { + const block = schema.match(new RegExp(`model\\s+${modelName}\\s+\\{[\\s\\S]*?\\n\\}`, 'm'))?.[0]; + if (!block) { + throw new Error(`Prisma schema missing model ${modelName}`); } - - // At least one schema should be used - return drizzleUsed || prismaUsed; + const line = block + .split('\n') + .map((l) => l.trim()) + .find((l) => l.startsWith(`${fieldName} `)); + if (!line) { + throw new Error(`Prisma schema missing field ${modelName}.${fieldName}`); + } + return line; +} + +function extractSqliteCreatedTableNames(sql: string): string[] { + const tables: string[] = []; + const matches = sql.match(/CREATE TABLE(?: IF NOT EXISTS)?\s+`[^`]+`/gi) || []; + for (const match of matches) { + const name = match.match(/CREATE TABLE(?: IF NOT EXISTS)?\s+`([^`]+)`/i)?.[1]; + if (name) tables.push(name); + } + return tables; } function findFileImports(dir: string, imports: string[]): string[] {