Files
haptika/src/features/watch/watchRepository.ts
T
2026-07-31 16:20:10 +02:00

225 lines
10 KiB
TypeScript

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<void> = Promise.resolve();
const enqueue = <T>(operation: () => Promise<T>): Promise<T> => {
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<WatchMeasurementSummaryV1[]> {
const raw = await readJSON<unknown>(storageKeys.watchMeasurements, []);
if (!Array.isArray(raw)) return [];
const unique = new Map<string, WatchMeasurementSummaryV1>();
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<WatchRegulationSessionV1[]> {
const raw = await readJSON<unknown>(storageKeys.watchRegulationSessions, []);
if (!Array.isArray(raw)) return [];
const unique = new Map<string, WatchRegulationSessionV1>();
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<WatchTombstoneV1[]> {
const raw = await readJSON<unknown>(storageKeys.watchTombstones, []);
if (!Array.isArray(raw)) return [];
const unique = new Map<string, WatchTombstoneV1>();
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<string[]> {
const raw = await readJSON<unknown>(storageKeys.watchProcessedEvents, []);
return Array.isArray(raw) ? raw.filter((item): item is string => typeof item === 'string') : [];
}
async function markProcessed(eventId: string, current: readonly string[]): Promise<void> {
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<WatchImportStatus> {
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<WatchImportStatus> {
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<WatchImportStatus> {
if (typeof payload !== 'object' || payload === null) return 'invalid';
const value = payload as Record<string, unknown>;
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<string, unknown>;
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<WatchImportStatus> {
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<WatchMeasurementSummaryV1[]> {
await mutationQueue;
const [items, tombstones] = await Promise.all([readMeasurements(), readTombstones()]);
return items.filter(item => tombstoneFor(tombstones, 'measurement', item.id) === undefined);
},
async getRegulationSessions(): Promise<WatchRegulationSessionV1[]> {
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<WatchTombstoneV1> {
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<WatchTombstoneV1[]> {
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<WatchImportResult> {
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};
});
},
};