Files
MIS-Contro-Tower/lib/contracts/v1.ts

382 lines
12 KiB
TypeScript

// /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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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<typeof SnapshotV1>;
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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 };
}