Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/modules/catalog-etl/catalog-etl.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { AdvisoryLockService } from '../../common/services';
import { FactionsSyncStep } from './steps/factions-sync.step';
import { JurisdictionsSyncStep } from './steps/jurisdictions-sync.step';
import { CompaniesSyncStep } from './steps/companies-sync.step';
import { StarSystemsSyncStep } from './steps/star-systems-sync.step';
import { OrbitsSyncStep } from './steps/orbits-sync.step';
import { UexSyncModule } from '../uex-sync/uex-sync.module';

@Module({
Expand All @@ -19,6 +21,8 @@ import { UexSyncModule } from '../uex-sync/uex-sync.module';
FactionsSyncStep,
JurisdictionsSyncStep,
CompaniesSyncStep,
StarSystemsSyncStep,
OrbitsSyncStep,
],
exports: [CatalogEtlService],
})
Expand Down
10 changes: 10 additions & 0 deletions backend/src/modules/catalog-etl/catalog-etl.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { AdvisoryLockService } from '../../common/services';
import { FactionsSyncStep } from './steps/factions-sync.step';
import { JurisdictionsSyncStep } from './steps/jurisdictions-sync.step';
import { CompaniesSyncStep } from './steps/companies-sync.step';
import { StarSystemsSyncStep } from './steps/star-systems-sync.step';
import { OrbitsSyncStep } from './steps/orbits-sync.step';

function buildMockRun(overrides: Partial<EtlRun> = {}): EtlRun {
const run = new EtlRun();
Expand Down Expand Up @@ -86,6 +88,14 @@ describe('CatalogEtlService', () => {
provide: CompaniesSyncStep,
useValue: { name: 'companies', execute: jest.fn() },
},
{
provide: StarSystemsSyncStep,
useValue: { name: 'star-systems', execute: jest.fn() },
},
{
provide: OrbitsSyncStep,
useValue: { name: 'orbits', execute: jest.fn() },
},
],
}).compile();

Expand Down
6 changes: 6 additions & 0 deletions backend/src/modules/catalog-etl/catalog-etl.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { AdvisoryLockService } from '../../common/services';
import { FactionsSyncStep } from './steps/factions-sync.step';
import { JurisdictionsSyncStep } from './steps/jurisdictions-sync.step';
import { CompaniesSyncStep } from './steps/companies-sync.step';
import { StarSystemsSyncStep } from './steps/star-systems-sync.step';
import { OrbitsSyncStep } from './steps/orbits-sync.step';

