/** * Zod schemas for the dispatch-priority write and read paths. * * These replace hand-rolled coercion and validation. The rules that matter are stated once, * declaratively, instead of being spread across bespoke helpers. * * **Domain assumption.** A `sequenceNumber` is an ERP routing step and a `priority` is an * ordinal rank. Routing steps are small numbers, conventionally multiples of ten so steps * can be inserted between them, with at most a decimal place or two. The bounds below * encode that as sanity checks with plain messages. They are not what correctness rests * on: MM's columns are float32 (`real`), and `dispatch-priorities.ts` compares sequence * numbers and ranks *in float32*, the way MM does, so the SDK and the database agree on * equality by construction whatever the magnitude. */ import { z } from "zod"; /** * Upper bound for a routing step: far above any real one. Sanity, not correctness — the * identity key already compares sequence numbers as MM stores them. */ export const MAX_SEQUENCE_NUMBER = 10_000_000; /** * Smallest nonzero routing step. Generous — a thousandth of a step is finer than any * routing scheme uses — and it makes the bound symmetric: without it a value far below the * bottom of the column's range would store as `0` and name a different operation. */ export const MIN_NONZERO_SEQUENCE_NUMBER = 0.001; /** Decimal numeric text, with optional sign, fraction and exponent — not hex, binary or octal. */ const DECIMAL_TEXT = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/; /** * A number given as a number or as decimal text; anything else (boolean, object, blank, NaN, * `"0x10"`) fails. The decimal check matters because `Number("0x10")` is 16 and * `Number("0b11")` is 3 — a validator whose job is to refuse garbage must not read them as * routing steps. */ const numberLike = z .union( [ z.number(), // Its own message: zod reports a branch that parsed but failed a check directly, // bypassing the union's errorMap, so without this the caller would see "Invalid". z .string() .trim() .regex(DECIMAL_TEXT, { message: "must be a finite number" }), ], { errorMap: () => ({ message: "must be a finite number" }), } ) .transform((value) => typeof value === "number" ? value : value === "" ? Number.NaN : Number(value) ) .refine(Number.isFinite, { message: "must be a finite number" }); /** * A number **as MM reports it**, for the read path only. Postgres renders a `real` that holds * `Infinity`, `-Infinity` or `NaN` as that text, and a row another writer (or an older SDK) * stored that way must still read back, or one poisoned row would take down the fetch that * exists to find it. Still refuses non-numeric junk such as booleans. */ const numberAsStored = z .union( [ z.number(), z .string() .trim() .regex( /^(?:[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|[+-]?Infinity|NaN)$/, { message: "must be a number", } ), ], { errorMap: () => ({ message: "must be a number" }) } ) .transform((value) => (typeof value === "number" ? value : Number(value))); /** Identity text (`lot`, `split`, `sub`): strings and numbers both allowed, absent means `""`. */ const identityText = z .union([z.string(), z.number().finite()], { errorMap: () => ({ message: "must be text or a number" }), }) .nullish() .transform((value) => value === null || value === undefined ? "" : String(value).trim() ); /** A required, non-blank identifier. */ const requiredId = z .union([z.string(), z.number().finite()], { errorMap: () => ({ message: "must be text or a number" }), }) .transform((value) => String(value).trim()) .refine((value) => value !== "", { message: "is required" }); const sequenceNumber = numberLike.pipe( z .number() .nonnegative({ message: "cannot be negative" }) .max(MAX_SEQUENCE_NUMBER, { message: `is larger than any routing step (max ${MAX_SEQUENCE_NUMBER})`, }) // Zero is a real step (it is the column's default); anything between zero and the // minimum is not a step at all, and would not survive the column intact. .refine((value) => value === 0 || value >= MIN_NONZERO_SEQUENCE_NUMBER, { message: `is smaller than any routing step (min ${MIN_NONZERO_SEQUENCE_NUMBER} when nonzero)`, }) ); /** * One row as the caller supplies it, whether hand-built or read back from a cache/JSON row. * `scopeResourceId` may be blank: `replaceDispatchPriorityScope` stamps the scope of the * call, and only rejects a row that names a *different* scope. */ export const dispatchPriorityInputSchema = z.object({ authorityOperationId: requiredId, scopeResourceId: identityText, workOrderId: requiredId, sequenceNumber, lot: identityText, split: identityText, sub: identityText, /** * An ordinal sort key. There is no domain bound to assert — an authority may rank by a * score of its own devising — but it must at least be a value the column can hold. A value * that overflows would store as `Infinity`, sorting to an extreme and breaking reads of the * whole scope. Values too *small* to survive are left alone: they store as `0`, which is * where they already sorted. * * Magnitude still matters for a reason no per-row rule can see: a `real` column stops * separating neighbouring values well before it overflows, so two distinct ranks can * collapse into one. Such rows are still valid: `replaceDispatchPriorityScope` sends a * scope's rows in rank order, so MM's own tie-break on insert order keeps the authority's * ordering, and reports the collapse (`ranksCollapsedByStorage`) as a warning rather than * rejecting the scope. */ priority: numberLike.refine((value) => Number.isFinite(Math.fround(value)), { message: "is too large for MM to store", }), preallocatedMachineResourceId: identityText.transform((value) => value === "" ? null : value ), customFieldsSchemaRef: numberLike .pipe(z.number().int({ message: "must be a whole number" })) .nullish() .transform((value) => value ?? null), customFields: z .record(z.unknown()) .nullish() .transform((value) => value ?? null), }); export type DispatchPriorityInput = z.infer; /** * One row as MM's GraphQL returns it. Hasura renders bigint and float as number or string. * * Looser than the write schema on *domain* rules: reads accept whatever MM holds, including * rows another writer created, so the routing-step bounds are not applied here. * * Stricter in one respect. The text columns are taken **verbatim**, not through the write * path's trimming: Postgres compares this text exactly, so a row another writer stored as * `" 0 "` has to read back as `" 0 "`. Trimming it to `"0"` would make a diff-by-scope * believe it matches a row we would write, and a missing column would read as `""` instead * of failing. Likewise `priority` and `sequenceNumber` read back as whatever number the * column holds, non-finite included; only the surrogate key must be a finite number. */ export const dispatchPriorityRowSchema = z.object({ dispatchPriorityRef: numberLike, companyId: z.string(), authorityOperationId: z .string() .nullish() .transform((value) => value ?? null), scopeResourceId: z.string(), workOrderId: z.string(), sequenceNumber: numberAsStored, lot: z.string(), split: z.string(), sub: z.string(), preallocatedMachineResourceId: z .string() .nullish() .transform((value) => value ?? null), priority: numberAsStored, createdAt: z.string(), updatedAt: z.string(), customFieldsSchemaRef: numberLike .nullish() .transform((value) => value ?? null), customFields: z .record(z.unknown()) .nullish() .transform((value) => value ?? null), }); /** `field is required` / `sequenceNumber must be a finite number`, from the first issue. */ export function describeZodIssue(error: z.ZodError): string { const issue = error.issues[0]; const path = issue.path.join("."); return path === "" ? issue.message : `${path} ${issue.message}`; }