From 3b595da9ab69398a75800644ac404ed552346721 Mon Sep 17 00:00:00 2001 From: pablonete Date: Wed, 24 Jun 2026 22:47:07 +0200 Subject: [PATCH] Refactor persistence commands for DB migration Move server write paths onto command-oriented store operations so file and blob adapters share the same registration, passport, prize, award, and restore behavior. This prepares the persistence seam for Netlify DB and dual-write rollout while preserving current API responses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/storage-json.md | 15 +- server/api.ts | 325 +++++++++++------------------------ server/blob-store.ts | 17 +- server/store.ts | 392 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 513 insertions(+), 236 deletions(-) diff --git a/docs/storage-json.md b/docs/storage-json.md index 8cad399..fd24dd4 100644 --- a/docs/storage-json.md +++ b/docs/storage-json.md @@ -11,6 +11,11 @@ Netlify Blobs are optimized for reads and infrequent writes. Avoid read-modify-write on shared JSON blobs for high-frequency event data because same-key writes are last-write-wins and reads may be stale. +Server code should write through the command-oriented store methods in +`server/store.ts` instead of calling generic snapshot mutators from API +handlers. This keeps IDs and timestamps explicit where needed and prepares the +app for DB and dual-write backends. + ## Current documents | Data | Local Node JSON | Netlify Blob key | Pattern | Notes | @@ -36,9 +41,7 @@ same-key writes are last-write-wins and reads may be stale. ## Follow-up migration targets -1. Move kids from `kids.json` to `kids/{kidId}.json` with `onlyIfNew` ID claims. -2. Consider per-prize blobs for `wheel-prizes.json` if concurrent catalog editing - becomes common. -3. Consider one blob per passport activity completion if concurrent same-kid - activity updates become common. -4. Consider one blob per magic token if concurrent admin link generation matters. +1. Move writable event state to Netlify DB/Postgres behind the store interface. +2. Add migration and verification tooling from file/blob JSON to DB rows. +3. Enable config-driven dual writes before switching reads to DB. +4. Keep blobs as a rollback target until DB reads are verified. diff --git a/server/api.ts b/server/api.ts index 177d142..85eda1f 100644 --- a/server/api.ts +++ b/server/api.ts @@ -6,13 +6,18 @@ import { type MagicLinkSession, } from './access-tokens.js'; import { + awardPrize, + completePassportActivity, + KidIdAllocationError, + PrizeOutOfStockError, readSnapshot, - updatePassportForKid, - updatePrizeAwardsForKid, - updateSnapshot, + registerKid, + restoreWritableData, + savePrize, + syncPrizeGivenCache, + UnknownPrizeError, } from './store.js'; import type { - Kid, PassportActivitiesByKid, PassportActivity, Prize, @@ -45,8 +50,6 @@ class HttpError extends Error { } } -class StaleKidIdReadError extends Error {} - type AdminBackup = { exportedAt: string; passports: PassportActivitiesByKid; @@ -66,8 +69,6 @@ const apiPaths = new Set([ '/prizes-kid', ]); const kidGenders = new Set(['boy', 'girl', 'preferNotToSay']); -const kidRegistrationRetryDelayMs = 250; -const maxKidRegistrationAttempts = 20; const prizeKinds = new Set(['final', 'normal', 'valuable']); const staffRoles = new Set(['desk', 'lead', 'wheel']); const supportedLocales = new Set(['en', 'es']); @@ -336,25 +337,6 @@ function normalizeOptionalLastKnownKidId(value: unknown) { return value.trim().toLowerCase(); } -function delay(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function getNextKidId(existingKids: Kid[], kidIdPrefix: string) { - const existingIds = new Set(existingKids.map((kid) => kid.id.toLowerCase())); - let sequence = existingKids.length + 1; - let nextId = `${kidIdPrefix}${sequence.toString().padStart(4, '0')}`; - - while (existingIds.has(nextId.toLowerCase())) { - sequence += 1; - nextId = `${kidIdPrefix}${sequence.toString().padStart(4, '0')}`; - } - - return nextId; -} - function passportResponse( passportActivitiesByKid: PassportActivitiesByKid, kidId: string, @@ -372,40 +354,6 @@ function passportTemplate( ); } -function ensurePassportForKid( - passportActivitiesByKid: PassportActivitiesByKid, - kidId: string, - activityId: number, -) { - const existingPassport = passportActivitiesByKid[kidId]; - - if (existingPassport) { - return existingPassport; - } - - const template = passportTemplate(passportActivitiesByKid); - const passport = - template.length > 0 ? template : ([{ id: activityId }] satisfies PassportActivity[]); - - passportActivitiesByKid[kidId] = passport; - return passport; -} - -function getPrizeGiven(prizeAwards: PrizeAward[], prizeId: string) { - return prizeAwards.filter((award) => award.prizeId === prizeId).length; -} - -function syncPrizeGivenCache(prizes: Prize[], prizeAwards: PrizeAward[]) { - return prizes.map((prize) => ({ - ...prize, - given: getPrizeGiven(prizeAwards, prize.id), - })); -} - -function getPrizeRemaining(prize: Prize) { - return Math.max(prize.initialUnits - prize.given, 0); -} - function normalizePrizeKind(value: unknown) { if (value === undefined) { return undefined; @@ -418,26 +366,6 @@ function normalizePrizeKind(value: unknown) { return value as PrizeKind; } -function createPrizeId(prizes: Prize[]) { - let suffix = prizes.length + 1; - let candidate = `prize-${suffix}`; - - while (prizes.some((prize) => prize.id === candidate)) { - suffix += 1; - candidate = `prize-${suffix}`; - } - - return candidate; -} - -function snapshotPrizeResponse(snapshot: StoreData, prize?: Prize) { - return { - prize, - prizeAwards: snapshot.prizeAwards, - prizes: syncPrizeGivenCache(snapshot.prizes, snapshot.prizeAwards), - }; -} - function createAdminBackup(snapshot: StoreData): AdminBackup { return { exportedAt: new Date().toISOString(), @@ -596,50 +524,24 @@ async function handleKids(request: ApiRequest, url: URL): Promise { await requireMagicLink(request, url, ['desk']); const registration = normalizeRegistrationInput(parseJsonBody(request.body)); - let response: Kid | undefined; - - for (let attempt = 1; attempt <= maxKidRegistrationAttempts; attempt += 1) { - try { - response = await updateSnapshot((snapshot) => { - const kidId = getNextKidId(snapshot.kids, snapshot.conference.kidIdPrefix); - - if (kidId.toLowerCase() === registration.lastKnownKidId) { - throw new StaleKidIdReadError(); - } - - const kid: Kid = { - age: registration.age, - gender: registration.gender, - id: kidId, - language: registration.language, - name: registration.nickname, - }; - const passport = passportTemplate(snapshot.passportActivitiesByKid); - - snapshot.kids.push(kid); - snapshot.passportActivitiesByKid[kid.id] = passport; - - return kid; - }, ['kids', 'passportActivitiesByKid']); - break; - } catch (error) { - if ( - error instanceof StaleKidIdReadError && - attempt < maxKidRegistrationAttempts - ) { - await delay(kidRegistrationRetryDelayMs); - continue; - } - - throw error; + + try { + const response = await registerKid({ + age: registration.age, + gender: registration.gender, + language: registration.language, + lastKnownKidId: registration.lastKnownKidId, + name: registration.nickname, + }); + + return jsonResponse(request, 201, response); + } catch (error) { + if (error instanceof KidIdAllocationError) { + throw new HttpError(409, 'Unable to allocate a fresh kid id'); } - } - if (!response) { - throw new HttpError(409, 'Unable to allocate a fresh kid id'); + throw error; } - - return jsonResponse(request, 201, response); } async function handleAdmin( @@ -697,13 +599,13 @@ async function handleAdmin( } const backup = parseAdminBackup(parseJsonBody(request.body)); - const restoredBackup = await updateSnapshot((snapshot) => { - snapshot.passportActivitiesByKid = backup.passports; - snapshot.prizeAwards = backup.prizesWon; - snapshot.prizes = syncPrizeGivenCache(backup.wheelPrizes, backup.prizesWon); - - return createAdminBackup(snapshot); - }, ['passportActivitiesByKid', 'prizeAwards', 'prizes']); + const restoredBackup = createAdminBackup( + await restoreWritableData({ + passportActivitiesByKid: backup.passports, + prizeAwards: backup.prizesWon, + prizes: backup.wheelPrizes, + }), + ); return jsonResponse(request, 200, restoredBackup); } @@ -770,22 +672,10 @@ async function handlePassport( const snapshot = await readSnapshot(); const kidId = normalizeKidId(url.searchParams.get('kid'), snapshot); - const passport = await updatePassportForKid(kidId, (snapshot) => { - const passport = ensurePassportForKid( - snapshot.passportActivitiesByKid, - kidId, - activityId, - ); - const matchingActivity = passport.find((activity) => activity.id === activityId); - - if (matchingActivity) { - matchingActivity.completedAt ??= new Date().toISOString(); - } else { - passport.push({ completedAt: new Date().toISOString(), id: activityId }); - passport.sort((left, right) => left.id - right.id); - } - - return passport; + const passport = await completePassportActivity({ + activityId, + completedAt: new Date().toISOString(), + kidId, }); return jsonResponse(request, 200, passport); @@ -815,72 +705,63 @@ async function handleWheelPrizes( const body = parseJsonBody(request.body); const stock = url.searchParams.get('stock')?.trim(); - const response = await updateSnapshot((snapshot) => { - const syncedPrizes = syncPrizeGivenCache(snapshot.prizes, snapshot.prizeAwards); + if (!stock) { + if (typeof body.title !== 'string' || !body.title.trim()) { + throw new HttpError(400, 'title is required when stock is omitted'); + } - if (!stock) { - if (typeof body.title !== 'string' || !body.title.trim()) { - throw new HttpError(400, 'title is required when stock is omitted'); - } + const initialUnits = + body.initialUnits === undefined + ? 1 + : normalizeCount(body.initialUnits, 'initialUnits'); - const initialUnits = - body.initialUnits === undefined - ? 1 - : normalizeCount(body.initialUnits, 'initialUnits'); - const prize: Prize = { - given: 0, - id: createPrizeId(snapshot.prizes), + return jsonResponse( + request, + 200, + await savePrize({ initialUnits, - kind: 'normal', title: body.title.trim(), - }; - - snapshot.prizes.push(prize); - return snapshotPrizeResponse(snapshot, prize); - } + type: 'create', + }), + ); + } - const prize = syncedPrizes.find((entry) => entry.id === stock); + const snapshot = await readSnapshot(); + const prize = syncPrizeGivenCache(snapshot.prizes, snapshot.prizeAwards).find( + (entry) => entry.id === stock, + ); - if (!prize) { - throw new HttpError(404, `Unknown prize: ${stock}`); - } + if (!prize) { + throw new HttpError(404, `Unknown prize: ${stock}`); + } - const title = body.title === undefined ? prize.title : String(body.title).trim(); + const title = body.title === undefined ? prize.title : String(body.title).trim(); - if (!title) { - throw new HttpError(400, 'title cannot be empty'); - } + if (!title) { + throw new HttpError(400, 'title cannot be empty'); + } - const kind = normalizePrizeKind(body.kind) ?? prize.kind; - const initialUnits = - body.initialUnits === undefined - ? prize.initialUnits - : Math.max( - normalizeCount(body.initialUnits, 'initialUnits'), - getPrizeGiven(snapshot.prizeAwards, prize.id), - ); - - snapshot.prizes = snapshot.prizes.map((entry) => - entry.id === prize.id - ? { - ...entry, - given: getPrizeGiven(snapshot.prizeAwards, prize.id), - initialUnits, - kind, - title, - } - : entry, - ); + try { + const response = await savePrize({ + initialUnits: + body.initialUnits === undefined + ? undefined + : normalizeCount(body.initialUnits, 'initialUnits'), + prizeId: stock, + prizeKind: normalizePrizeKind(body.kind) ?? prize.kind, + title, + type: 'update', + }); - return snapshotPrizeResponse( - snapshot, - snapshot.prizes.find((entry) => entry.id === prize.id), - ); - }, ['prizes']); + return jsonResponse(request, 200, response); + } catch (error) { + if (error instanceof UnknownPrizeError) { + throw new HttpError(404, `Unknown prize: ${stock}`); + } - return jsonResponse(request, 200, response); + throw error; + } } - function normalizeAwardSource(value: unknown) { if (value === undefined || value === null || value === '') { return undefined; @@ -924,43 +805,29 @@ async function handlePrizesKid( const snapshot = await readSnapshot(); const kidId = normalizeKidId(url.searchParams.get('kid'), snapshot); - const response = await updatePrizeAwardsForKid(kidId, (snapshot) => { - const source = normalizeAwardSource(body.source); - const syncedPrizes = syncPrizeGivenCache(snapshot.prizes, snapshot.prizeAwards); - const prize = syncedPrizes.find((entry) => entry.id === stock); - - if (!prize) { - throw new HttpError(404, `Unknown prize: ${stock}`); - } - - if (source === 'passportCompletion') { - const existingAward = snapshot.prizeAwards.find( - (award) => award.kidId === kidId && award.source === 'passportCompletion', - ); - - if (existingAward) { - return snapshot.prizeAwards.filter((award) => award.kidId === kidId); - } - } - - if (getPrizeRemaining(prize) <= 0) { - throw new HttpError(409, `Prize is out of stock: ${stock}`); - } + const source = normalizeAwardSource(body.source); - const award: PrizeAward = { + try { + const response = await awardPrize({ + awardId: `${kidId}-${source === 'passportCompletion' ? 'passport-complete' : 'wheel'}-${randomUUID()}`, awardedAt: new Date().toISOString(), - id: `${kidId}-${source === 'passportCompletion' ? 'passport-complete' : 'wheel'}-${randomUUID()}`, kidId, - prizeId: prize.id, + prizeId: stock, ...(source ? { source } : {}), - }; + }); - snapshot.prizeAwards.push(award); + return jsonResponse(request, 200, response); + } catch (error) { + if (error instanceof UnknownPrizeError) { + throw new HttpError(404, `Unknown prize: ${stock}`); + } - return snapshot.prizeAwards.filter((award) => award.kidId === kidId); - }); + if (error instanceof PrizeOutOfStockError) { + throw new HttpError(409, `Prize is out of stock: ${stock}`); + } - return jsonResponse(request, 200, response); + throw error; + } } export async function handleApiRequest(request: ApiRequest): Promise { diff --git a/server/blob-store.ts b/server/blob-store.ts index b9bebd5..b317a87 100644 --- a/server/blob-store.ts +++ b/server/blob-store.ts @@ -1,7 +1,16 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { getStore, type Store as NetlifyBlobStore } from '@netlify/blobs'; -import { getStoreFileName, type StoreAdapter, type StoreFile } from './store.js'; +import { + getStoreFileName, + runAwardPrizeCommand, + runCompletePassportActivityCommand, + runRegisterKidCommand, + runRestoreWritableDataCommand, + runSavePrizeCommand, + type StoreAdapter, + type StoreFile, +} from './store.js'; import type { ConferenceData, Kid, @@ -378,7 +387,13 @@ export function createBlobStore( } return { + awardPrize: (command) => runAwardPrizeCommand(command, updatePrizeAwardsForKid), + completePassportActivity: (command) => + runCompletePassportActivityCommand(command, updatePassportForKid), readSnapshot, + registerKid: (command) => runRegisterKidCommand(command, updateSnapshot), + restoreWritableData: (data) => runRestoreWritableDataCommand(data, updateSnapshot), + savePrize: (command) => runSavePrizeCommand(command, updateSnapshot), updatePassportForKid, updatePrizeAwardsForKid, updateSnapshot, diff --git a/server/store.ts b/server/store.ts index a986f05..3dc4176 100644 --- a/server/store.ts +++ b/server/store.ts @@ -4,6 +4,7 @@ import type { ConferenceData, Kid, PassportActivitiesByKid, + PassportActivity, Prize, PrizeAward, StoreData, @@ -11,8 +12,62 @@ import type { export type StoreFile = keyof typeof storeFiles; +export type RegisterKidCommand = { + age: number; + gender: string; + language: string; + lastKnownKidId?: string; + name: string; +}; + +export type CompletePassportActivityCommand = { + activityId: number; + completedAt: string; + kidId: string; +}; + +export type SavePrizeCommand = + | { + initialUnits: number; + title: string; + type: 'create'; + } + | { + initialUnits?: number; + prizeId: string; + prizeKind?: Prize['kind']; + title?: string; + type: 'update'; + }; + +export type PrizeMutationResult = { + prize?: Prize; + prizeAwards: PrizeAward[]; + prizes: Prize[]; +}; + +export type AwardPrizeCommand = { + awardId: string; + awardedAt: string; + kidId: string; + prizeId: string; + source?: PrizeAward['source']; +}; + +export type WritableStoreData = Pick< + StoreData, + 'passportActivitiesByKid' | 'prizeAwards' | 'prizes' +>; + export type StoreAdapter = { + awardPrize(command: AwardPrizeCommand): Promise; + completePassportActivity( + command: CompletePassportActivityCommand, + ): Promise; readSnapshot(): Promise; + registerKid(command: RegisterKidCommand): Promise; + restoreWritableData(data: WritableStoreData): Promise; + savePrize(command: SavePrizeCommand): Promise; updatePassportForKid( kidId: string, mutator: (snapshot: StoreData) => T | Promise, @@ -35,6 +90,29 @@ const storeFiles = { prizes: 'prizes.json', } as const; +class StaleKidIdReadError extends Error {} + +export class KidIdAllocationError extends Error { + constructor() { + super('Unable to allocate a fresh kid id'); + } +} + +export class UnknownPrizeError extends Error { + constructor(prizeId: string) { + super(`Unknown prize: ${prizeId}`); + } +} + +export class PrizeOutOfStockError extends Error { + constructor(prizeId: string) { + super(`Prize is out of stock: ${prizeId}`); + } +} + +const kidRegistrationRetryDelayMs = 250; +const maxKidRegistrationAttempts = 20; + const defaultDataDir = path.resolve(process.env.KID_A_DATA_DIR ?? 'server/data'); const seedDataDir = path.resolve(process.env.KID_A_SEED_DATA_DIR ?? 'src/data'); @@ -51,6 +129,292 @@ export function getStoreFileName(storeFile: StoreFile) { return storeFiles[storeFile]; } +function delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function getNextKidId(existingKids: Kid[], kidIdPrefix: string) { + const existingIds = new Set(existingKids.map((kid) => kid.id.toLowerCase())); + let sequence = existingKids.length + 1; + let nextId = `${kidIdPrefix}${sequence.toString().padStart(4, '0')}`; + + while (existingIds.has(nextId.toLowerCase())) { + sequence += 1; + nextId = `${kidIdPrefix}${sequence.toString().padStart(4, '0')}`; + } + + return nextId; +} + +function passportTemplate( + passportActivitiesByKid: PassportActivitiesByKid, +): PassportActivity[] { + return ( + Object.values(passportActivitiesByKid)[0]?.map((activity) => ({ + id: activity.id, + })) ?? [] + ); +} + +function ensurePassportForKid( + passportActivitiesByKid: PassportActivitiesByKid, + kidId: string, + activityId: number, +) { + const existingPassport = passportActivitiesByKid[kidId]; + + if (existingPassport) { + return existingPassport; + } + + const template = passportTemplate(passportActivitiesByKid); + const passport = + template.length > 0 ? template : ([{ id: activityId }] satisfies PassportActivity[]); + + passportActivitiesByKid[kidId] = passport; + return passport; +} + +function getPrizeGiven(prizeAwards: PrizeAward[], prizeId: string) { + return prizeAwards.filter((award) => award.prizeId === prizeId).length; +} + +export function syncPrizeGivenCache(prizes: Prize[], prizeAwards: PrizeAward[]) { + return prizes.map((prize) => ({ + ...prize, + given: getPrizeGiven(prizeAwards, prize.id), + })); +} + +function getPrizeRemaining(prize: Prize) { + return Math.max(prize.initialUnits - prize.given, 0); +} + +function createPrizeId(prizes: Prize[]) { + let suffix = prizes.length + 1; + let candidate = `prize-${suffix}`; + + while (prizes.some((prize) => prize.id === candidate)) { + suffix += 1; + candidate = `prize-${suffix}`; + } + + return candidate; +} + +function snapshotPrizeResponse(snapshot: StoreData, prize?: Prize): PrizeMutationResult { + return { + prize, + prizeAwards: snapshot.prizeAwards, + prizes: syncPrizeGivenCache(snapshot.prizes, snapshot.prizeAwards), + }; +} + +function applyRegisterKid(snapshot: StoreData, command: RegisterKidCommand) { + const kidId = getNextKidId(snapshot.kids, snapshot.conference.kidIdPrefix); + + if (kidId.toLowerCase() === command.lastKnownKidId) { + throw new StaleKidIdReadError(); + } + + const kid: Kid = { + age: command.age, + gender: command.gender, + id: kidId, + language: command.language, + name: command.name, + }; + const passport = passportTemplate(snapshot.passportActivitiesByKid); + + snapshot.kids.push(kid); + snapshot.passportActivitiesByKid[kid.id] = passport; + + return kid; +} + +function applyCompletePassportActivity( + snapshot: StoreData, + command: CompletePassportActivityCommand, +) { + const passport = ensurePassportForKid( + snapshot.passportActivitiesByKid, + command.kidId, + command.activityId, + ); + const matchingActivity = passport.find( + (activity) => activity.id === command.activityId, + ); + + if (matchingActivity) { + matchingActivity.completedAt ??= command.completedAt; + } else { + passport.push({ completedAt: command.completedAt, id: command.activityId }); + passport.sort((left, right) => left.id - right.id); + } + + return passport; +} + +function applySavePrize(snapshot: StoreData, command: SavePrizeCommand) { + if (command.type === 'create') { + const prize: Prize = { + given: 0, + id: createPrizeId(snapshot.prizes), + initialUnits: command.initialUnits, + kind: 'normal', + title: command.title, + }; + + snapshot.prizes.push(prize); + return snapshotPrizeResponse(snapshot, prize); + } + + const syncedPrizes = syncPrizeGivenCache(snapshot.prizes, snapshot.prizeAwards); + const prize = syncedPrizes.find((entry) => entry.id === command.prizeId); + + if (!prize) { + throw new UnknownPrizeError(command.prizeId); + } + + const initialUnits = + command.initialUnits === undefined + ? prize.initialUnits + : Math.max(command.initialUnits, getPrizeGiven(snapshot.prizeAwards, prize.id)); + + snapshot.prizes = snapshot.prizes.map((entry) => + entry.id === prize.id + ? { + ...entry, + given: getPrizeGiven(snapshot.prizeAwards, prize.id), + initialUnits, + kind: command.prizeKind ?? prize.kind, + title: command.title ?? prize.title, + } + : entry, + ); + + return snapshotPrizeResponse( + snapshot, + snapshot.prizes.find((entry) => entry.id === prize.id), + ); +} + +function applyAwardPrize(snapshot: StoreData, command: AwardPrizeCommand) { + const syncedPrizes = syncPrizeGivenCache(snapshot.prizes, snapshot.prizeAwards); + const prize = syncedPrizes.find((entry) => entry.id === command.prizeId); + + if (!prize) { + throw new UnknownPrizeError(command.prizeId); + } + + if (command.source === 'passportCompletion') { + const existingAward = snapshot.prizeAwards.find( + (award) => award.kidId === command.kidId && award.source === 'passportCompletion', + ); + + if (existingAward) { + return snapshot.prizeAwards.filter((award) => award.kidId === command.kidId); + } + } + + if (getPrizeRemaining(prize) <= 0) { + throw new PrizeOutOfStockError(command.prizeId); + } + + snapshot.prizeAwards.push({ + awardedAt: command.awardedAt, + id: command.awardId, + kidId: command.kidId, + prizeId: prize.id, + ...(command.source ? { source: command.source } : {}), + }); + + return snapshot.prizeAwards.filter((award) => award.kidId === command.kidId); +} + +type SnapshotUpdater = ( + mutator: (snapshot: StoreData) => T | Promise, + changedFiles: readonly StoreFile[], +) => Promise; + +type KidScopedUpdater = ( + kidId: string, + mutator: (snapshot: StoreData) => T | Promise, +) => Promise; + +export async function runRegisterKidCommand( + command: RegisterKidCommand, + updateSnapshot: SnapshotUpdater, +) { + for (let attempt = 1; attempt <= maxKidRegistrationAttempts; attempt += 1) { + try { + return await updateSnapshot( + (snapshot) => applyRegisterKid(snapshot, command), + ['kids', 'passportActivitiesByKid'], + ); + } catch (error) { + if ( + error instanceof StaleKidIdReadError && + attempt < maxKidRegistrationAttempts + ) { + await delay(kidRegistrationRetryDelayMs); + continue; + } + + if (error instanceof StaleKidIdReadError) { + throw new KidIdAllocationError(); + } + + throw error; + } + } + + throw new KidIdAllocationError(); +} + +export function runCompletePassportActivityCommand( + command: CompletePassportActivityCommand, + updatePassportForKid: KidScopedUpdater, +) { + return updatePassportForKid(command.kidId, (snapshot) => + applyCompletePassportActivity(snapshot, command), + ); +} + +export function runSavePrizeCommand( + command: SavePrizeCommand, + updateSnapshot: SnapshotUpdater, +) { + return updateSnapshot((snapshot) => applySavePrize(snapshot, command), ['prizes']); +} + +export function runAwardPrizeCommand( + command: AwardPrizeCommand, + updatePrizeAwardsForKid: KidScopedUpdater, +) { + return updatePrizeAwardsForKid(command.kidId, (snapshot) => + applyAwardPrize(snapshot, command), + ); +} + +export function runRestoreWritableDataCommand( + data: WritableStoreData, + updateSnapshot: SnapshotUpdater, +) { + return updateSnapshot( + (snapshot) => { + snapshot.passportActivitiesByKid = data.passportActivitiesByKid; + snapshot.prizeAwards = data.prizeAwards; + snapshot.prizes = syncPrizeGivenCache(data.prizes, data.prizeAwards); + + return snapshot; + }, + ['passportActivitiesByKid', 'prizeAwards', 'prizes'], + ); +} + export function createFileStore(): StoreAdapter { let seedPromise: Promise | undefined; let writeQueue: Promise = Promise.resolve(); @@ -160,7 +524,13 @@ export function createFileStore(): StoreAdapter { } return { + awardPrize: (command) => runAwardPrizeCommand(command, updatePrizeAwardsForKid), + completePassportActivity: (command) => + runCompletePassportActivityCommand(command, updatePassportForKid), readSnapshot, + registerKid: (command) => runRegisterKidCommand(command, updateSnapshot), + restoreWritableData: (data) => runRestoreWritableDataCommand(data, updateSnapshot), + savePrize: (command) => runSavePrizeCommand(command, updateSnapshot), updatePassportForKid, updatePrizeAwardsForKid, updateSnapshot, @@ -177,6 +547,28 @@ export async function readSnapshot() { return activeStore.readSnapshot(); } +export async function registerKid(command: RegisterKidCommand) { + return activeStore.registerKid(command); +} + +export async function completePassportActivity( + command: CompletePassportActivityCommand, +) { + return activeStore.completePassportActivity(command); +} + +export async function savePrize(command: SavePrizeCommand) { + return activeStore.savePrize(command); +} + +export async function awardPrize(command: AwardPrizeCommand) { + return activeStore.awardPrize(command); +} + +export async function restoreWritableData(data: WritableStoreData) { + return activeStore.restoreWritableData(data); +} + export async function updateSnapshot( mutator: (snapshot: StoreData) => T | Promise, changedFiles: readonly StoreFile[],