// /home/mdares/mis-control-tower/lib/contracts/v1.ts import { z } from "zod"; /** * Phase 0: freeze schema version string now and never change it without bumping. * If you later create v2, make a new file or new constant. */ export const SCHEMA_VERSION = "1.0"; // KPI scale is frozen as 0..100 (you confirmed) const KPI_0_100 = z.number().min(0).max(100); function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; return value as Record; } function unwrapCanonicalEnvelope(raw: unknown) { if (!raw || typeof raw !== "object") return raw; const obj = asRecord(raw); if (!obj) return raw; const payload = asRecord(obj.payload); if (!payload) return raw; const hasMeta = obj.schemaVersion !== undefined || obj.machineId !== undefined || obj.tsMs !== undefined || obj.tsDevice !== undefined || obj.seq !== undefined || obj.type !== undefined; if (!hasMeta) return raw; const tsDevice = typeof obj.tsDevice === "number" ? obj.tsDevice : typeof obj.tsMs === "number" ? obj.tsMs : typeof payload.tsDevice === "number" ? payload.tsDevice : typeof payload.tsMs === "number" ? payload.tsMs : undefined; return { ...payload, schemaVersion: obj.schemaVersion ?? payload.schemaVersion, machineId: obj.machineId ?? payload.machineId, tsDevice: tsDevice ?? payload.tsDevice, seq: obj.seq ?? payload.seq, }; } function normalizeTsDevice(raw: unknown) { if (!raw || typeof raw !== "object") return raw; const obj = asRecord(raw); if (!obj) return raw; if (typeof obj.tsDevice === "number") return obj; if (typeof obj.tsMs === "number") return { ...obj, tsDevice: obj.tsMs }; return obj; } function preprocessPayload(raw: unknown) { return normalizeTsDevice(unwrapCanonicalEnvelope(raw)); } export const SnapshotV1 = z .object({ schemaVersion: z.literal(SCHEMA_VERSION), machineId: z.string().uuid(), tsDevice: z.number().int().nonnegative(), // epoch ms // IMPORTANT: seq should be sent as string if it can ever exceed JS safe int seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]), // current shape (keep it flat so Node-RED changes are minimal) activeWorkOrder: z .object({ id: z.string(), sku: z.string().optional(), target: z.number().optional(), good: z.number().optional(), scrap: z.number().optional(), // add the ones you actually rely on cycleTime: z.number().optional(), cavities: z.number().optional(), progressPercent: z.number().optional(), status: z.string().optional(), lastUpdateIso: z.string().optional(), cycle_count: z.number().optional(), good_parts: z.number().optional(), scrap_parts: z.number().optional(), }) .partial() .passthrough() .optional(), cycle_count: z.number().int().nonnegative().optional(), good_parts: z.number().int().nonnegative().optional(), scrap_parts: z.number().int().nonnegative().optional(), cavities: z.number().int().positive().optional(), cycleTime: z.number().nonnegative().optional(), // theoretical/target cycle time actualCycleTime: z.number().nonnegative().optional(), // optional trackingEnabled: z.boolean().optional(), productionStarted: z.boolean().optional(), kpis: z.object({ oee: KPI_0_100, availability: KPI_0_100, performance: KPI_0_100, quality: KPI_0_100, }), }) .passthrough(); /** * TEMPORARY: Accept your current legacy payload while Node-RED is not sending * schemaVersion/tsDevice/seq yet. Remove this once edge is upgraded. */ const SnapshotLegacy = z .object({ machineId: z.any(), kpis: z.any(), }) .passthrough(); export type SnapshotV1Type = z.infer; export function normalizeSnapshotV1(raw: unknown): { ok: true; value: SnapshotV1Type } | { ok: false; error: string } { const candidate = preprocessPayload(raw); const strict = SnapshotV1.safeParse(candidate); if (strict.success) return { ok: true, value: strict.data }; // Legacy fallback (temporary) const legacy = SnapshotLegacy.safeParse(candidate); if (!legacy.success) { return { ok: false, error: strict.error.message }; } /* const b: any = legacy.data; // Build a "best effort" SnapshotV1 so ingest works during transition. // seq is intentionally set to "0" if missing (so you can still store); // once Node-RED emits real seq, dedupe and ordering become reliable. const migrated: any = { schemaVersion: SCHEMA_VERSION, machineId: String(b.machineId), tsDevice: typeof b.tsDevice === "number" ? b.tsDevice : Date.now(), seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0", ...b, }; const recheck = SnapshotV1.safeParse(migrated); if (!recheck.success) return { ok: false, error: recheck.error.message }; return { ok: true, value: recheck.data }; */ const b = asRecord(legacy.data) ?? {}; const activeWorkOrder = asRecord(b.activeWorkOrder); const kpis = asRecord(b.kpis); const kpiSnapshot = asRecord(b.kpi_snapshot); const legacyCycleTime = b.cycleTime ?? b.cycle_time ?? b.theoretical_cycle_time ?? b.theoreticalCycleTime ?? b.standard_cycle_time ?? kpiSnapshot?.cycleTime ?? kpiSnapshot?.cycle_time ?? undefined; const legacyActualCycleTime = b.actualCycleTime ?? b.actual_cycle_time ?? b.actualCycleSeconds ?? kpiSnapshot?.actualCycleTime ?? kpiSnapshot?.actual_cycle_time ?? undefined; const legacyWorkOrderId = activeWorkOrder?.id ?? b.work_order_id ?? b.workOrderId ?? kpis?.workOrderId ?? kpiSnapshot?.work_order_id ?? undefined; const legacySku = activeWorkOrder?.sku ?? b.sku ?? kpis?.sku ?? kpiSnapshot?.sku ?? undefined; const legacyTarget = activeWorkOrder?.target ?? b.target ?? kpis?.target ?? kpiSnapshot?.target ?? undefined; const legacyGood = activeWorkOrder?.good ?? b.good_parts ?? b.good ?? kpis?.good ?? kpiSnapshot?.good_parts ?? undefined; const legacyScrap = activeWorkOrder?.scrap ?? b.scrap_parts ?? b.scrap ?? kpis?.scrap ?? kpiSnapshot?.scrap_parts ?? undefined; const migrated: Record = { schemaVersion: SCHEMA_VERSION, machineId: String(b.machineId), tsDevice: typeof b.tsDevice === "number" ? b.tsDevice : Date.now(), seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0", // canonical fields (force them) cycleTime: legacyCycleTime != null ? Number(legacyCycleTime) : undefined, actualCycleTime: legacyActualCycleTime != null ? Number(legacyActualCycleTime) : undefined, activeWorkOrder: legacyWorkOrderId ? { id: String(legacyWorkOrderId), sku: legacySku != null ? String(legacySku) : undefined, target: legacyTarget != null ? Number(legacyTarget) : undefined, good: legacyGood != null ? Number(legacyGood) : undefined, scrap: legacyScrap != null ? Number(legacyScrap) : undefined, } : activeWorkOrder, // keep everything else ...b, }; const recheck = SnapshotV1.safeParse(migrated); if (!recheck.success) return { ok: false, error: recheck.error.message }; return { ok: true, value: recheck.data }; } const HeartbeatV1 = z.object({ schemaVersion: z.literal(SCHEMA_VERSION), machineId: z.string().uuid(), tsDevice: z.number().int().nonnegative(), seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]), // legacy shape you currently send: status/message/ip/fwVersion status: z.string().optional(), message: z.string().optional(), ip: z.string().optional(), fwVersion: z.string().optional(), // new canonical boolean online: z.boolean().optional(), }).passthrough(); export function normalizeHeartbeatV1(raw: unknown) { const candidate = preprocessPayload(raw); const strict = HeartbeatV1.safeParse(candidate); if (strict.success) return { ok: true as const, value: strict.data }; // legacy fallback: allow missing meta const legacy = z.object({ machineId: z.any() }).passthrough().safeParse(candidate); if (!legacy.success) return { ok: false as const, error: strict.error.message }; const b = asRecord(legacy.data) ?? {}; const migrated: Record = { schemaVersion: SCHEMA_VERSION, machineId: String(b.machineId), tsDevice: typeof b.tsDevice === "number" ? b.tsDevice : Date.now(), seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0", ...b, }; const recheck = HeartbeatV1.safeParse(migrated); if (!recheck.success) return { ok: false as const, error: recheck.error.message }; return { ok: true as const, value: recheck.data }; } const CycleV1 = z.object({ schemaVersion: z.literal(SCHEMA_VERSION), machineId: z.string().uuid(), tsDevice: z.number().int().nonnegative(), seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]), cycle: z.object({ timestamp: z.number().int().positive(), cycle_count: z.number().int().nonnegative(), actual_cycle_time: z.number(), theoretical_cycle_time: z.number().optional(), work_order_id: z.string(), sku: z.string().optional(), cavities: z.number().optional(), good_delta: z.number().optional(), scrap_total: z.number().optional(), }).passthrough(), }).passthrough(); export function normalizeCycleV1(raw: unknown) { const candidate = preprocessPayload(raw); const strict = CycleV1.safeParse(candidate); if (strict.success) return { ok: true as const, value: strict.data }; // legacy fallback: { machineId, cycle } const legacy = z.object({ machineId: z.any(), cycle: z.any() }).passthrough().safeParse(candidate); if (!legacy.success) return { ok: false as const, error: strict.error.message }; const b = asRecord(legacy.data) ?? {}; const cycle = asRecord(b.cycle); const tsDevice = typeof b.tsDevice === "number" ? b.tsDevice : (cycle?.timestamp as number | undefined) ?? Date.now(); const seq = typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : (cycle?.cycle_count as number | string | undefined) ?? "0"; const migrated: Record = { schemaVersion: SCHEMA_VERSION, machineId: String(b.machineId), tsDevice, seq, ...b, }; const recheck = CycleV1.safeParse(migrated); if (!recheck.success) return { ok: false as const, error: recheck.error.message }; return { ok: true as const, value: recheck.data }; } const EventV1 = z.object({ schemaVersion: z.literal(SCHEMA_VERSION), machineId: z.string().uuid(), tsDevice: z.number().int().nonnegative(), seq: z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]), // IMPORTANT: event must be an object, not an array event: z.object({ anomaly_type: z.string(), severity: z.string(), title: z.string(), description: z.string().optional(), timestamp: z.number().int().positive(), work_order_id: z.string(), cycle_count: z.number().optional(), data: z.any().optional(), kpi_snapshot: z.any().optional(), }).passthrough(), }).passthrough(); export function normalizeEventV1(raw: unknown) { const candidate = preprocessPayload(raw); const strict = EventV1.safeParse(candidate); if (strict.success) return { ok: true as const, value: strict.data }; // legacy fallback: allow missing meta, but STILL reject arrays later const legacy = z.object({ machineId: z.any(), event: z.any() }).passthrough().safeParse(candidate); if (!legacy.success) return { ok: false as const, error: strict.error.message }; const b = asRecord(legacy.data) ?? {}; const event = asRecord(b.event); const tsDevice = typeof b.tsDevice === "number" ? b.tsDevice : (event?.timestamp as number | undefined) ?? Date.now(); const migrated: Record = { schemaVersion: SCHEMA_VERSION, machineId: String(b.machineId), tsDevice, seq: typeof b.seq === "number" || typeof b.seq === "string" ? b.seq : "0", ...b, }; const recheck = EventV1.safeParse(migrated); if (!recheck.success) return { ok: false as const, error: recheck.error.message }; return { ok: true as const, value: recheck.data }; }