chore(source-priority): observable parse failures + frozen resolved blob

Two reconcile fixes on parseSourcePriority(), one observability and one
invariant guard:

  - Emit a wide-event tag when the persisted JSON fails the Zod schema
    so a future schema-tightening regression that silently nukes a
    user's saved ladder surfaces in ops dashboards. annotate() is a
    no-op outside a request context, so static settings-page renders
    and unit tests stay side-effect-free.

  - Deep-freeze the resolved blob. The top-level flat keys share their
    backing arrays with `metricPriority` via the spread, so a caller
    mutating `resolved.metricPriority.weight = […]` would silently
    desync the two views. Freezing every ladder + the containers + the
    resolved root surfaces the mutation at runtime in strict mode.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 16:49:49 +02:00
1 parent 71745b4c63
commit 2bb49ae764
1 file changed
+38 -2
+38 -2
View File
@@ -34,6 +34,7 @@
*/
import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
import {
measurementSourceEnum,
measurementTypeEnum,
@@ -272,7 +273,24 @@ export function parseSourcePriority(raw: unknown): ResolvedSourcePriority {
if (raw == null) return buildResolved({}, {}, {});
const parsed = sourcePrioritySchema.safeParse(raw);
if (!parsed.success) return buildResolved({}, {}, {});
if (!parsed.success) {
// v1.4.25 W10 reconcile (Sr-M2) — emit a wide-event tag so a future
// schema-tightening regression that silently nukes a user's saved
// ladder surfaces in ops dashboards rather than as a "why did my
// priorities reset?" user report. `annotate()` is a no-op outside a
// request context, so the static settings-page render and tests
// remain side-effect-free.
annotate({
meta: {
sourcePriority: {
parse: "failed",
issueCount: parsed.error.issues.length,
firstIssuePath: parsed.error.issues[0]?.path.join(".") ?? null,
},
},
});
return buildResolved({}, {}, {});
}
// Pull the W5e flat per-metric ladder off the top level. Drop the
// two W8c container keys so the rest of the object is just the flat
@@ -296,11 +314,29 @@ function buildResolved(
...flat,
...nested,
};
return {
const resolved: ResolvedSourcePriority = {
...merged,
metricPriority: merged,
deviceTypePriority,
};
// v1.4.25 W10 reconcile (Code-M1) — deep-freeze the resolved blob so
// a caller who mutates `resolved.metricPriority.weight = …` after
// parseSourcePriority() returns trips at runtime instead of silently
// desyncing the two views (the top-level flat keys share their
// backing object with `metricPriority` via the spread above, but
// anything written through the alias post-freeze raises in strict
// mode and surfaces in dev). Freezing the leaves keeps the contract
// immutable end-to-end without copying the source ladders.
for (const ladder of Object.values(merged)) {
Object.freeze(ladder);
}
for (const ladder of Object.values(deviceTypePriority)) {
if (ladder) Object.freeze(ladder);
}
Object.freeze(merged);
Object.freeze(deviceTypePriority);
Object.freeze(resolved);
return resolved;
}
/**