import { isRecordChanged } from './recordChanged'; import { isSaveRetired, isSaveTimedOut, outcomeUnknown, TIMED_OUT_MAY_HAVE_SAVED, type SaveOperation, type SaveTransport } from './saveOperation'; import { olderThan } from './versionOrder'; import type { StaleReason } from '../hooks/useEditBaseline'; type Versioned = { meta?: { versionId?: string } }; /** The parts of an editor's useEditBaseline a save needs. */ interface Baseline { basedOn: string | undefined; beginSave: () => number; endSave: (token: number, saved?: T) => void; refuse: (reason: StaleReason) => void; } /** * `stale`: the opening can save nothing more (refused, out of date, or unconfirmed); the editor shows its standard * notice with Reload record rather than this message. Otherwise the message says nothing was saved; try again. */ export type BaselineSaveOutcome = { saved: T } | { refused: string; stale: boolean } | { retired: true }; /** * A form's save, written only over the version it was opened on, within `operation`. * * Landed: what was saved is the new opening. Refused because someone else saved first (412), or because the page * named no version (428): nothing was written, and nothing more is saved from this opening - no re-read, no merge, * no retry. Out of time, or sent with no answer of ours: says whether the change may have landed, and if it may * have, the opening is done too. * Retired (closed, another record, another person signed in): nothing is said, and nothing of a later save is ended. * Any other failure is thrown for the editor to word. */ export const saveOverBaseline = async ({ baseline, operation, write, }: { baseline: Baseline; operation: SaveOperation; /** The write, carrying the save's transport so it is admitted and cancelled with the save. */ write: (basedOn: string | undefined, transport: SaveTransport) => Promise; }): Promise> => { const token = baseline.beginSave(); try { const saved = await operation.step(() => write(baseline.basedOn, operation.transport), true); baseline.endSave(token, saved); return { saved }; } catch (error) { // Only this save is ended: an opening or save that came after it is left alone. baseline.endSave(token); if (isSaveRetired(error) || !operation.belongs()) return { retired: true }; if (isSaveTimedOut(error)) { if (error.mayHaveSaved) baseline.refuse('unconfirmed'); return { refused: error.message, stale: error.mayHaveSaved }; } // Sent, and no answer of ours came back (the connection failed, or the gateway answered for us): it may have // landed. Not offered again from this opening; what is saved is reloaded and looked at first. if (outcomeUnknown(error)) { baseline.refuse('unconfirmed'); return { refused: TIMED_OUT_MAY_HAVE_SAVED, stale: true }; } if (!isRecordChanged(error)) throw error; baseline.refuse(error.outOfDate ? 'outOfDate' : 'changed'); return { refused: error.message, stale: true }; } }; /** * Why a failed write ends its opening, if it does: refused because someone else saved first (`changed`), the page named * no version (`outOfDate`), or sent with no answer of ours, so it may have landed (`unconfirmed`). Anything else is a * failure with an answer, which leaves the opening as it was. */ export const staleReasonOf = (error: unknown): StaleReason | undefined => isRecordChanged(error) ? (error.outOfDate ? 'outOfDate' : 'changed') : (outcomeUnknown(error) ? 'unconfirmed' : undefined); /** * Reload record: what is saved now, read fresh within `operation`, becomes the opening - the draft is discarded, as * the editor said before. Never an older version than one already known here (a read can lag a broadcast). */ export const reloadBaseline = async ({ baseline, operation, readNow, }: { baseline: { open: (record: T) => void; newest: () => T | undefined }; operation: SaveOperation; readNow: (transport: SaveTransport) => Promise; }): Promise => { const fresh = await operation.step(() => readNow(operation.transport)); operation.admit(); const known = baseline.newest(); const record = known && olderThan(fresh, known) ? known : fresh; baseline.open(record); return record; };