wulf-pulse/lib/services/route53-record-key.ts
lorentz c18271dda9 test(24-02): add Route 53 record-key, normalization, and drift-classification helpers
- buildRecordKey, normalizeRecordSet, recordSetsEqual, classifyDrift, toHistoryPayload
- Pure, dependency-free module (no pg, no AWS client construction)
- 16 unit tests covering every behavior bullet from the plan
2026-08-05 20:21:05 -04:00

117 lines
3.9 KiB
TypeScript

/**
* 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<string, unknown> | null;
}
/**
* Build the `route53_records.record_key` primary key / `recordId` URL
* segment: `${zoneId}:${name}:${type}:${setIdentifier ?? ''}`.
*/
export function buildRecordKey(input: {
zoneId: string;
name: string;
type: string;
setIdentifier?: string | null;
}): string {
return `${input.zoneId}:${input.name}:${input.type}:${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<string, unknown> | 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,
};
}