Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
/**
|
|
* Tenant Management Schema Validator
|
|
*
|
|
* Validates that TENANT_TABLES_DELETION_ORDER in tenant-deletion-activities.ts
|
|
* includes ALL tenant-scoped tables from the database.
|
|
*
|
|
* This ensures the tenant lifecycle management workflow stays in sync with
|
|
* database schema changes as new migrations are added.
|
|
*
|
|
* This script:
|
|
* 1. Reads TENANT_TABLES_DELETION_ORDER from the actual source file (not hardcoded)
|
|
* 2. Queries the database for all tables with 'tenant' or 'tenant_id' columns
|
|
* 3. Compares and fails if any tables are missing
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/validate-tenant-management.ts
|
|
*
|
|
* Environment variables:
|
|
* DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import knex, { Knex } from 'knex';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// Tables that are intentionally excluded from the deletion order
|
|
const EXCLUDED_TABLES: string[] = [
|
|
'tenants', // The tenant table itself - deleted last separately
|
|
'knex_migrations', // Knex internal
|
|
'knex_migrations_lock', // Knex internal
|
|
'pending_tenant_deletions', // Managed separately in deletion workflow
|
|
'spatial_ref_sys', // PostGIS system table
|
|
];
|
|
|
|
// Foreign-key constraints that are exempt from the ordering check because
|
|
// breakCircularDependencies() in tenant-deletion-activities.ts NULLs out the
|
|
// referencing column before deletion begins. Add entries by constraint name.
|
|
const EXEMPT_FK_CONSTRAINTS: Set<string> = new Set([
|
|
'statuses_tenant_board_id_fk', // NULL'd via breakCircularDependencies (statuses.board_id)
|
|
'authorization_bundles_tenant_published_revision_id_foreign', // NULL'd via breakCircularDependencies (authorization_bundles.published_revision_id)
|
|
'inbound_ticket_defaults_tenant_client_id_foreign', // NULL'd via breakCircularDependencies (inbound_ticket_defaults.client_id)
|
|
]);
|
|
|
|
interface FkOrderingViolation {
|
|
childTable: string;
|
|
parentTable: string;
|
|
constraintName: string;
|
|
deleteRule: string;
|
|
childIdx: number;
|
|
parentIdx: number;
|
|
}
|
|
|
|
interface ValidationResult {
|
|
success: boolean;
|
|
missingTables: string[];
|
|
duplicatesInOrder: string[];
|
|
tablesInDatabase: string[];
|
|
tablesInDeletionOrder: string[];
|
|
fkOrderingViolations: FkOrderingViolation[];
|
|
}
|
|
|
|
/**
|
|
* Parse the TENANT_TABLES_DELETION_ORDER array from the source file
|
|
*/
|
|
function parseDeletionOrderFromSource(): string[] {
|
|
const projectRoot = path.resolve(__dirname, '..');
|
|
const sourceFile = path.join(
|
|
projectRoot,
|
|
'ee',
|
|
'temporal-workflows',
|
|
'src',
|
|
'activities',
|
|
'tenant-deletion-activities.ts'
|
|
);
|
|
|
|
if (!fs.existsSync(sourceFile)) {
|
|
throw new Error(`Source file not found: ${sourceFile}`);
|
|
}
|
|
|
|
const content = fs.readFileSync(sourceFile, 'utf-8');
|
|
|
|
// Find the TENANT_TABLES_DELETION_ORDER array - match from declaration to closing ];
|
|
const arrayMatch = content.match(
|
|
/const\s+TENANT_TABLES_DELETION_ORDER\s*:\s*string\[\]\s*=\s*\[([\s\S]*?)\n\];/
|
|
);
|
|
|
|
if (!arrayMatch) {
|
|
throw new Error('Could not find TENANT_TABLES_DELETION_ORDER array in source file');
|
|
}
|
|
|
|
const arrayContent = arrayMatch[1];
|
|
|
|
// Extract all single-quoted string values from the array
|
|
const tableNames: string[] = [];
|
|
const stringMatches = arrayContent.matchAll(/'([a-z_]+)'/g);
|
|
|
|
for (const match of stringMatches) {
|
|
tableNames.push(match[1]);
|
|
}
|
|
|
|
if (tableNames.length === 0) {
|
|
throw new Error('No table names found in TENANT_TABLES_DELETION_ORDER');
|
|
}
|
|
|
|
return tableNames;
|
|
}
|
|
|
|
/**
|
|
* Query all foreign keys in the public schema.
|
|
* Returns { childTable, parentTable, constraintName, deleteRule }.
|
|
*
|
|
* The child is the table declaring the FK; the parent is the table it references.
|
|
* For deletion ordering, the child must be deleted BEFORE the parent (unless the
|
|
* FK cascades or sets null on delete).
|
|
*/
|
|
interface ForeignKey {
|
|
childTable: string;
|
|
parentTable: string;
|
|
constraintName: string;
|
|
deleteRule: string;
|
|
}
|
|
|
|
async function getForeignKeys(db: Knex): Promise<ForeignKey[]> {
|
|
const result = await db.raw(`
|
|
SELECT DISTINCT
|
|
tc.table_name AS child_table,
|
|
ccu.table_name AS parent_table,
|
|
tc.constraint_name,
|
|
rc.delete_rule
|
|
FROM information_schema.table_constraints tc
|
|
JOIN information_schema.referential_constraints rc
|
|
ON rc.constraint_name = tc.constraint_name
|
|
AND rc.constraint_schema = tc.table_schema
|
|
JOIN information_schema.constraint_column_usage ccu
|
|
ON ccu.constraint_name = rc.unique_constraint_name
|
|
AND ccu.constraint_schema = rc.unique_constraint_schema
|
|
WHERE tc.table_schema = 'public'
|
|
AND tc.constraint_type = 'FOREIGN KEY'
|
|
AND tc.table_name NOT LIKE 'pg_%'
|
|
AND tc.table_name NOT LIKE 'citus_%'
|
|
AND ccu.table_name NOT LIKE 'pg_%'
|
|
AND ccu.table_name NOT LIKE 'citus_%'
|
|
`);
|
|
|
|
return result.rows.map((row: any) => ({
|
|
childTable: row.child_table,
|
|
parentTable: row.parent_table,
|
|
constraintName: row.constraint_name,
|
|
deleteRule: row.delete_rule,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Query the database for all tables with tenant or tenant_id columns
|
|
*/
|
|
async function getTablesWithTenantColumn(db: Knex): Promise<string[]> {
|
|
const result = await db.raw(`
|
|
SELECT DISTINCT c.table_name
|
|
FROM information_schema.columns c
|
|
JOIN information_schema.tables t
|
|
ON c.table_name = t.table_name
|
|
AND c.table_schema = t.table_schema
|
|
WHERE c.table_schema = 'public'
|
|
AND c.column_name IN ('tenant', 'tenant_id')
|
|
AND c.table_name NOT LIKE 'pg_%'
|
|
AND c.table_name NOT LIKE 'citus_%'
|
|
AND t.table_type = 'BASE TABLE'
|
|
ORDER BY c.table_name
|
|
`);
|
|
|
|
return result.rows.map((row: { table_name: string }) => row.table_name);
|
|
}
|
|
|
|
/**
|
|
* Create database connection
|
|
*/
|
|
function createDbConnection(): Knex {
|
|
return knex({
|
|
client: 'pg',
|
|
connection: {
|
|
host: process.env.DB_HOST || 'localhost',
|
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
|
user: process.env.DB_USER || 'postgres',
|
|
password: process.env.DB_PASSWORD || '',
|
|
database: process.env.DB_NAME || 'alga',
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate the deletion order against the database schema
|
|
*/
|
|
async function validateDeletionOrder(): Promise<ValidationResult> {
|
|
console.log('Reading TENANT_TABLES_DELETION_ORDER from source file...');
|
|
const tablesInDeletionOrder = parseDeletionOrderFromSource();
|
|
console.log(` Found ${tablesInDeletionOrder.length} tables in deletion order`);
|
|
|
|
// Check for duplicates
|
|
const seen = new Set<string>();
|
|
const duplicatesInOrder: string[] = [];
|
|
for (const table of tablesInDeletionOrder) {
|
|
if (seen.has(table)) {
|
|
duplicatesInOrder.push(table);
|
|
}
|
|
seen.add(table);
|
|
}
|
|
|
|
if (duplicatesInOrder.length > 0) {
|
|
console.log(` Warning: Found ${duplicatesInOrder.length} duplicate entries`);
|
|
}
|
|
|
|
console.log('\nConnecting to database...');
|
|
const db = createDbConnection();
|
|
|
|
try {
|
|
const tablesInDatabase = await getTablesWithTenantColumn(db);
|
|
console.log(` Found ${tablesInDatabase.length} tables with tenant column in database`);
|
|
|
|
// Filter out excluded tables
|
|
const relevantDbTables = tablesInDatabase.filter(
|
|
(t) => !EXCLUDED_TABLES.includes(t)
|
|
);
|
|
console.log(` After excluding system tables: ${relevantDbTables.length} tables`);
|
|
|
|
// Find missing tables (in database but not in deletion order)
|
|
const deletionOrderSet = new Set(tablesInDeletionOrder);
|
|
const missingTables = relevantDbTables.filter((t) => !deletionOrderSet.has(t));
|
|
|
|
// === FK ordering check ===
|
|
// For every FK between two tables that both appear in the deletion order,
|
|
// the child (referencing) table must come BEFORE the parent (referenced)
|
|
// table — otherwise the parent DELETE will hit the FK and fail.
|
|
// FKs with CASCADE / SET NULL / SET DEFAULT can tolerate either order, so
|
|
// we only enforce ordering for restrictive rules (NO ACTION, RESTRICT).
|
|
console.log('\nChecking FK ordering...');
|
|
const foreignKeys = await getForeignKeys(db);
|
|
console.log(` Found ${foreignKeys.length} foreign keys to inspect`);
|
|
|
|
const orderIdx = new Map<string, number>();
|
|
tablesInDeletionOrder.forEach((t, i) => {
|
|
// First occurrence wins — duplicates are already reported separately
|
|
if (!orderIdx.has(t)) orderIdx.set(t, i);
|
|
});
|
|
|
|
const fkOrderingViolations: FkOrderingViolation[] = [];
|
|
for (const fk of foreignKeys) {
|
|
if (fk.childTable === fk.parentTable) continue; // self-ref is fine
|
|
if (EXEMPT_FK_CONSTRAINTS.has(fk.constraintName)) continue; // handled via NULL-out
|
|
const childIdx = orderIdx.get(fk.childTable);
|
|
const parentIdx = orderIdx.get(fk.parentTable);
|
|
if (childIdx === undefined || parentIdx === undefined) continue; // outside deletion scope
|
|
if (fk.deleteRule !== 'NO ACTION' && fk.deleteRule !== 'RESTRICT') continue;
|
|
if (childIdx >= parentIdx) {
|
|
fkOrderingViolations.push({
|
|
childTable: fk.childTable,
|
|
parentTable: fk.parentTable,
|
|
constraintName: fk.constraintName,
|
|
deleteRule: fk.deleteRule,
|
|
childIdx,
|
|
parentIdx,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
success:
|
|
missingTables.length === 0 &&
|
|
duplicatesInOrder.length === 0 &&
|
|
fkOrderingViolations.length === 0,
|
|
missingTables,
|
|
duplicatesInOrder,
|
|
tablesInDatabase: relevantDbTables,
|
|
tablesInDeletionOrder,
|
|
fkOrderingViolations,
|
|
};
|
|
} finally {
|
|
await db.destroy();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main execution
|
|
*/
|
|
async function main() {
|
|
console.log('========================================================================');
|
|
console.log(' Tenant Management Schema Validator');
|
|
console.log('========================================================================');
|
|
console.log('');
|
|
|
|
try {
|
|
const result = await validateDeletionOrder();
|
|
|
|
console.log('\n========================================================================');
|
|
console.log(' Results');
|
|
console.log('========================================================================');
|
|
|
|
console.log(`\nTables in deletion order: ${result.tablesInDeletionOrder.length}`);
|
|
console.log(`Tables with tenant column in DB: ${result.tablesInDatabase.length}`);
|
|
|
|
if (result.duplicatesInOrder.length > 0) {
|
|
console.log('\n❌ DUPLICATE TABLES IN DELETION ORDER:');
|
|
result.duplicatesInOrder.forEach((t) => console.log(` - ${t}`));
|
|
}
|
|
|
|
if (result.missingTables.length > 0) {
|
|
console.log('\n❌ MISSING TABLES (exist in DB but not in deletion order):');
|
|
result.missingTables.forEach((t) => console.log(` - ${t}`));
|
|
console.log('\n These tables have a tenant column but are NOT in TENANT_TABLES_DELETION_ORDER.');
|
|
console.log(' Add them to: ee/temporal-workflows/src/activities/tenant-deletion-activities.ts');
|
|
console.log('\n Consider the correct position based on foreign key dependencies:');
|
|
console.log(' - Tables referenced by other tables should be deleted AFTER their dependents');
|
|
console.log(' - Leaf tables (no dependencies) should be deleted first');
|
|
console.log('\n ⚠️ IMPORTANT: After adding missing tables, a new Temporal deployment is required');
|
|
console.log(' to pick up the updated deletion order.');
|
|
}
|
|
|
|
if (result.fkOrderingViolations.length > 0) {
|
|
console.log('\n❌ FK ORDERING VIOLATIONS (parent referenced by child but deleted first):');
|
|
for (const v of result.fkOrderingViolations) {
|
|
console.log(
|
|
` - ${v.childTable} (idx ${v.childIdx}) must be deleted BEFORE ${v.parentTable} (idx ${v.parentIdx})`
|
|
);
|
|
console.log(` constraint: ${v.constraintName}, ON DELETE ${v.deleteRule}`);
|
|
}
|
|
console.log('\n These FKs have restrictive ON DELETE (NO ACTION/RESTRICT), so the DELETE on');
|
|
console.log(' the parent row will fail while child rows still reference it. Move the child');
|
|
console.log(' table to an earlier position than the parent in TENANT_TABLES_DELETION_ORDER.');
|
|
console.log('\n (CASCADE and SET NULL FKs are allowed in either order and are not flagged.)');
|
|
}
|
|
|
|
if (result.success) {
|
|
console.log('\n✅ All tenant-scoped tables are included in the deletion order!');
|
|
process.exit(0);
|
|
} else {
|
|
console.log('\n❌ Validation FAILED. Please fix the issues above.');
|
|
process.exit(1);
|
|
}
|
|
} catch (error) {
|
|
console.error('\n❌ Error:', error instanceof Error ? error.message : error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|