/** * Pure, dependency-free helpers for Route 53 record-key derivation, * recordset normalization, and drift classification. * * No `pg` import, no AWS SDK client construction — only types are imported * from `@aws-sdk/client-route-53`. This keeps the module unit-testable * without mocking anything (see 24-RESEARCH.md Anti-Patterns: no per-value * diffing; Route 53 models an update as a whole-recordset replace). */ import type { ResourceRecordSet } from '@aws-sdk/client-route-53'; export interface NormalizedRecordSet { recordKey: string; zoneId: string; name: string; type: string; setIdentifier: string | null; ttl: number | null; resourceRecords: Array<{ value: string }>; aliasTarget: Record | null; } /** * Build the `route53_records.record_key` primary key / `recordId` URL * segment: `${zoneId}:${name}:${type}:${setIdentifier ?? ''}`. * * Normalizes `name` (lowercase) and `type` (uppercase) itself rather than * trusting every caller to pre-normalize — every current call site happens * to normalize before calling this, but that's a fragile invariant with no * guard at the point where it actually matters: a mismatched-case call would * silently produce a different `record_key` than the canonical one, splitting * a single AWS record across two mirror rows. */ export function buildRecordKey(input: { zoneId: string; name: string; type: string; setIdentifier?: string | null; }): string { return `${input.zoneId}:${input.name.toLowerCase()}:${input.type.toUpperCase()}:${input.setIdentifier ?? ''}`; } /** * Normalize a raw AWS `ResourceRecordSet` into a comparable, stable shape. * * - `name` is lowercased with its trailing dot preserved * - `type` is uppercased * - `ttl` is `rs.TTL ?? null` (alias records have no TTL) * - `resourceRecords` is sorted by value so AWS's unordered list doesn't * register as drift on every sync * - `aliasTarget` is `rs.AliasTarget ?? null` */ export function normalizeRecordSet(rs: ResourceRecordSet, zoneId: string): NormalizedRecordSet { const name = (rs.Name ?? '').toLowerCase(); const type = (rs.Type ?? '').toUpperCase(); const setIdentifier = rs.SetIdentifier ?? null; const ttl = rs.TTL ?? null; const resourceRecords = (rs.ResourceRecords ?? []) .map((r) => ({ value: r.Value ?? '' })) .sort((a, b) => a.value.localeCompare(b.value)); const aliasTarget = (rs.AliasTarget as unknown as Record | undefined) ?? null; return { recordKey: buildRecordKey({ zoneId, name, type, setIdentifier }), zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget, }; } /** * Compare two normalized recordsets for material equality: TTL, the * serialized (already-sorted) resourceRecords array, and the serialized * aliasTarget. Both null returns true; exactly one null returns false. */ export function recordSetsEqual(a: NormalizedRecordSet | null, b: NormalizedRecordSet | null): boolean { if (a === null && b === null) return true; if (a === null || b === null) return false; if (a.ttl !== b.ttl) return false; if (JSON.stringify(a.resourceRecords) !== JSON.stringify(b.resourceRecords)) return false; if (JSON.stringify(a.aliasTarget) !== JSON.stringify(b.aliasTarget)) return false; return true; } /** * Classify the drift between a previous (mirror) and next (AWS-live) * normalized recordset, mapping to `route53_record_history.change_action`. * Returns `null` when there is no material difference, so the sync does not * write a no-op history row. */ export function classifyDrift( prev: NormalizedRecordSet | null, next: NormalizedRecordSet | null ): 'create' | 'update' | 'delete' | null { if (prev === null && next === null) return null; if (prev === null) return 'create'; if (next === null) return 'delete'; return recordSetsEqual(prev, next) ? null : 'update'; } /** * Build the JSONB payload stored in `before_value`/`after_value`: the whole * recordset snapshot. Route 53 has no partial-value primitive, so the unit * of change recorded in history is always the whole recordset, never a * per-field delta. */ export function toHistoryPayload(ns: NormalizedRecordSet | null): unknown { if (ns === null) return null; return { name: ns.name, type: ns.type, setIdentifier: ns.setIdentifier, ttl: ns.ttl, resourceRecords: ns.resourceRecords, aliasTarget: ns.aliasTarget, }; }