refactor: share prisma json codec and check progress shape (WealthLedger/backend#170)
Backend verify / commits (pull_request) Successful in 1m26s
Backend verify / security (pull_request) Successful in 1m11s
Backend verify / verify (pull_request) Successful in 6m42s

This commit is contained in:
2026-11-04 12:57:37 +03:00
parent fa0a6e5603
commit 44ef63e29f
10 changed files with 196 additions and 59 deletions
@@ -2,7 +2,11 @@ jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
import { screenIntent } from './tbank-sync.repository';
import {
hourlyProgress,
screenIntent,
screenProgress,
} from './tbank-sync.repository';
describe('screen sync intent', () => {
it('restores a persisted intent and rejects a corrupted one', () => {
@@ -12,3 +16,66 @@ describe('screen sync intent', () => {
expect(() => screenIntent(null)).toThrow('Corrupted screen sync intent');
});
});
describe('screen sync progress', () => {
it('restores a persisted progress unchanged', () => {
const progress = { attempts: { 'work-1': 'attempt-1' }, children: ['work-1'], dispatched: true };
expect(screenProgress(progress)).toEqual(progress);
});
it('tolerates legacy rows without attempts', () => {
expect(screenProgress({ children: [], dispatched: false })).toEqual({
children: [],
dispatched: false,
});
});
it('rejects corrupt rows with an explicit error', () => {
expect(() => screenProgress(null)).toThrow('Corrupted screen sync progress');
expect(() => screenProgress({})).toThrow('Corrupted screen sync progress');
expect(() => screenProgress({ children: 'x', dispatched: false })).toThrow(
'Corrupted screen sync progress',
);
expect(() => screenProgress({ children: [1], dispatched: false })).toThrow(
'Corrupted screen sync progress',
);
expect(
() => screenProgress({ children: [], dispatched: 'yes' }),
).toThrow('Corrupted screen sync progress');
expect(
() => screenProgress({ children: [], dispatched: false, attempts: { w: 7 } }),
).toThrow('Corrupted screen sync progress');
});
});
describe('hourly sync progress', () => {
it('restores a persisted progress unchanged', () => {
const progress = {
attempts: { 'work-1': 'attempt-1' },
children: [{ id: 'work-1', section: 'portfolio' }],
result: 'partial',
sections: [{ errorCode: null, section: 'events' }],
};
expect(hourlyProgress(progress)).toEqual(progress);
});
it('tolerates legacy rows without optional collections', () => {
expect(hourlyProgress({ sections: [] })).toEqual({ sections: [] });
// Legacy hourly rows were seeded with an empty progress object.
expect(hourlyProgress({})).toEqual({});
});
it('rejects corrupt rows with an explicit error', () => {
expect(() => hourlyProgress(null)).toThrow('Corrupted hourly sync progress');
expect(() => hourlyProgress({ sections: 'x' })).toThrow('Corrupted hourly sync progress');
expect(() => hourlyProgress({ sections: [{ errorCode: 'x', section: 1 }] })).toThrow(
'Corrupted hourly sync progress',
);
expect(() =>
hourlyProgress({ sections: [], children: [{ id: 'w', section: 5 }] }),
).toThrow('Corrupted hourly sync progress');
expect(() => hourlyProgress({ sections: [], attempts: { w: 7 } })).toThrow(
'Corrupted hourly sync progress',
);
});
});
@@ -7,6 +7,7 @@ import type { InvestmentAttemptDto } from '../investment-resource.dto';
import type { InvestmentScreenDto } from './tbank-sync.dto';
import { PrismaService } from '../../../prisma/prisma.service';
import { toJson } from '../../../prisma/to-json';
export type ScreenIntent = { now: string; scope: InvestmentScreenDto };
// Json columns are written only by this repository; a shape check turns corruption into an explicit 500.
@@ -22,6 +23,31 @@ export type ScreenProgress = {
children: string[];
dispatched: boolean;
};
// Progress columns are written only by this repository; a shape check turns
// corruption into an explicit 500. Legacy rows predate optional fields, so
// only the fields every writer has always written are required.
function validAttemptMap(attempts: unknown): boolean {
return (
attempts === undefined ||
(typeof attempts === 'object' &&
attempts !== null &&
!Array.isArray(attempts) &&
Object.values(attempts).every((id) => typeof id === 'string'))
);
}
export function screenProgress(value: Prisma.JsonValue): ScreenProgress {
const progress = value as null | Partial<ScreenProgress>;
if (
typeof progress?.dispatched !== 'boolean' ||
!Array.isArray(progress.children) ||
!progress.children.every((child) => typeof child === 'string') ||
!validAttemptMap(progress.attempts)
) {
throw new Error('Corrupted screen sync progress');
}
return progress as ScreenProgress;
}
export type HourlySection = {
accountId?: string;
errorCode: null | string;
@@ -43,6 +69,42 @@ export type HourlyProgress = {
result?: 'failed' | 'partial' | 'succeeded';
sections: HourlySection[];
};
// Same tolerance rule as screenProgress: required fields are the ones every
// hourly writer has always written; optional collections may be absent.
export function hourlyProgress(value: Prisma.JsonValue): HourlyProgress {
const progress: unknown = value;
if (typeof progress !== 'object' || progress === null) {
throw new Error('Corrupted hourly sync progress');
}
const row = progress as Record<string, unknown>;
const validChildren =
row.children === undefined ||
(Array.isArray(row.children) &&
row.children.every((child) => {
if (typeof child !== 'object' || child === null) return false;
const entry = child as Record<string, unknown>;
return typeof entry.id === 'string' && typeof entry.section === 'string';
}));
const validSections =
row.sections === undefined ||
(Array.isArray(row.sections) &&
row.sections.every((section) => {
if (typeof section !== 'object' || section === null) return false;
const entry = section as Record<string, unknown>;
return (
typeof entry.section === 'string' &&
(typeof entry.errorCode === 'string' || entry.errorCode === null)
);
}));
if (
!validSections ||
!validAttemptMap(row.attempts) ||
!validChildren
) {
throw new Error('Corrupted hourly sync progress');
}
return progress as HourlyProgress;
}
type HourlyAttempt = {
finishedAt: Date | null;
sections: HourlySection[];
@@ -99,7 +161,7 @@ export class TbankSyncRepository {
where: { userId_revision_scopeKey: key },
});
if (previous?.state === 'running' && previous.processRunId === runId) {
const progress = previous.progress as ScreenProgress;
const progress = screenProgress(previous.progress);
const children = progress.children ?? [];
if (
!progress.dispatched ||
@@ -115,9 +177,7 @@ export class TbankSyncRepository {
processRunId: runId,
state: 'running',
priority: 0,
intent: JSON.parse(
JSON.stringify({ scope, now: new Date().toISOString() }),
) as Prisma.InputJsonObject,
intent: toJson({ scope, now: new Date().toISOString() }),
progress: { children: [], dispatched: false },
errorCode: null,
};
@@ -136,7 +196,7 @@ export class TbankSyncRepository {
where: { userId, revision, id },
});
if (!work) return null;
const progress = work.progress as ScreenProgress;
const progress = screenProgress(work.progress);
const children = await tx.tbankSyncWork.findMany({
where: { userId, revision, id: { in: progress.children ?? [] } },
select: { id: true, attemptId: true, state: true, errorCode: true },
@@ -163,7 +223,7 @@ export class TbankSyncRepository {
return this.prisma.$transaction(async (tx) => {
const previous = await tx.tbankSyncWork.findFirst({ where: attempt });
if (!previous) return { count: 0 };
const old = previous.progress as ScreenProgress;
const old = screenProgress(previous.progress);
const rows = await tx.tbankSyncWork.findMany({
where: {
userId: attempt.userId,
@@ -295,7 +355,7 @@ export class TbankSyncRepository {
cause: HourlyCause | null,
dispatchProblems: HourlySection[],
) {
const progress = work.progress as HourlyProgress;
const progress = hourlyProgress(work.progress);
const children = progress.children ?? [];
const rows = children.length
? await tx.tbankSyncWork.findMany({
@@ -359,7 +419,7 @@ export class TbankSyncRepository {
select: { progress: true },
});
if (!work) return false;
const progress = work.progress as HourlyProgress;
const progress = hourlyProgress(work.progress);
const row = await tx.tbankSyncWork.findFirst({
where: { userId: attempt.userId, id: child.id },
select: { attemptId: true },
@@ -404,7 +464,7 @@ export class TbankSyncRepository {
},
});
if (!work) return null;
const progress = work.progress as HourlyProgress;
const progress = hourlyProgress(work.progress);
let state: 'cancelled' | 'running' | HourlyProgress['result'];
if (work.state === 'running') state = 'running';
else if (work.state === 'cancelled') state = 'cancelled';