import { isRecordChanged, RecordChangedError, RECORD_CHANGED_MESSAGE } from './recordChanged'; import { isSaveRetired, outcomeUnknown, type SaveOperation, type SaveTransport } from './saveOperation'; type Versioned = { meta?: { versionId?: string } }; /** A measurement the correction changes, by the name the report shows for it. */ export interface MeasurementCorrection { name: string; write: (transport: SaveTransport) => Promise; } /** The step of a report correction that did not finish: one measurement, or the report itself (written last). */ export type ReportSaveStep = { kind: 'measurement'; name: string } | { kind: 'report' }; /** * How that step ended: refused because someone else saved first (nothing of it was written), unconfirmed (it was * sent and its answer was lost, so it may have landed), or failed with an answer (not written). */ export type ReportStepOutcome = 'refused' | 'unconfirmed' | 'failed'; const CHECK_FIRST = 'Reload the report and check what is saved before correcting it again.'; const listOf = (names: string[]) => names.join(', '); const wasOrWere = (names: string[]) => (names.length === 1 ? 'was' : 'were'); const sentenceFor = (corrected: string[], step: ReportSaveStep, outcome: ReportStepOutcome): string => { const saved = corrected.length ? `The corrected ${listOf(corrected)} ${wasOrWere(corrected)} saved` : ''; if (step.kind === 'report') { if (outcome === 'unconfirmed') { return `${saved}. Whether the report, with the correction and its reason, was saved could not be confirmed. ` + 'Reload the report and check what is saved before saving again.'; } const why = outcome === 'refused' ? 'was not, because someone else saved a change to the report first' : 'could not be saved'; return `${saved}, but the report ${why}. The correction and its reason are not on the report. ${CHECK_FIRST}`; } const lead = saved ? `${saved}. ` : ''; const notSent = 'The report was not sent, so the correction and its reason are not on it.'; if (outcome === 'unconfirmed') { return `${lead}Whether the corrected ${step.name} was saved could not be confirmed. ${notSent} ${CHECK_FIRST}`; } const why = outcome === 'refused' ? 'was not saved, because someone else saved a change to it first' : 'could not be saved'; return `${lead}The corrected ${step.name} ${why}. ${notSent} ${CHECK_FIRST}`; }; /** * A correction that did not finish once something of it may be on record: the measurements corrected stand (nothing * is rolled back over what may have changed since), the step that did not finish is named with how it ended, and the * way on is to reload and check before correcting again - never to repeat the correction blind. */ export class ReportPartlySavedError extends Error { constructor( public readonly corrected: string[], public readonly step: ReportSaveStep, public readonly outcome: ReportStepOutcome, ) { super(sentenceFor(corrected, step, outcome)); this.name = 'ReportPartlySavedError'; } } const outcomeOf = (error: unknown): ReportStepOutcome => isRecordChanged(error) ? 'refused' : (outcomeUnknown(error) ? 'unconfirmed' : 'failed'); export const isReportPartlySaved = (error: unknown): error is ReportPartlySavedError => error instanceof ReportPartlySavedError || (error as { name?: unknown } | null)?.name === 'ReportPartlySavedError'; /** * A report edit, written only over the version its form was opened on, as one operation of its editor: the report is * read first, so a report changed since is refused before any measurement is corrected; each correction names its * own measurement's version; the report is written last, conditional on the version opened. Retired (another report, * another person) part way, nothing more is sent. Once a measurement is corrected, any later failure is reported as * a partial save naming what was saved, never as nothing saved. */ export const saveReportOverItsVersion = async ({ operation, basedOn, readNow, corrections, write, }: { operation: SaveOperation; basedOn: string | undefined; readNow: (transport: SaveTransport) => Promise; corrections: MeasurementCorrection[]; write: (basedOn: string, transport: SaveTransport) => Promise; }): Promise => { const stored = await operation.step(() => readNow(operation.transport)); if (!basedOn || stored.meta?.versionId !== basedOn) throw new RecordChangedError(RECORD_CHANGED_MESSAGE); const corrected: string[] = []; let step: ReportSaveStep = { kind: 'report' }; try { for (const correction of corrections) { step = { kind: 'measurement', name: correction.name }; await operation.step(() => correction.write(operation.transport), true); corrected.push(correction.name); } step = { kind: 'report' }; return await operation.step(() => write(basedOn, operation.transport), true); } catch (error) { if (isSaveRetired(error)) throw error; const outcome = outcomeOf(error); // Only the report itself was sent: the plain refusal, failure or unconfirmed write says so. A measurement // that did not go through is named, with the report not sent, even when it was the first. if (corrected.length === 0 && step.kind === 'report') throw error; throw new ReportPartlySavedError(corrected, step, outcome); } };