@Injectable()
export class CatalogEtlService {
Expand All @@ -25,11 +27,15 @@ export class CatalogEtlService {
private readonly factionsSyncStep: FactionsSyncStep,
private readonly jurisdictionsSyncStep: JurisdictionsSyncStep,
private readonly companiesSyncStep: CompaniesSyncStep,
private readonly starSystemsSyncStep: StarSystemsSyncStep,
private readonly orbitsSyncStep: OrbitsSyncStep,
) {
this.ETL_STEPS = [
factionsSyncStep,
jurisdictionsSyncStep,
starSystemsSyncStep,
companiesSyncStep,
orbitsSyncStep,
];
}

Expand Down
66 changes: 1 addition & 65 deletions backend/src/modules/catalog-etl/steps/factions-sync.step.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,7 @@ describe('FactionsSyncStep', () => {

beforeEach(() => {
uexGet = jest.fn();
// Default: return [] for existence checks, [] for deletes/inserts
dsQuery = jest.fn().mockResolvedValue([{ exists: false }]);
dsQuery = jest.fn().mockResolvedValue([]);
repoCreate = jest
.fn()
.mockImplementation((dto) => ({ ...dto }) as EtlWarning);
Expand Down Expand Up @@ -237,67 +236,4 @@ describe('FactionsSyncStep', () => {
expect(insertCalls).toHaveLength(0);
});
});

describe('star system junction reconciliation', () => {
it('deletes existing star system rows then inserts links for known star systems', async () => {
const factions = [makeFaction({ id: 1, ids_star_systems: '10,20' })];
uexGet.mockResolvedValue(factions);

// Star system 10 exists, 20 does not
dsQuery.mockImplementation((sql: string, params?: unknown[]) => {
if ((sql as string).includes('EXISTS') && params?.[0] === 10)
return Promise.resolve([{ exists: true }]);
if ((sql as string).includes('EXISTS') && params?.[0] === 20)
return Promise.resolve([{ exists: false }]);
return Promise.resolve([]);
});

const step = buildStep(uexGet, dsQuery, repoCreate, repoSave);
await step.execute({ runId: RUN_ID });

const deleteCalls = dsQuery.mock.calls.filter(
(c: unknown[]) =>
(c[0] as string).includes(
'DELETE FROM station_faction_star_system',
) && (c[1] as number[])[0] === 1,
);
expect(deleteCalls).toHaveLength(1);

const insertCalls = dsQuery.mock.calls.filter((c: unknown[]) =>
(c[0] as string).includes('INSERT INTO station_faction_star_system'),
);
// Only id 10 inserts; id 20 is skipped with a warning
expect(insertCalls).toHaveLength(1);
expect(insertCalls[0][1]).toEqual([1, 10]);

expect(repoCreate).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'warn',
message: expect.stringContaining('20'),
rawPayload: { faction_id: 1, missing_id: 20 },
}),
);
});

it('deletes existing star system rows even when ids_star_systems is empty', async () => {
const factions = [makeFaction({ id: 1, ids_star_systems: '' })];
uexGet.mockResolvedValue(factions);

const step = buildStep(uexGet, dsQuery, repoCreate, repoSave);
await step.execute({ runId: RUN_ID });

const deleteCalls = dsQuery.mock.calls.filter(
(c: unknown[]) =>
(c[0] as string).includes(
'DELETE FROM station_faction_star_system',
) && (c[1] as number[])[0] === 1,
);
expect(deleteCalls).toHaveLength(1);

const insertCalls = dsQuery.mock.calls.filter((c: unknown[]) =>
(c[0] as string).includes('INSERT INTO station_faction_star_system'),
);
expect(insertCalls).toHaveLength(0);
});
});
});
44 changes: 4 additions & 40 deletions backend/src/modules/catalog-etl/steps/factions-sync.step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,15 @@ export class FactionsSyncStep implements EtlStep {
);
}

// Reconcile junction tables per faction — delete existing rows then
// re-insert current set so stale relationships are removed on each run.
// Reconcile friendly/hostile junction tables per faction — delete existing
// rows then re-insert current set so stale relationships are removed on
// each run. Star-system junction reconciliation is deferred to
// StarSystemsSyncStep so it runs after star systems are populated.
for (const record of factions) {
if (!record.name) continue;

await this.reconcileFriendly(ctx, record, fetchedIds);
await this.reconcileHostile(ctx, record, fetchedIds);
await this.reconcileStarSystems(ctx, record);
}

this.logger.info({ runId: ctx.runId }, 'factions-sync step complete');
Expand Down Expand Up @@ -167,41 +168,4 @@ export class FactionsSyncStep implements EtlStep {
);
}
}

private async reconcileStarSystems(
ctx: EtlStepContext,
record: UexFaction,
): Promise<void> {
const starSystemIds = parseCsvInts(record.ids_star_systems);

await this.dataSource.query(
`DELETE FROM station_faction_star_system WHERE faction_uex_id = $1`,
[record.id],
);

for (const ssId of starSystemIds) {
// Only insert if the star system row exists; it may not be synced yet.
const [{ exists }] = await this.dataSource.query(
`SELECT EXISTS(SELECT 1 FROM station_star_system WHERE uex_id = $1) AS exists`,
[ssId],
);
if (!exists) {
const warning = this.warningsRepo.create({
runId: ctx.runId,
stepName: this.name,
severity: 'warn',
message: `Star system ${ssId} not found — faction_star_system link deferred`,
rawPayload: { faction_id: record.id, missing_id: ssId },
});
await this.warningsRepo.save(warning);
continue;
}
await this.dataSource.query(
`INSERT INTO station_faction_star_system (faction_uex_id, star_system_uex_id)
VALUES ($1,$2)
ON CONFLICT DO NOTHING`,
[record.id, ssId],
);
}
}
}
Loading
Loading