docs(24-02): create plan summary

Route 53 sync + drift detection plan complete: 3 tasks, 4 files, all
verification passing (22 new tests, tsc clean, full suite green aside from
pre-existing unrelated itglue-search failures).
This commit is contained in:
lorentz 2026-08-05 20:24:18 -04:00
parent 0acf1fa24f
commit 6df084cb98

View file

@ -0,0 +1,92 @@
---
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
plan: 02
subsystem: aws-route53
tags: [route53, sync, drift-detection, soft-delete, sync_history]
dependency-graph:
requires:
- "lib/services/route53-factory.ts (getRoute53Client / isRoute53Configured)"
- "lib/types/route53.ts (Route53Record, Route53SyncResult, Route53HistorySource)"
- "migrations/102_route53_tables.sql (route53_zones / route53_records / route53_record_history)"
provides:
- "lib/services/route53-record-key.ts (buildRecordKey, normalizeRecordSet, recordSetsEqual, classifyDrift, toHistoryPayload)"
- "lib/services/route53-sync-service.ts (Route53SyncService, getRoute53SyncService(), buildDriftHistoryRows)"
affects:
- "plan 24-05 (CRUD routes import getRoute53SyncService for post-write history/audit patterns)"
- "plan 24-06 (scheduler wiring calls fullSync()/incrementalSync())"
tech-stack:
added: []
patterns:
- "Sync-service class shape mirrors lib/services/veeam-sync-service.ts: constructor(client?), isSyncing guard, executeSync() step loop with per-step error isolation, sync_history bookkeeping"
- "Pure drift-classification logic extracted into an injectable, unit-testable function (buildDriftHistoryRows) rather than inlined in the DB-touching sync method"
key-files:
created:
- lib/services/route53-record-key.ts
- lib/services/route53-record-key.test.ts
- lib/services/route53-sync-service.ts
- lib/services/route53-sync-service.test.ts
modified: []
decisions:
- "Combined Task 2 (sync mirror) and Task 3 (drift detection) into a single write of route53-sync-service.ts rather than writing sync-only first and retrofitting drift after — the plan's own Task 2 action already anticipated buildDriftHistoryRows()'s existence (referencing it in Task 3's read_first), so building both together avoided a throwaway intermediate version of syncRecords()."
- "Symlinked node_modules from the main repo checkout into this worktree (git-ignored, not committed) — the worktree had no node_modules of its own and @aws-sdk/client-route-53 types were required for both writing and type-checking this plan's code."
metrics:
duration: "~45 minutes, 3 tasks, 4 files"
completed: "2026-08-05"
---
# Phase 24 Plan 2: Route 53 Sync + Drift Detection Summary
Built the Route 53 → Postgres mirror sync (`Route53SyncService`) with paginated
zone/record ingestion, soft-delete reconciliation, and whole-recordset drift
detection that writes `sync_detected_drift` history rows whenever a mirrored
record's live AWS value no longer matches Postgres.
## What Was Built
**`lib/services/route53-record-key.ts`** — pure, dependency-free helpers:
- `buildRecordKey({ zoneId, name, type, setIdentifier })``${zoneId}:${name}:${type}:${setIdentifier ?? ''}`, the `route53_records.record_key` primary key
- `normalizeRecordSet(rs, zoneId)` — lowercases name (preserving trailing dot), uppercases type, coerces missing TTL to `null`, sorts `resourceRecords` by value (so AWS's unordered list never produces phantom drift), passes through `aliasTarget`
- `recordSetsEqual(a, b)` — TTL + serialized resourceRecords + serialized aliasTarget comparison; both-null is equal, one-null is not
- `classifyDrift(prev, next)``'create' | 'update' | 'delete' | null`, returning `null` for no material difference (no-op history row avoided)
- `toHistoryPayload(ns)` — whole-recordset JSONB snapshot for `before_value`/`after_value`
**`lib/services/route53-sync-service.ts`** — `Route53SyncService` class:
- `fullSync()` / `incrementalSync()` both delegate to `executeSync()`, which runs the same two steps in both cases (Route 53 exposes no modification cursor, so "incremental" is the same full diff run more often — documented in a code comment per D-11)
- `syncZones()` — paginates `ListHostedZonesCommand` (Marker/IsTruncated/NextMarker), strips the `/hostedzone/` prefix from each zone id, fetches `GetHostedZoneCommand` per zone for `DelegationSet.NameServers` (backs plan 24-04's D-12 NS-delegation check), upserts into `route53_zones`, then soft-deletes zones no longer returned by AWS
- `syncRecords()` — for each live zone, paginates `ListResourceRecordSetsCommand` (StartRecordName/Type/Identifier), normalizes each recordset, loads the zone's current mirror rows, computes drift via `buildDriftHistoryRows()` **before** upserting (so the pre-sync state is still available), inserts drift history rows in a transaction, then upserts records and soft-deletes anything no longer present
- `buildDriftHistoryRows(prevByKey, nextByKey, zoneId)` — exported pure function; unions both keyed maps, classifies drift per key via `classifyDrift`, skips `null` (no-change) keys, builds `DriftHistoryRow[]` with whole-recordset before/after payloads
- Initial-import guard: if a zone's mirror had zero rows before this sync, drift history generation is skipped entirely for that zone (logged once) — prevents flooding the append-only ledger with meaningless `create` rows on first import
- `sync_history` bookkeeping: `entity_type='route53'`, `sync_type` written as the literal `'full'`/`'incremental'` (matching the table's CHECK constraint), catastrophic-failure catch marks the row `status='failed'`
- All error logging uses `error instanceof Error ? error.message : String(error)` — never the raw AWS SDK error object (T-24-03: avoids leaking `$metadata`/request headers into logs)
- No `integration_settings` check anywhere in the file (D-10: Route 53's disable toggle is display-only, must never gate sync)
- `getRoute53SyncService()` module-level singleton, following `pax8-sync-service.ts`'s lazy-instance pattern, exported for plans 24-05/24-06
## Deviations from Plan
None — plan executed as written. Tasks 2 and 3 were combined into a single write of `route53-sync-service.ts` (see Decisions above) since the file only exists once either way; both tasks' acceptance criteria are independently verified below.
## Verification
- `npx vitest run lib/services/route53-record-key.test.ts` — 16 tests passed
- `npx vitest run lib/services/route53-sync-service.test.ts` — 6 tests passed
- `npx tsc --noEmit --pretty` — exits 0
- `npm test` (full suite) — 489 passed, 2 pre-existing failures in `lib/services/analyzer/itglue-search.test.ts`, unrelated to this plan's files and already logged in `deferred-items.md` from plan 24-01 (neither `itglue-search.ts` nor its test were touched by this plan)
- Acceptance-criteria greps all confirmed: `integration_settings` count 0, `entity_type` present, both pagination loops present (`IsTruncated` count 2), both soft-delete statements present (`is_deleted = true` count 2), `sync_detected_drift` literal present, `changed_by_user_id` NULL for drift rows, initial-import guard present (`prevByKey.size === 0`), `buildDriftHistoryRows` call precedes the `INSERT INTO route53_records` statement in source order
## Environment Note (not a code deviation)
This worktree had no `node_modules` directory. Symlinked it from the main
repo checkout (`ln -s /opt/stacks/pulse/node_modules ./node_modules`) so
`@aws-sdk/client-route-53` types and `vitest`/`tsc` were available. This is a
local filesystem convenience, not a git-tracked change — `node_modules` is
`.gitignore`d and no commit references it.
## Self-Check: PASSED
- `lib/services/route53-record-key.ts` — FOUND
- `lib/services/route53-record-key.test.ts` — FOUND
- `lib/services/route53-sync-service.ts` — FOUND
- `lib/services/route53-sync-service.test.ts` — FOUND
- Commit `c18271d` (Task 1) — FOUND in `git log --oneline --all`
- Commit `d8c0912` (Task 2) — FOUND in `git log --oneline --all`
- Commit `0acf1fa` (Task 3) — FOUND in `git log --oneline --all`