/** * Dispatch priorities: the MM table behind the GraphQL roots * `erpWorkOrderOperationDispatchPriorities` / `erpWorkOrderOperationDispatchPriority`. * * An ordering authority (a scheduler such as PFM) ranks work order operations within a * **scope** — an ERP resource id for a machine or a machine group. The unit of validity is * the scope: a list is complete and consistent for its scope, so ingest is a **scope-level * replace** (delete every row with that `scopeResourceId`, insert the new list) in one * transaction — never a per-row upsert, which would leave rows behind for operations that * left the list. * * This is the only SDK write that goes through the MM GraphQL proxy instead of erp-api * (`/erp/v1/...`): erp-api has no endpoint for the table, and a Hasura mutation document * runs its delete + insert atomically. Because the write never enters `_sendToAPI`, the * local dedupe cache is not consulted — intentional, since an unchanged scope must still * be re-asserted and a replace that drops rows must never be skipped as a "duplicate". * * Verified against the live schema (2026-09-14): * - Primary key is a surrogate `dispatchPriorityRef` (bigint). * - Unique constraint `work_order_operation_dispatch_priority_uq` covers * (companyId, scopeResourceId, workOrderId, sequenceNumber, lot, split, sub) — MM's * operation identity per scope. `authorityOperationId` is NOT part of it (and is nullable * in MM), so two authority rows resolving to the same MM operation in one scope would * fail the whole replace; {@link dedupeDispatchPriorityRows} keeps the better-ranked one * (the earlier one only on an exact tie). * - `sequenceNumber` and `priority` are float32 (`real`) columns, and so is the * `sequence_number` they join to on `erpWorkOrderOperations`. The SDK therefore compares * these values *in float32*, the way MM does: the identity key holds the sequence number * as MM will store it, and the rank check compares priorities as MM will store them. That * is what makes the double-vs-float32 seam disappear rather than move. The bounds in * `dispatch-priority-schemas.ts` are domain sanity on top, not what correctness rests on. * - `lot`, `split`, `sub` are NOT NULL with default `''`. The `workOrderOperation` * relationship joins on workOrderId + sequenceNumber + lot + split + sub, so rows must * carry MM's own values for those columns to resolve. * - No foreign keys: rows whose operation or resource MM does not have are accepted and * their relationships resolve to null (queryable with `unresolvedOnly`). * - `companyId` is a session preset; it is not part of the insert input. * * **Single-writer assumption.** These writes assume MachineMetrics' deployment model: one * connector writes a given company's data, single-threaded and in sequence. Under that * assumption two replacements of the same scope never overlap, and the atomic delete + * insert is sufficient on its own. * * The assumption is load-bearing rather than incidental, so it is worth stating. Nothing in * the database serializes concurrent replacements of one scope: both transactions can * delete, then insert disjoint operation identities, leaving the scope holding the union of * two lists that were each meant to be complete. Measured against the live tenant, three * concurrent writers on one scope merged in 6 of 8 attempts, and the failure is silent — * every mutation reports success. Anything that breaks the assumption therefore corrupts a * scope quietly: a second connector for the company, a sweep that overlaps its predecessor, * or a manual tool run alongside a cycle. * * There is no defence available at this layer if it is ever broken — all 98 mutation roots * on the proxy are plain CRUD, with no advisory lock or serializing action — so concurrent * writers would need an MM-side endpoint or backend function that serializes the replace. */ import { MMReceiveDispatchPriority } from "./types/receive-types.js"; import type { MMSendDispatchPriority } from "./types/send-types.js"; import { collectNonBlankStrings, type GraphqlOperation, } from "./graphql-helpers.js"; import { describeZodIssue, dispatchPriorityInputSchema, dispatchPriorityRowSchema, } from "./dispatch-priority-schemas.js"; export const DISPATCH_PRIORITIES_QUERY_ROOT = "erpWorkOrderOperationDispatchPriorities"; export const DISPATCH_PRIORITIES_INSERT_ROOT = "insertErpWorkOrderOperationDispatchPriorities"; export const DISPATCH_PRIORITIES_DELETE_ROOT = "deleteErpWorkOrderOperationDispatchPriorities"; export const DISPATCH_PRIORITIES_INSERT_INPUT_TYPE = "erpWorkOrderOperationDispatchPriorities_insert_input"; export const DISPATCH_PRIORITIES_BOOL_EXP_TYPE = "erpWorkOrderOperationDispatchPriorities_bool_exp"; /** Field list for the dispatch-priorities GraphQL reads. */ export const DISPATCH_PRIORITY_GRAPHQL_FIELDS = "dispatchPriorityRef companyId authorityOperationId scopeResourceId workOrderId sequenceNumber lot split sub preallocatedMachineResourceId priority createdAt updatedAt customFieldsSchemaRef customFields"; //#region identity /** The columns MM's unique constraint compares (besides the session `companyId`). */ export interface DispatchPriorityOperationIdentity { workOrderId: string; sequenceNumber: string | number; lot: string; split: string; sub: string; } /** * Canonical **display** form of a sequence number: `"010"`, `10` and `"10"` all render as * `"10"`. Finite values are rendered through `Number`; anything else is kept as trimmed * text so a malformed value still produces a stable (and visibly odd) string rather than * throwing here. * * This is what {@link MMReceiveDispatchPriority.sequenceNumber} exposes, matching * {@link MMReceiveWorkOrderOperation.sequenceNumber}, and it is what the identity key uses. */ export function normalizeDispatchSequenceNumber( value: string | number | null | undefined ): string { if (value === null || value === undefined) { return ""; } const text = String(value).trim(); if (text === "") { return ""; } const parsed = Number(text); return Number.isFinite(parsed) ? String(parsed) : text; } /** * Key over the unique-constraint columns, as MM will see them after * `MMSendDispatchPriority.toGraphqlInsertInput()` trims the text columns. * * Parts are `|`-joined with `\` and `|` escaped in each part, so the encoding is * unambiguous: a literal `|` inside a work order id or lot cannot shift the part * boundaries and make two different operations share a key (which would silently drop a * valid row from a scope's list). * * The sequence number is keyed **as MM stores it**: `String(Math.fround(x))`. MM compares * this column as float32, so comparing it any other way lets two rows that MM treats as one * key apart, and the insert then fails the unique constraint instead of the collision being * reported as a duplicate. A double's string form is unique per value, so two rows share a * key exactly when MM would store the same number — no more and no less. The part is exact * rather than pretty (`10.1` keys as `10.100000381469727`); treat the key as opaque and use * {@link normalizeDispatchSequenceNumber} for anything a person reads. * * Text parts are trimmed, because that is the form the SDK writes. A row MM holds with * untrimmed text — which only another writer could have produced — therefore keys as its * trimmed form. The SDK's own replace deletes every row of the scope regardless, so such * a row does not survive a cycle either way. * * The key omits `scopeResourceId` on purpose: it identifies an operation *within* a scope, * and {@link dedupeDispatchPriorityRows} is defined per scope. Do not feed it a list that * spans scopes. * * Deliberately **not** `normalizeWorkOrderOperationKey` (the reconciliation key): that one * upper-cases, which is right for matching ERP keys against MM rows but wrong here — MM's * unique index compares text exactly, so two rows differing only in case are two rows to * MM, and folding them would drop a row MM would have accepted. */ export function dispatchPriorityIdentityKey( row: DispatchPriorityOperationIdentity ): string { return [ encodeKeyPart(trimText(row.workOrderId)), encodeKeyPart(sequenceNumberAsStored(row.sequenceNumber)), encodeKeyPart(trimText(row.lot)), encodeKeyPart(trimText(row.split)), encodeKeyPart(trimText(row.sub)), ].join("|"); } /** * The sequence number as MM's `real` column will hold it, rendered exactly. Non-numeric text * passes through so a malformed value still yields a stable key rather than throwing here. */ function sequenceNumberAsStored( value: string | number | null | undefined ): string { const normalized = normalizeDispatchSequenceNumber(value); const parsed = Number(normalized); return normalized !== "" && Number.isFinite(parsed) ? String(Math.fround(parsed)) : normalized; } /** Trimmed text for a key column; null/undefined become `""` (never the string "null"). */ function trimText(value: unknown): string { return value === null || value === undefined ? "" : String(value).trim(); } /** Escapes the separator (and its own escape character) so joined parts stay unambiguous. */ function encodeKeyPart(value: string): string { return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); } export interface DispatchPriorityDroppedDuplicate { /** The row that was dropped (its list position is `index`). */ row: MMSendDispatchPriority; index: number; /** The row kept instead: better-ranked, or the earlier of the two on a tie. */ duplicateOf: MMSendDispatchPriority; identityKey: string; } export interface DedupeDispatchPriorityRowsResult { kept: MMSendDispatchPriority[]; dropped: DispatchPriorityDroppedDuplicate[]; } /** * Drops rows that would violate `work_order_operation_dispatch_priority_uq` within one scope. * * Selection is **by rank, not position**: of two rows naming the same MM operation, the one * with the lower `priority` survives wherever it sits in the list — compared as MM will * store it, so two ranks MM cannot tell apart are a tie here too. Nothing requires the * caller to pass rows in rank order — this helper does not reorder them, and MM re-sorts by * `priority` on read — so position carries no meaning for choosing between different ranks; * a caller that builds its list by iterating a map would otherwise have an arbitrary row * survive. * * Position is used for exactly one thing: when two ranks are **equal**, the earlier row is * kept, so the result is deterministic rather than depending on map iteration order. * * `kept` keeps the caller's ordering; sorting is the write path's job, which puts the * survivors in rank order with {@link sortByRank} after selection is over. The caller * decides how loudly to report `dropped`. */ export function dedupeDispatchPriorityRows( rows: readonly MMSendDispatchPriority[] ): DedupeDispatchPriorityRowsResult { const keys = rows.map(dispatchPriorityIdentityKey); // Number() first: a caller outside TypeScript can hand us "10", and "10" < "9" as text. // Math.fround: compare ranks as MM will store them. NaN never displaces anything. const storedRank = (row: MMSendDispatchPriority): number => Math.fround(Number(row.priority)); const winnerIndex = new Map(); rows.forEach((row, index) => { const current = winnerIndex.get(keys[index]); if (current === undefined || storedRank(row) < storedRank(rows[current])) { winnerIndex.set(keys[index], index); } }); const kept: MMSendDispatchPriority[] = []; const dropped: DispatchPriorityDroppedDuplicate[] = []; rows.forEach((row, index) => { // Every key was recorded in the first pass, so a winner always exists. const winner = winnerIndex.get(keys[index]) as number; if (winner === index) { kept.push(row); } else { dropped.push({ row, index, duplicateOf: rows[winner], identityKey: keys[index], }); } }); return { kept, dropped }; } //#endregion identity //#region validation function describeRow(row: MMSendDispatchPriority, index: number): string { // A sparse or lookup-built list can hold undefined; the message must still name the row. if (typeof row !== "object" || row === null) { return `row ${index} (${row === null ? "null" : typeof row})`; } return `row ${index} (authorityOperationId ${JSON.stringify(row.authorityOperationId)}, workOrderId ${JSON.stringify(row.workOrderId)})`; } /** * Fails fast — before any network call — on rows MM would store with a meaning the caller * did not intend. The field rules live in `dispatch-priority-schemas.ts`; this adds the one * cross-field rule the schema cannot see, that a row must not name a different scope. * * Nothing in the table enforces `authorityOperationId` (nullable, non-unique), so the SDK * requires it: a dispatch row without the authority's operation id cannot be traced back to * the ordering it came from. * * @throws {Error} naming the offending row and field */ export function assertValidDispatchPriorityRows( scopeResourceId: string, rows: readonly MMSendDispatchPriority[] ): void { rows.forEach((row, index) => { const parsed = dispatchPriorityInputSchema.safeParse(row); if (!parsed.success) { throw new Error( `replaceDispatchPriorityScope: ${describeRow(row, index)} ${describeZodIssue(parsed.error)}` ); } const rowScope = parsed.data.scopeResourceId; if (rowScope !== "" && rowScope !== scopeResourceId) { throw new Error( `replaceDispatchPriorityScope: ${describeRow(row, index)} belongs to scope ${JSON.stringify(rowScope)}, not ${JSON.stringify(scopeResourceId)}` ); } }); } /** * The rows in the order they should be **sent**: by rank, then by the caller's position. * * Hasura assigns `dispatchPriorityRef` in insert order and every SDK read tie-breaks on it, * so sending in rank order lets the authority's ordering survive even where two of its ranks * land on one value in the float32 column: MM cannot separate them, but it returns them in * the order they arrived. * * Sorting on the **raw** rank is what makes that work. `Math.fround` is monotonic, so the * raw values already order the stored ones correctly, and they still carry the distinction * the column is about to lose — rounding before comparing would throw that away and let the * caller's array order decide, which is the thing this function exists to stop mattering. */ export function sortByRank( rows: readonly MMSendDispatchPriority[] ): MMSendDispatchPriority[] { return rows .map((row, index) => ({ row, index })) .sort( (a, b) => Number(a.row.priority) - Number(b.row.priority) || a.index - b.index ) .map((entry) => entry.row); } /** * Ranks that MM will store as one value although the authority made them different. * * `priority` is a `real` column, which stops separating neighbouring values long before it * overflows: at around 1.7e12 — the magnitude of epoch milliseconds — its granularity is * about 131072, so ranks one millisecond apart collapse into one. Within MM the tie is then * broken by `dispatchPriorityRef`, which is why {@link sortByRank} sends rows in rank * order: the authority's order still comes back out. This reports the collapse so an * operator can see that ranks at this magnitude are not distinct to MM. It is not fatal — * failing the write would leave the scope's *previous* list standing indefinitely, a worse * outcome than a tie MM breaks in the right order. * * Equal ranks are not collisions; this fires only when ranks that were different would stop * being different. Run it on the rows that will actually be sent, after deduplication. * * One entry per collapse, each naming just the two ranks and the stored value: a scope * ranked by epoch milliseconds collapses on nearly every row, so callers should report the * count and log a capped sample rather than a line apiece. */ export function ranksCollapsedByStorage( rows: readonly MMSendDispatchPriority[] ): string[] { const byStoredValue = new Map(); const collisions: string[] = []; for (const row of rows) { const priority = Number(row.priority); const stored = Math.fround(priority); const existing = byStoredValue.get(stored); if (existing !== undefined && existing !== priority) { collisions.push( `priorities ${existing} and ${priority} are different ranks but MM stores both as ${stored}` ); } byStoredValue.set(stored, priority); } return collisions; } //#endregion validation //#region query builders /** * One document, one transaction: delete the scope's rows, then insert the new list. * Hasura runs a mutation document's top-level fields sequentially inside a single * transaction and rolls the whole document back if any field fails (verified: a * uniqueness violation in the insert left the previously deleted rows in place). * An empty `objects` list is accepted and clears the scope (verified: 200, 0 rows). */ export function buildReplaceDispatchPriorityScopeMutation( scopeResourceId: string, objects: Record[] ): GraphqlOperation { const operationName = "replaceDispatchPriorityScope"; return { operationName, query: `mutation ${operationName}($scope: String!, $objects: [${DISPATCH_PRIORITIES_INSERT_INPUT_TYPE}!]!) { ` + `deleted: ${DISPATCH_PRIORITIES_DELETE_ROOT}(where: { scopeResourceId: { _eq: $scope } }) { affected_rows } ` + `inserted: ${DISPATCH_PRIORITIES_INSERT_ROOT}(objects: $objects) { affected_rows } ` + `}`, variables: { scope: scopeResourceId, objects }, }; } export function buildRemoveDispatchPriorityScopeMutation( scopeResourceId: string ): GraphqlOperation { const operationName = "removeDispatchPriorityScope"; return { operationName, query: `mutation ${operationName}($scope: String!) { ` + `deleted: ${DISPATCH_PRIORITIES_DELETE_ROOT}(where: { scopeResourceId: { _eq: $scope } }) { affected_rows } ` + `}`, variables: { scope: scopeResourceId }, }; } export interface FetchDispatchPrioritiesFilter { /** Exact `scopeResourceId` match. Combined with `scopeResourceIds` as one `_in` list. */ scopeResourceId?: string; scopeResourceIds?: readonly string[]; /** Exact `workOrderId` match. Combined with `workOrderIds` as one `_in` list. */ workOrderId?: string; workOrderIds?: readonly string[]; /** * Only rows whose `workOrderOperation` relationship is null — i.e. rows that resolve to * no MM operation on (workOrderId, sequenceNumber, lot, split, sub). Validation tooling. */ unresolvedOnly?: boolean; /** Rows per GraphQL page (default {@link DEFAULT_GRAPHQL_PAGE_SIZE}). */ pageSize?: number; } /** * Hasura `_bool_exp` for {@link FetchDispatchPrioritiesFilter}; `{}` matches every row of * the company. Returned as a variable value, never interpolated, so ids need no escaping. * Returns `null` when a filter was given (a value, a list, or `null` from an untyped * caller) but nothing survives trimming — the caller must short-circuit to "no rows" * rather than query, because an empty `_in` would otherwise be dropped and match everything. */ export function buildDispatchPrioritiesWhere( filter: FetchDispatchPrioritiesFilter ): Record | null { const where: Record = {}; if ( !applyInFilter( where, "scopeResourceId", filter.scopeResourceId, filter.scopeResourceIds ) ) return null; if ( !applyInFilter( where, "workOrderId", filter.workOrderId, filter.workOrderIds ) ) return null; if (filter.unresolvedOnly) where._not = { workOrderOperation: {} }; return where; } /** * Adds `column: { _in: [...] }` when the filter names values. Returns `false` when values * were given (even `null`) but none survive trimming — the caller must short-circuit to * "no rows" rather than let an empty `_in` match everything. */ function applyInFilter( where: Record, column: string, single: string | undefined, many: readonly string[] | undefined ): boolean { const given = single !== undefined || many !== undefined; const values = collectNonBlankStrings(single, many); if (given && values.length === 0) return false; if (values.length > 0) where[column] = { _in: values }; return true; } export function buildDispatchPrioritiesPageQuery( where: Record, pageSize: number, offset: number ): GraphqlOperation { const operationName = "getErpDispatchPrioritiesPage"; return { operationName, query: `query ${operationName}($where: ${DISPATCH_PRIORITIES_BOOL_EXP_TYPE}!, $limit: Int!, $offset: Int!) { ` + `rows: ${DISPATCH_PRIORITIES_QUERY_ROOT}(where: $where, limit: $limit, offset: $offset, ` + `order_by: [{ scopeResourceId: asc }, { priority: asc }, { dispatchPriorityRef: asc }]) { ` + `${DISPATCH_PRIORITY_GRAPHQL_FIELDS} } }`, variables: { where, limit: pageSize, offset }, }; } /** * The distinct scopes currently holding rows, one page. Used to find scopes an authority has * stopped issuing, without reading every row of every scope. * * `order_by` must lead with the `distinct_on` column: Hasura rejects the query outright * otherwise ("distinct_on columns must match initial order_by columns"), so this ordering is * load-bearing rather than cosmetic. */ export function buildDispatchPriorityScopesPageQuery( pageSize: number, offset: number ): GraphqlOperation { const operationName = "getErpDispatchPriorityScopes"; return { operationName, query: `query ${operationName}($limit: Int!, $offset: Int!) { ` + `rows: ${DISPATCH_PRIORITIES_QUERY_ROOT}(distinct_on: scopeResourceId, ` + `order_by: { scopeResourceId: asc }, limit: $limit, offset: $offset) { scopeResourceId } }`, variables: { limit: pageSize, offset }, }; } //#endregion query builders //#region row parsing /** * Maps one GraphQL row to {@link MMReceiveDispatchPriority}. Hasura renders `bigint` as a * string or number depending on configuration, and `Float` as a number or string depending * on the value; the schema accepts either. `sequenceNumber` is exposed as the string form * the other receive models use. * * @throws {Error} naming the field when the row is not the shape MM documents */ export function parseDispatchPriorityRow( raw: unknown ): MMReceiveDispatchPriority { const parsed = dispatchPriorityRowSchema.safeParse(raw); if (!parsed.success) { throw new Error( `${DISPATCH_PRIORITIES_QUERY_ROOT}: ${describeZodIssue(parsed.error)}` ); } const row = parsed.data; return new MMReceiveDispatchPriority({ ...row, sequenceNumber: normalizeDispatchSequenceNumber(row.sequenceNumber), }); } //#endregion row parsing //#region results export interface ReplaceDispatchPriorityScopeResult { scopeResourceId: string; /** Rows MM deleted for the scope before inserting (the scope's previous list). */ deletedRows: number; /** Rows inserted (after duplicate dropping). */ insertedRows: number; /** Rows dropped because another row named the same MM operation and was kept. */ droppedDuplicates: DispatchPriorityDroppedDuplicate[]; } export interface RemoveDispatchPriorityScopeResult { scopeResourceId: string; deletedRows: number; } /** * Reads `affected_rows` from a mutation response field. A field that is missing, null, or * not a number is a malformed response, not "zero rows affected". */ export function readAffectedRows( response: unknown, field: string, operation: string ): number { const data = (response as { data?: Record } | undefined) ?.data; const entry = data?.[field] as { affected_rows?: unknown } | undefined; const affected = entry?.affected_rows; // Only a number or a numeric string counts. `Number(null)` is 0, so null/booleans/"" must // not read as "zero rows affected" — they mean the envelope is not what we expect. const parsed = typeof affected === "number" ? affected : typeof affected === "string" && affected.trim() !== "" ? Number(affected) : Number.NaN; if (!Number.isFinite(parsed)) { throw new Error( `${operation}: malformed GraphQL response — data.${field}.affected_rows is missing or not a number` ); } return parsed; } //#endregion results