feat(cdc): batch table removal — reconfigure connectors once

Removing N tables via Edit Objects previously looped a single-table
DELETE per table (maestro deleteTable hardcodes job_ids:[one]), so the
Debezium source + Snowflake sink connectors were rewritten/restarted once
per table. platform-api's DELETE /pipeline/:id/jobs already batches (2
connector writes total for any N), but nothing above it used the array.

New maestro DELETE /pipelines/:pipelineId/inputs/:inputId/tables takes
{ table_names: [] }: soft-deletes each in DynamoDB (tracking successes),
resolves all table_names -> job_ids from the platform pipeline in one GET,
then makes ONE DELETE /pipeline/:id/jobs with all job_ids. All-or-nothing:
any failure (a later mark, an unmatched table, or the platform delete)
rolls back only the marks made in this call.

The single-table deleteTable route is kept (unchanged) — nothing else
depends on removing it, and that's a separable cleanup.

Tests: N tables -> one platform DELETE with all job_ids and no per-job
call; rollback on platform failure; rollback + no delete when a later mark
fails; 404 for an unmatched table. 8 controller specs pass; maestro builds.

Co-Authored-By: WOZCODE <contact@withwoz.com>
This commit is contained in:
Rafael
2026-08-19 10:47:06 -03:00
co-authored by WOZCODE
parent 714c1334b9
commit ead3fa12dc
2 changed files with 179 additions and 0 deletions
@@ -199,3 +199,113 @@ describe('PlatformApiController - addTable', () => {
});
});
});
describe('PlatformApiController - deleteTables (batch)', () => {
let controller: PlatformApiController;
let platformApiService: { proxy: jest.Mock };
let inputsService: { markTableDeleted: jest.Mock; unmarkTableDeleted: jest.Mock };
beforeEach(async () => {
platformApiService = { proxy: jest.fn() };
inputsService = {
markTableDeleted: jest.fn().mockResolvedValue({ is_deleted: true, deleted_at: 't' }),
unmarkTableDeleted: jest.fn().mockResolvedValue({}),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [PlatformApiController],
providers: [
{ provide: PlatformApiService, useValue: platformApiService },
{ provide: ElasticsearchService, useValue: {} },
{ provide: DynamoDBService, useValue: {} },
{ provide: CustomersService, useValue: {} },
{ provide: CatalogService, useValue: {} },
{ provide: InputsService, useValue: inputsService },
{ provide: PipelinesService, useValue: {} },
{ provide: DadosferaLogger, useValue: { logger } },
],
}).compile();
controller = module.get<PlatformApiController>(PlatformApiController);
});
const pipelineWithJobs = () => ({
jobs: [
{ job_id: 'p_0', input: { connector: 'cdc', table_name: 'pedidos' } },
{ job_id: 'p_1', input: { connector: 'cdc', table_name: 'clientes' } },
{ job_id: 'p_2', input: { connector: 'cdc', table_name: 'produtos' } },
],
});
it('removes N tables in ONE platform call (connectors reconfigured once)', async () => {
platformApiService.proxy.mockImplementation((method: string) =>
Promise.resolve(method === 'GET' ? pipelineWithJobs() : {}),
);
await controller.deleteTables('pid', 'iid', { table_names: ['pedidos', 'produtos'] }, mockUser);
// one mark per table
expect(inputsService.markTableDeleted).toHaveBeenCalledTimes(2);
// exactly one DELETE to the batch endpoint, with BOTH job_ids
const deleteCalls = platformApiService.proxy.mock.calls.filter(
([m, p]: any[]) => m === 'DELETE' && p === '/pipeline/pid/jobs',
);
expect(deleteCalls).toHaveLength(1);
expect(deleteCalls[0][3]).toEqual({
job_ids: ['p_0', 'p_2'],
delete_snowflake_tables: false,
});
// never the per-job route
const perJob = platformApiService.proxy.mock.calls.some(
([m, p]: any[]) => m === 'DELETE' && String(p).startsWith('/jobs/'),
);
expect(perJob).toBe(false);
});
it('rolls back only this call\'s marks when the platform delete fails', async () => {
platformApiService.proxy.mockImplementation((method: string) => {
if (method === 'GET') return Promise.resolve(pipelineWithJobs());
return Promise.reject(new Error('platform boom'));
});
await expect(
controller.deleteTables('pid', 'iid', { table_names: ['pedidos', 'clientes'] }, mockUser),
).rejects.toThrow();
// both marks rolled back, nothing else
expect(inputsService.unmarkTableDeleted).toHaveBeenCalledTimes(2);
const unmarked = inputsService.unmarkTableDeleted.mock.calls.map((c: any[]) => c[0].table_name).sort();
expect(unmarked).toEqual(['clientes', 'pedidos']);
});
it('rolls back the marks made so far if a later mark fails (atomic)', async () => {
// second mark fails → first must be rolled back, no platform delete attempted
inputsService.markTableDeleted
.mockResolvedValueOnce({ is_deleted: true, deleted_at: 't' })
.mockRejectedValueOnce(new Error('dynamo boom'));
await expect(
controller.deleteTables('pid', 'iid', { table_names: ['pedidos', 'clientes'] }, mockUser),
).rejects.toThrow();
expect(inputsService.unmarkTableDeleted).toHaveBeenCalledTimes(1);
expect(inputsService.unmarkTableDeleted.mock.calls[0][0].table_name).toBe('pedidos');
// never reached the platform delete
const attemptedDelete = platformApiService.proxy.mock.calls.some(([m]: any[]) => m === 'DELETE');
expect(attemptedDelete).toBe(false);
});
it('404s when a requested table has no matching job', async () => {
platformApiService.proxy.mockImplementation((method: string) =>
Promise.resolve(method === 'GET' ? pipelineWithJobs() : {}),
);
await expect(
controller.deleteTables('pid', 'iid', { table_names: ['pedidos', 'ghost'] }, mockUser),
).rejects.toThrow();
// the successful mark (pedidos) must be rolled back
expect(inputsService.unmarkTableDeleted).toHaveBeenCalled();
});
});