import {readJSON, storageKeys, writeJSON} from '../ppg/storage/database'; import type { WatchMeasurementFeelingV1, WatchMeasurementSummaryV1, WatchRegulationSessionV1, WatchSyncEnvelopeV1, WatchTombstoneV1, } from './watchTypes'; import { parseWatchEnvelope, parseWatchMeasurement, parseWatchRegulationSession, parseWatchTombstone, } from './watchValidation'; export type WatchImportStatus = 'applied' | 'duplicate' | 'stale' | 'invalid' | 'ignored'; export interface WatchImportResult { eventId: string | null; status: WatchImportStatus; } let mutationQueue: Promise = Promise.resolve(); const enqueue = (operation: () => Promise): Promise => { const pending = mutationQueue.then(operation); mutationQueue = pending.then(() => undefined, () => undefined); return pending; }; const updatedAt = (value: {updatedAt: string}): number => Date.parse(value.updatedAt); async function readMeasurements(): Promise { const raw = await readJSON(storageKeys.watchMeasurements, []); if (!Array.isArray(raw)) return []; const unique = new Map(); raw.forEach(value => { const parsed = parseWatchMeasurement(value); const previous = parsed === null ? undefined : unique.get(parsed.id); if (parsed !== null && (previous === undefined || updatedAt(parsed) > updatedAt(previous))) { unique.set(parsed.id, parsed); } }); return [...unique.values()].sort((a, b) => Date.parse(b.endedAt) - Date.parse(a.endedAt)); } async function readSessions(): Promise { const raw = await readJSON(storageKeys.watchRegulationSessions, []); if (!Array.isArray(raw)) return []; const unique = new Map(); raw.forEach(value => { const parsed = parseWatchRegulationSession(value); const previous = parsed === null ? undefined : unique.get(parsed.id); if (parsed !== null && (previous === undefined || updatedAt(parsed) > updatedAt(previous))) { unique.set(parsed.id, parsed); } }); return [...unique.values()].sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt)); } async function readTombstones(): Promise { const raw = await readJSON(storageKeys.watchTombstones, []); if (!Array.isArray(raw)) return []; const unique = new Map(); raw.forEach(value => { const parsed = parseWatchTombstone(value); const previous = parsed === null ? undefined : unique.get(`${parsed.entityKind}:${parsed.entityId}`); if (parsed !== null && (previous === undefined || updatedAt(parsed) >= updatedAt(previous))) { unique.set(`${parsed.entityKind}:${parsed.entityId}`, parsed); } }); return [...unique.values()]; } async function processedEvents(): Promise { const raw = await readJSON(storageKeys.watchProcessedEvents, []); return Array.isArray(raw) ? raw.filter((item): item is string => typeof item === 'string') : []; } async function markProcessed(eventId: string, current: readonly string[]): Promise { await writeJSON(storageKeys.watchProcessedEvents, [eventId, ...current.filter(id => id !== eventId)].slice(0, 1_000)); } function tombstoneFor( tombstones: readonly WatchTombstoneV1[], entityKind: WatchTombstoneV1['entityKind'], entityId: string, ): WatchTombstoneV1 | undefined { return tombstones.find(item => item.entityKind === entityKind && item.entityId === entityId); } async function upsertMeasurement( candidate: WatchMeasurementSummaryV1, tombstones: readonly WatchTombstoneV1[], ): Promise { const tombstone = tombstoneFor(tombstones, 'measurement', candidate.id); if (tombstone !== undefined && updatedAt(tombstone) >= updatedAt(candidate)) return 'stale'; const current = await readMeasurements(); const previous = current.find(item => item.id === candidate.id); if (previous !== undefined && updatedAt(previous) >= updatedAt(candidate)) return 'stale'; await writeJSON(storageKeys.watchMeasurements, [candidate, ...current.filter(item => item.id !== candidate.id)]); return 'applied'; } async function upsertSession( candidate: WatchRegulationSessionV1, tombstones: readonly WatchTombstoneV1[], ): Promise { const tombstone = tombstoneFor(tombstones, 'regulationSession', candidate.id); if (tombstone !== undefined && updatedAt(tombstone) >= updatedAt(candidate)) return 'stale'; const current = await readSessions(); const previous = current.find(item => item.id === candidate.id); if (previous !== undefined && updatedAt(previous) >= updatedAt(candidate)) return 'stale'; await writeJSON(storageKeys.watchRegulationSessions, [candidate, ...current.filter(item => item.id !== candidate.id)]); return 'applied'; } async function applyFeeling( payload: unknown, tombstones: readonly WatchTombstoneV1[], ): Promise { if (typeof payload !== 'object' || payload === null) return 'invalid'; const value = payload as Record; if (typeof value.measurementId !== 'string' || typeof value.updatedAt !== 'string' || !Number.isFinite(Date.parse(value.updatedAt)) || typeof value.feeling !== 'object' || value.feeling === null) return 'invalid'; const feeling = value.feeling as Record; if (![1, 2, 3, 4, 5].includes(Number(feeling.score)) || typeof feeling.recordedAt !== 'string' || !Number.isFinite(Date.parse(feeling.recordedAt))) return 'invalid'; const current = await readMeasurements(); const measurement = current.find(item => item.id === value.measurementId); if (measurement === undefined || tombstoneFor(tombstones, 'measurement', measurement.id) !== undefined) return 'ignored'; if (updatedAt(measurement) > Date.parse(value.updatedAt)) return 'stale'; const next: WatchMeasurementSummaryV1 = { ...measurement, feeling: feeling as unknown as WatchMeasurementFeelingV1, updatedAt: value.updatedAt, }; await writeJSON(storageKeys.watchMeasurements, [next, ...current.filter(item => item.id !== next.id)]); return 'applied'; } async function applyTombstone(candidate: WatchTombstoneV1): Promise { const tombstones = await readTombstones(); const previous = tombstoneFor(tombstones, candidate.entityKind, candidate.entityId); if (previous !== undefined && updatedAt(previous) > updatedAt(candidate)) return 'stale'; const next = [candidate, ...tombstones.filter(item => item.entityKind !== candidate.entityKind || item.entityId !== candidate.entityId)]; await writeJSON(storageKeys.watchTombstones, next); if (candidate.entityKind === 'measurement') { const measurements = await readMeasurements(); await writeJSON(storageKeys.watchMeasurements, measurements.filter(item => item.id !== candidate.entityId)); } else { const sessions = await readSessions(); await writeJSON(storageKeys.watchRegulationSessions, sessions.filter(item => item.id !== candidate.entityId)); } return 'applied'; } export const watchRepository = { async getMeasurements(): Promise { await mutationQueue; const [items, tombstones] = await Promise.all([readMeasurements(), readTombstones()]); return items.filter(item => tombstoneFor(tombstones, 'measurement', item.id) === undefined); }, async getRegulationSessions(): Promise { await mutationQueue; const [items, tombstones] = await Promise.all([readSessions(), readTombstones()]); return items.filter(item => tombstoneFor(tombstones, 'regulationSession', item.id) === undefined); }, async deleteMeasurement(id: string, deletedAt = new Date().toISOString()): Promise { return enqueue(async () => { const tombstone: WatchTombstoneV1 = {schemaVersion: 1, entityId: id, entityKind: 'measurement', deletedAt, updatedAt: deletedAt}; await applyTombstone(tombstone); return tombstone; }); }, async clearAll(deletedAt = new Date().toISOString()): Promise { return enqueue(async () => { const [measurements, sessions] = await Promise.all([readMeasurements(), readSessions()]); const created = [ ...measurements.map(item => ({schemaVersion: 1 as const, entityId: item.id, entityKind: 'measurement' as const, deletedAt, updatedAt: deletedAt})), ...sessions.map(item => ({schemaVersion: 1 as const, entityId: item.id, entityKind: 'regulationSession' as const, deletedAt, updatedAt: deletedAt})), ]; await writeJSON(storageKeys.watchTombstones, created); await Promise.all([writeJSON(storageKeys.watchMeasurements, []), writeJSON(storageKeys.watchRegulationSessions, [])]); return created; }); }, async importEnvelope(input: WatchSyncEnvelopeV1 | unknown): Promise { return enqueue(async () => { const envelope = parseWatchEnvelope(input); if (envelope === null) return {eventId: null, status: 'invalid'}; const processed = await processedEvents(); if (processed.includes(envelope.eventId)) return {eventId: envelope.eventId, status: 'duplicate'}; const tombstones = await readTombstones(); let status: WatchImportStatus; switch (envelope.kind) { case 'measurementUpsert': { const measurement = parseWatchMeasurement(envelope.payload); status = measurement === null ? 'invalid' : await upsertMeasurement(measurement, tombstones); break; } case 'regulationSessionUpsert': { const session = parseWatchRegulationSession(envelope.payload); status = session === null ? 'invalid' : await upsertSession(session, tombstones); break; } case 'feelingUpsert': status = await applyFeeling(envelope.payload, tombstones); break; case 'tombstone': { const tombstone = parseWatchTombstone(envelope.payload); status = tombstone === null ? 'invalid' : await applyTombstone(tombstone); break; } default: status = 'ignored'; } // Invalid payloads are intentionally not acknowledged as processed so a fixed retry can win. if (status !== 'invalid') await markProcessed(envelope.eventId, processed); return {eventId: envelope.eventId, status}; }); }, };