225 lines
10 KiB
TypeScript
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};
|
|
});
|
|
},
|
|
};
|