From 7f36ef00d34f6cf85b154072b9d4cd809107d9aa Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 14 Jul 2026 13:15:47 -0400 Subject: [PATCH 1/3] PROD-2311: resolve post-publish targetVersionID via Fetch-layer poll instead of stale snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-publish mapping bookkeeping recorded new targetVersionIDs by reading the start-of-run filesystem snapshot, which does not contain items CREATED during the same run. Those lookups failed ("Target content item N not found in filesystem"), left the mapping's targetVersionID stale (→ change-detection drift next sync), and were mislabeled under "Auto-Publish Errors". Fix: resolve each published item's fresh versionID by polling the Fetch layer via @agility/content-fetch (preview mode) until the observed versionID DIVERGES from its pre-publish baseline: - updated item: baseline = mapping.targetVersionID → wait until observed != baseline - created item: baseline = 0 (absent) → wait until it appears Staying on the Fetch layer keeps a single source of truth: content-fetch shares the datasource with content-sync (the pull), so the recorded versionID matches what change-detection later compares against — no source mismatch, no drift. (For published items the preview and fetch layers agree, so preview is safe.) The Fetch-API sync status (Management SDK) is used ONLY as a backoff hint — never as an exit condition. The exit condition is divergence, bounded by a hard timeout: - created item that never appears → non-blocking mapping warning - update whose version never diverges (no-op republish) → keep baseline silently Also re-labels the summary: mapping/refresh notices now print under a non-blocking "Mapping Update Warnings" header, separate from genuine (blocking) "Auto-Publish Errors" — closing the loop on the categorization PROD-2310 deferred. - src/lib/mappers/resolve-published-version-ids.ts (new): poll-until-diverges loop + content-fetch client builder + content/page version fetchers. - src/lib/mappers/mapping-version-updater.ts: source versionIDs from the poll. - src/core/push.ts: split the error summary (non-blocking mapping warnings). - tests for the poll loop. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/push.ts | 34 ++- src/lib/mappers/mapping-version-updater.ts | 121 +++++----- .../mappers/resolve-published-version-ids.ts | 223 ++++++++++++++++++ .../resolve-published-version-ids.test.ts | 105 +++++++++ 4 files changed, 422 insertions(+), 61 deletions(-) create mode 100644 src/lib/mappers/resolve-published-version-ids.ts create mode 100644 src/lib/mappers/tests/resolve-published-version-ids.test.ts diff --git a/src/core/push.ts b/src/core/push.ts index 2f0eea4c..2fe61287 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -151,10 +151,20 @@ export class Push { autoPublishErrors = await this.executeAutoPublish(results, autoPublish); } - // Final error summary - show if there were ANY failures (sync or auto-publish) - const hasFailures = totalSyncFailures > 0 || syncErrors.length > 0 || autoPublishErrors.length > 0; - - if (hasFailures) { + // Split auto-publish outcomes: genuine publish failures (blocking) vs + // post-publish mapping/refresh bookkeeping notices (non-blocking). PROD-2311 + const genuinePublishErrors = autoPublishErrors.filter( + ({ type }) => type === "publish" || type === "fatal" + ); + const mappingWarnings = autoPublishErrors.filter( + ({ type }) => type === "mapping" || type === "refresh" + ); + + // Final error summary - show only if there were genuine (blocking) failures + const hasBlockingFailures = + totalSyncFailures > 0 || syncErrors.length > 0 || genuinePublishErrors.length > 0; + + if (hasBlockingFailures) { console.log(ansiColors.red("\n" + "═".repeat(50))); console.log(ansiColors.red("⚠️ ERROR SUMMARY")); console.log(ansiColors.red("═".repeat(50))); @@ -196,16 +206,26 @@ export class Push { }); } - // Show auto-publish errors - if (autoPublishErrors.length > 0) { + // Show genuine auto-publish failures (publish/fatal) + if (genuinePublishErrors.length > 0) { console.log(ansiColors.red(`\n Auto-Publish Errors:`)); - autoPublishErrors.forEach(({ locale, type, error }) => { + genuinePublishErrors.forEach(({ locale, type, error }) => { const localeDisplay = locale ? `[${locale}]` : ""; console.log(ansiColors.red(` • ${localeDisplay} ${type}: ${error}`)); }); } } + // PROD-2311: post-publish mapping/version bookkeeping notices are NOT publish + // failures — show them under a non-blocking header so they don't read as broken. + if (mappingWarnings.length > 0) { + console.log(ansiColors.yellow(`\n Mapping Update Warnings (non-blocking):`)); + mappingWarnings.forEach(({ locale, type, error }) => { + const localeDisplay = locale ? `[${locale}]` : ""; + console.log(ansiColors.yellow(` • ${localeDisplay} ${type}: ${error}`)); + }); + } + // Show log file paths at the very end if (logFilePaths.length > 0) { console.log(ansiColors.cyan("\n📄 Log Files:")); diff --git a/src/lib/mappers/mapping-version-updater.ts b/src/lib/mappers/mapping-version-updater.ts index 886ce327..ec9650b7 100644 --- a/src/lib/mappers/mapping-version-updater.ts +++ b/src/lib/mappers/mapping-version-updater.ts @@ -1,18 +1,26 @@ /** * Mapping Version Updater * - * After publishing, updates the mappings with the new versionIDs - * by reading the refreshed data from the filesystem using fileOperations. + * After publishing, updates the mappings with the new targetVersionIDs. + * PROD-2311: versionIDs are resolved by polling the Fetch layer until each item + * diverges from its pre-publish baseline (see resolve-published-version-ids.ts), + * rather than reading a start-of-run filesystem snapshot that misses same-run creations. */ -import { fileOperations } from "../../core"; import { getLogger } from "../../core/state"; import { ContentItemMapper } from "./content-item-mapper"; import { PageMapper } from "./page-mapper"; -import { getContentItemsFromFileSystem } from "../getters/filesystem/get-content-items"; -import { getPagesFromFileSystem } from "../getters/filesystem/get-pages"; import ansiColors from "ansi-colors"; import { MappingUpdateResult } from "../../types"; +import { + resolvePublishedVersionIDs, + makeContentVersionFetcher, + makePageVersionFetcher, + POLL_TIMEOUT_MS, +} from "./resolve-published-version-ids"; + +/** Human-readable timeout for the "did not appear" warning message (PROD-2311). */ +const POLL_TIMEOUT_SECONDS = Math.round(POLL_TIMEOUT_MS / 1000); // Re-export type for convenience export { MappingUpdateResult }; @@ -65,46 +73,52 @@ export async function updateContentMappingsAfterPublish( } try { - // Create file operations for target (we only need target data for versionID) - const targetFileOps = new fileOperations(targetGuid, locale); - - // Load content items from target filesystem (refreshed after pull) - const targetContentItems = getContentItemsFromFileSystem(targetFileOps); - - // Create content item mapper const contentMapper = new ContentItemMapper(sourceGuid, targetGuid, locale); - // Create lookup map for quick access - const targetContentMap = new Map(targetContentItems.map((item) => [item.contentID, item])); - - // Update targetVersionID for each published content item - for (const targetContentId of uniqueContentIds) { - const targetItem = targetContentMap.get(targetContentId); - if (!targetItem) { - errors.push(`Target content item ${targetContentId} not found in filesystem`); - continue; - } - - // Update only the target versionID in the mapping - const result = contentMapper.updateTargetVersionID(targetContentId, targetItem.properties.versionID); - + // PROD-2311: resolve each published item's fresh versionID by polling the + // Fetch layer until it diverges from its pre-publish baseline, instead of + // reading the start-of-run filesystem snapshot (which misses items created + // during this same run). Baseline = mapping's current targetVersionID (0 = new). + const items = uniqueContentIds.map((id) => ({ + id, + baseline: contentMapper.getContentItemMappingByContentID(id, "target")?.targetVersionID ?? 0, + })); + + const fetchItem = makeContentVersionFetcher(targetGuid, locale); + const { resolved, missingCreates } = await resolvePublishedVersionIDs(items, fetchItem, { + guid: targetGuid, + mode: "preview", + }); + + // Record the diverged versionIDs into the mappings. + for (const [targetContentId, observed] of Array.from(resolved.entries())) { + const result = contentMapper.updateTargetVersionID(targetContentId, observed.versionID); if (result.success) { updated++; - // Track all version updates with display info changes.push({ id: targetContentId, oldVersion: result.oldVersionID!, newVersion: result.newVersionID!, changed: result.oldVersionID !== result.newVersionID, - name: targetItem.fields?.title || targetItem.fields?.name || `Item ${targetContentId}`, - refName: targetItem.properties?.referenceName, - modelName: targetItem.properties?.definitionName, + name: observed.name, + refName: observed.refName, + modelName: observed.modelName, }); } else { errors.push(`No mapping found for target content ID ${targetContentId}`); } } + // New items that never propagated to the Fetch API within the timeout. These + // are non-blocking mapping warnings (the content itself did publish). + for (const targetContentId of missingCreates) { + errors.push( + `target content item ${targetContentId} did not appear on the Fetch API within ${POLL_TIMEOUT_SECONDS}s after publish; mapping version left unset` + ); + } + // Note: unchangedUpdates (baseline never diverged, e.g. no-op republish) are + // intentionally left as-is — the existing targetVersionID is already correct. + return { updated, errors, changes }; } catch (error: any) { errors.push(`Content mapping update failed: ${error.message}`); @@ -134,45 +148,44 @@ export async function updatePageMappingsAfterPublish( } try { - // Create file operations for target (we only need target data for versionID) - const targetFileOps = new fileOperations(targetGuid, locale); - - // Load pages from target filesystem (refreshed after pull) - const targetPages = getPagesFromFileSystem(targetFileOps); - - // Create page mapper const pageMapper = new PageMapper(sourceGuid, targetGuid, locale); - // Create lookup map for quick access - const targetPageMap = new Map(targetPages.map((page) => [page.pageID, page])); - - // Update targetVersionID for each published page - for (const targetPageId of uniquePageIds) { - const targetPage = targetPageMap.get(targetPageId); - if (!targetPage) { - errors.push(`Target page ${targetPageId} not found in filesystem`); - continue; - } - - // Update only the target versionID in the mapping - const result = pageMapper.updateTargetVersionID(targetPageId, targetPage.properties.versionID); - + // PROD-2311: resolve fresh versionIDs by polling the Fetch layer until each + // page diverges from its pre-publish baseline (see updateContentMappingsAfterPublish). + const items = uniquePageIds.map((id) => ({ + id, + baseline: pageMapper.getPageMappingByPageID(id, "target")?.targetVersionID ?? 0, + })); + + const fetchItem = makePageVersionFetcher(targetGuid, locale); + const { resolved, missingCreates } = await resolvePublishedVersionIDs(items, fetchItem, { + guid: targetGuid, + mode: "preview", + }); + + for (const [targetPageId, observed] of Array.from(resolved.entries())) { + const result = pageMapper.updateTargetVersionID(targetPageId, observed.versionID); if (result.success) { updated++; - // Track all version updates with display info changes.push({ id: targetPageId, oldVersion: result.oldVersionID!, newVersion: result.newVersionID!, changed: result.oldVersionID !== result.newVersionID, - name: targetPage.title || targetPage.name || `Page ${targetPageId}`, - refName: targetPage.name ? `/${targetPage.name}` : undefined, + name: observed.name, + refName: observed.refName, }); } else { errors.push(`No mapping found for target page ID ${targetPageId}`); } } + for (const targetPageId of missingCreates) { + errors.push( + `target page ${targetPageId} did not appear on the Fetch API within ${POLL_TIMEOUT_SECONDS}s after publish; mapping version left unset` + ); + } + return { updated, errors, changes }; } catch (error: any) { errors.push(`Page mapping update failed: ${error.message}`); diff --git a/src/lib/mappers/resolve-published-version-ids.ts b/src/lib/mappers/resolve-published-version-ids.ts new file mode 100644 index 00000000..bbd61961 --- /dev/null +++ b/src/lib/mappers/resolve-published-version-ids.ts @@ -0,0 +1,223 @@ +/** + * Resolve Published Version IDs (PROD-2311) + * + * After auto-publish, the post-publish bookkeeping needs each just-published + * item's new targetVersionID. The old approach read the start-of-run filesystem + * snapshot, which does not contain items CREATED during the same run — so those + * lookups failed ("not found in filesystem") and the mapping's targetVersionID + * was left stale, causing change-detection drift on the next sync. + * + * Instead we poll the Fetch layer (via @agility/content-fetch, preview mode) + * until each item's observed versionID DIVERGES from its pre-publish baseline: + * - updated item: baseline = mapping.targetVersionID → wait until observed != baseline + * - created item: baseline = 0 (absent) → wait until it appears (any versionID) + * + * Why this is safe (no source mismatch / no drift): for PUBLISHED items the + * preview and fetch layers return the same version, and content-fetch shares + * the datasource with content-sync (the pull). So a versionID read here equals + * what the pull writes and what change-detection later compares against. + * + * The Fetch-API sync status is used ONLY as a backoff hint (wait smartly while a + * CDN sync is in progress) — never as an exit condition. The exit condition is + * divergence, or the hard timeout. + */ + +import * as agilityFetch from "@agility/content-fetch"; +import { Auth } from "../../core/auth"; +import { getApiKeysForGuid } from "../../core/state"; +import { getFetchApiStatus, waitForFetchApiSync, FetchApiSyncMode } from "../shared/get-fetch-api-status"; + +// getApi is a runtime export of @agility/content-fetch but is not surfaced in +// the package's type declarations, so we reach it via a cast. +const getApi = (agilityFetch as any).getApi as (config: any) => any; + +/** Max wall-clock to wait for CDN propagation before giving up (per locale batch). */ +export const POLL_TIMEOUT_MS = 60_000; +/** Delay between polls when NO sync is in progress (the post-publish registration-race window). */ +export const POLL_BACKOFF_MS = 2_000; +/** Hard iteration cap as a belt-and-suspenders backstop on top of the wall-clock deadline. */ +export const POLL_MAX_ITERATIONS = 1_000; + +/** What we observe for a single item on the Fetch layer (null = not present yet). */ +export interface ObservedItem { + versionID: number; + name?: string; + refName?: string; + modelName?: string; +} + +export interface VersionResolutionItem { + id: number; + /** Pre-publish versionID from the mapping; 0 means the item is new (absent). */ + baseline: number; +} + +export interface VersionResolutionResult { + /** id -> the freshly observed item (versionID diverged from baseline). */ + resolved: Map; + /** New items (baseline 0) that never appeared before the timeout — surface as non-blocking warnings. */ + missingCreates: number[]; + /** Updated items whose versionID never diverged (e.g. no-op republish) — keep the baseline silently. */ + unchangedUpdates: number[]; +} + +/** Injectable dependencies so the poll loop is unit-testable without real timers/network. */ +export interface PollDeps { + getStatus: (guid: string, mode: FetchApiSyncMode) => Promise<{ inProgress: boolean }>; + waitForSync: (guid: string, mode: FetchApiSyncMode) => Promise; + sleep: (ms: number) => Promise; + now: () => number; +} + +const defaultDeps: PollDeps = { + getStatus: async (guid, mode) => getFetchApiStatus(guid, mode, false), + waitForSync: async (guid, mode) => { + await waitForFetchApiSync(guid, mode, true /* silent */); + }, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now: () => Date.now(), +}; + +export interface PollOptions { + guid: string; + mode?: FetchApiSyncMode; + timeoutMs?: number; + backoffMs?: number; + maxIterations?: number; + deps?: Partial; +} + +/** + * Poll the Fetch layer until each item diverges from its baseline (or timeout). + * + * @param items items to resolve, each with its pre-publish baseline + * @param fetchItem resolves an item's current Fetch-layer state (null if not present yet) + */ +export async function resolvePublishedVersionIDs( + items: VersionResolutionItem[], + fetchItem: (id: number) => Promise, + options: PollOptions +): Promise { + const mode: FetchApiSyncMode = options.mode ?? "preview"; + const timeoutMs = options.timeoutMs ?? POLL_TIMEOUT_MS; + const backoffMs = options.backoffMs ?? POLL_BACKOFF_MS; + const maxIterations = options.maxIterations ?? POLL_MAX_ITERATIONS; + const deps: PollDeps = { ...defaultDeps, ...options.deps }; + + const resolved = new Map(); + // id -> baseline for the items still awaiting divergence + const remaining = new Map(items.map((i) => [i.id, i.baseline])); + + const deadline = deps.now() + timeoutMs; + let iterations = 0; + + while (remaining.size > 0 && deps.now() < deadline && iterations < maxIterations) { + iterations++; + + // Backoff hint: if a CDN sync is in progress, wait for it to finish before + // fetching (efficient). inProgress is a HINT ONLY — not an exit condition. + const status = await deps.getStatus(options.guid, mode); + if (status.inProgress) { + await deps.waitForSync(options.guid, mode); + } + + // Fetch each remaining item; resolve the ones that have diverged. + for (const [id, baseline] of Array.from(remaining.entries())) { + const observed = await fetchItem(id); + if (observed && observed.versionID !== baseline) { + resolved.set(id, observed); + remaining.delete(id); + } + } + + if (remaining.size === 0) break; + + // Not fully resolved. If no sync was in progress, we're in the propagation + // window (or the sync hasn't registered yet) — sleep before re-polling so we + // don't hot-loop. If a sync WAS in progress we already waited on it above, so + // loop straight back and re-check status. + if (!status.inProgress) { + await deps.sleep(backoffMs); + } + } + + // Classify whatever never diverged. + const missingCreates: number[] = []; + const unchangedUpdates: number[] = []; + for (const [id, baseline] of Array.from(remaining.entries())) { + if (baseline === 0) { + // New item that never appeared on the Fetch API — a real problem, warn. + missingCreates.push(id); + } else { + // Existing item whose version never changed (e.g. no-op republish). The + // baseline is already correct, so keep it silently — no drift, no error. + unchangedUpdates.push(id); + } + } + + return { resolved, missingCreates, unchangedUpdates }; +} + +/** + * Build a content-fetch API client for the target instance (preview layer), + * mirroring how download-sync-sdk.ts configures the sync client: + * previewKey + `${determineFetchUrl(guid)}/${guid}` as the baseUrl. + */ +function buildFetchApi(guid: string): any { + const { previewKey: apiKey } = getApiKeysForGuid(guid); + const fetchUrl = new Auth().determineFetchUrl(guid); + return getApi({ + guid, + apiKey, + isPreview: true, + // content-fetch composes `${baseUrl}/${fetch|preview}/${locale}/item/{id}`, + // so the guid must be part of baseUrl (same shape the sync SDK uses). + baseUrl: `${fetchUrl}/${guid}`, + }); +} + +/** Returns a fetcher that reads a content item's current versionID from the Fetch layer. */ +export function makeContentVersionFetcher( + guid: string, + locale: string +): (id: number) => Promise { + const api = buildFetchApi(guid); + return async (contentID: number) => { + try { + const item = await api.getContentItem({ contentID, locale }); + const versionID = item?.properties?.versionID; + if (typeof versionID !== "number") return null; + return { + versionID, + name: item?.fields?.title || item?.fields?.name || `Item ${contentID}`, + refName: item?.properties?.referenceName, + modelName: item?.properties?.definitionName, + }; + } catch { + // Not on the CDN yet (or transient) — treat as "not present, keep polling". + return null; + } + }; +} + +/** Returns a fetcher that reads a page's current versionID from the Fetch layer. */ +export function makePageVersionFetcher( + guid: string, + locale: string +): (id: number) => Promise { + const api = buildFetchApi(guid); + return async (pageID: number) => { + try { + const page = await api.getPage({ pageID, locale }); + const versionID = page?.properties?.versionID; + if (typeof versionID !== "number") return null; + return { + versionID, + name: page?.title || page?.name || `Page ${pageID}`, + refName: page?.name ? `/${page.name}` : undefined, + }; + } catch { + return null; + } + }; +} diff --git a/src/lib/mappers/tests/resolve-published-version-ids.test.ts b/src/lib/mappers/tests/resolve-published-version-ids.test.ts new file mode 100644 index 00000000..4ccfbf1e --- /dev/null +++ b/src/lib/mappers/tests/resolve-published-version-ids.test.ts @@ -0,0 +1,105 @@ +import { resolvePublishedVersionIDs, ObservedItem, PollDeps } from "../resolve-published-version-ids"; + +// Injected deps that never touch the network or real timers. +function makeDeps(overrides: Partial = {}): Partial { + return { + getStatus: jest.fn().mockResolvedValue({ inProgress: false }), + waitForSync: jest.fn().mockResolvedValue(undefined), + sleep: jest.fn().mockResolvedValue(undefined), + now: () => 1_000, // constant clock → termination is driven by maxIterations, not wall-clock + ...overrides, + }; +} + +const obs = (versionID: number): ObservedItem => ({ versionID }); + +describe("resolvePublishedVersionIDs", () => { + it("resolves an item once its versionID diverges from the baseline", async () => { + const fetchItem = jest.fn().mockResolvedValue(obs(7)); + + const result = await resolvePublishedVersionIDs([{ id: 1, baseline: 5 }], fetchItem, { + guid: "g", + deps: makeDeps(), + }); + + expect(result.resolved.get(1)).toEqual(obs(7)); + expect(result.missingCreates).toEqual([]); + expect(result.unchangedUpdates).toEqual([]); + }); + + it("resolves a created item (baseline 0) as soon as it appears", async () => { + const fetchItem = jest.fn().mockResolvedValue(obs(2)); + + const result = await resolvePublishedVersionIDs([{ id: 4, baseline: 0 }], fetchItem, { + guid: "g", + deps: makeDeps(), + }); + + expect(result.resolved.get(4)).toEqual(obs(2)); + expect(result.missingCreates).toEqual([]); + }); + + it("classifies a new item that never appears as a missing create (non-blocking)", async () => { + const fetchItem = jest.fn().mockResolvedValue(null); // never on the CDN + + const result = await resolvePublishedVersionIDs([{ id: 2, baseline: 0 }], fetchItem, { + guid: "g", + maxIterations: 3, + deps: makeDeps(), + }); + + expect(result.resolved.size).toBe(0); + expect(result.missingCreates).toEqual([2]); + expect(result.unchangedUpdates).toEqual([]); + }); + + it("keeps a no-op update (baseline never diverges) with no error", async () => { + const fetchItem = jest.fn().mockResolvedValue(obs(9)); // same as baseline forever + + const result = await resolvePublishedVersionIDs([{ id: 3, baseline: 9 }], fetchItem, { + guid: "g", + maxIterations: 3, + deps: makeDeps(), + }); + + expect(result.resolved.size).toBe(0); + expect(result.missingCreates).toEqual([]); + expect(result.unchangedUpdates).toEqual([3]); + }); + + it("backs off with sleep when no sync is in progress, then resolves on a later poll", async () => { + const fetchItem = jest + .fn() + .mockResolvedValueOnce(null) // first poll: not propagated yet + .mockResolvedValueOnce(obs(2)); // second poll: appeared + const deps = makeDeps(); + + const result = await resolvePublishedVersionIDs([{ id: 4, baseline: 0 }], fetchItem, { + guid: "g", + backoffMs: 1234, + deps, + }); + + expect(result.resolved.get(4)).toEqual(obs(2)); + expect(deps.sleep).toHaveBeenCalledWith(1234); // backoff was used in the race window + expect(deps.waitForSync).not.toHaveBeenCalled(); // no sync was in progress + }); + + it("waits for an in-progress sync instead of sleeping", async () => { + const fetchItem = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(obs(3)); + const deps = makeDeps({ + getStatus: jest + .fn() + .mockResolvedValueOnce({ inProgress: true }) // first iteration: sync running + .mockResolvedValue({ inProgress: false }), + }); + + const result = await resolvePublishedVersionIDs([{ id: 5, baseline: 1 }], fetchItem, { + guid: "g", + deps, + }); + + expect(result.resolved.get(5)).toEqual(obs(3)); + expect(deps.waitForSync).toHaveBeenCalledTimes(1); + }); +}); From ee7800a2f23e0ae8dc443f40eeda08893189472f Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 14 Jul 2026 15:33:43 -0400 Subject: [PATCH 2/3] PROD-2311: classify unresolved items by observed-ness, not baseline===0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newly-created items already carry a non-zero create-time targetVersionID in the mapping (ContentItemMapper.addMapping), so baseline===0 was not a reliable "is new" signal — a created item that never propagated to the Fetch API could be silently kept instead of warned. Track which ids were observed on the Fetch layer at least once and classify leftovers by that: never observed -> non-blocking "missing" warning; observed but never diverged (e.g. no-op republish) -> keep the existing mapping value silently. Reworded the residual warning to "mapping version not updated" so it is accurate for both zero and non-zero baselines. Added a test covering a never-observed item with a non-zero baseline. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/mappers/mapping-version-updater.ts | 4 +-- .../mappers/resolve-published-version-ids.ts | 25 +++++++++++++------ .../resolve-published-version-ids.test.ts | 16 ++++++++++++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/lib/mappers/mapping-version-updater.ts b/src/lib/mappers/mapping-version-updater.ts index ec9650b7..f2f5f2d4 100644 --- a/src/lib/mappers/mapping-version-updater.ts +++ b/src/lib/mappers/mapping-version-updater.ts @@ -113,7 +113,7 @@ export async function updateContentMappingsAfterPublish( // are non-blocking mapping warnings (the content itself did publish). for (const targetContentId of missingCreates) { errors.push( - `target content item ${targetContentId} did not appear on the Fetch API within ${POLL_TIMEOUT_SECONDS}s after publish; mapping version left unset` + `target content item ${targetContentId} did not appear on the Fetch API within ${POLL_TIMEOUT_SECONDS}s after publish; mapping version not updated` ); } // Note: unchangedUpdates (baseline never diverged, e.g. no-op republish) are @@ -182,7 +182,7 @@ export async function updatePageMappingsAfterPublish( for (const targetPageId of missingCreates) { errors.push( - `target page ${targetPageId} did not appear on the Fetch API within ${POLL_TIMEOUT_SECONDS}s after publish; mapping version left unset` + `target page ${targetPageId} did not appear on the Fetch API within ${POLL_TIMEOUT_SECONDS}s after publish; mapping version not updated` ); } diff --git a/src/lib/mappers/resolve-published-version-ids.ts b/src/lib/mappers/resolve-published-version-ids.ts index bbd61961..e4ee64b1 100644 --- a/src/lib/mappers/resolve-published-version-ids.ts +++ b/src/lib/mappers/resolve-published-version-ids.ts @@ -55,9 +55,9 @@ export interface VersionResolutionItem { export interface VersionResolutionResult { /** id -> the freshly observed item (versionID diverged from baseline). */ resolved: Map; - /** New items (baseline 0) that never appeared before the timeout — surface as non-blocking warnings. */ + /** Items never observed on the Fetch layer before the timeout (e.g. a created item that never propagated) — surface as non-blocking warnings. */ missingCreates: number[]; - /** Updated items whose versionID never diverged (e.g. no-op republish) — keep the baseline silently. */ + /** Items observed but whose versionID never diverged from baseline (e.g. no-op republish) — keep the existing mapping value silently. */ unchangedUpdates: number[]; } @@ -107,6 +107,9 @@ export async function resolvePublishedVersionIDs( const resolved = new Map(); // id -> baseline for the items still awaiting divergence const remaining = new Map(items.map((i) => [i.id, i.baseline])); + // ids observed on the Fetch layer at least once (present, even if not yet diverged). + // Used to distinguish "never appeared" (warn) from "appeared but unchanged" (keep silently). + const seen = new Set(); const deadline = deps.now() + timeoutMs; let iterations = 0; @@ -124,6 +127,7 @@ export async function resolvePublishedVersionIDs( // Fetch each remaining item; resolve the ones that have diverged. for (const [id, baseline] of Array.from(remaining.entries())) { const observed = await fetchItem(id); + if (observed) seen.add(id); if (observed && observed.versionID !== baseline) { resolved.set(id, observed); remaining.delete(id); @@ -141,16 +145,21 @@ export async function resolvePublishedVersionIDs( } } - // Classify whatever never diverged. + // Classify whatever never diverged by whether we ever OBSERVED it on the Fetch + // layer — not by baseline. Newly-created items already carry a non-zero + // create-time targetVersionID in the mapping (addMapping), so baseline === 0 is + // not a reliable "is new" signal; "never observed" is. const missingCreates: number[] = []; const unchangedUpdates: number[] = []; - for (const [id, baseline] of Array.from(remaining.entries())) { - if (baseline === 0) { - // New item that never appeared on the Fetch API — a real problem, warn. + for (const id of Array.from(remaining.keys())) { + if (!seen.has(id)) { + // Never appeared on the Fetch API within the timeout (e.g. a created item + // that never propagated) — a real problem, surface as a non-blocking warning. missingCreates.push(id); } else { - // Existing item whose version never changed (e.g. no-op republish). The - // baseline is already correct, so keep it silently — no drift, no error. + // Observed, but its versionID never diverged from the baseline (e.g. a + // no-op republish). The mapping's existing value is already correct, so + // keep it silently — no drift, no error. unchangedUpdates.push(id); } } diff --git a/src/lib/mappers/tests/resolve-published-version-ids.test.ts b/src/lib/mappers/tests/resolve-published-version-ids.test.ts index 4ccfbf1e..cfba0734 100644 --- a/src/lib/mappers/tests/resolve-published-version-ids.test.ts +++ b/src/lib/mappers/tests/resolve-published-version-ids.test.ts @@ -67,6 +67,22 @@ describe("resolvePublishedVersionIDs", () => { expect(result.unchangedUpdates).toEqual([3]); }); + it("flags a never-observed item as missing even with a non-zero baseline (created items carry a create-time version)", async () => { + const fetchItem = jest.fn().mockResolvedValue(null); // never on the CDN + + const result = await resolvePublishedVersionIDs([{ id: 8, baseline: 42 }], fetchItem, { + guid: "g", + maxIterations: 3, + deps: makeDeps(), + }); + + // Classification keys off "never observed", not baseline === 0, so a created + // item that already had a create-time versionID is still surfaced (not kept silently). + expect(result.resolved.size).toBe(0); + expect(result.missingCreates).toEqual([8]); + expect(result.unchangedUpdates).toEqual([]); + }); + it("backs off with sleep when no sync is in progress, then resolves on a later poll", async () => { const fetchItem = jest .fn() From d48429d95cbf5858f5fa3fe614716762e3c46e10 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 20 Jul 2026 14:37:59 -0400 Subject: [PATCH 3/3] PROD-2311: resolve post-publish versionIDs via Management API instead of Fetch-layer polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fetch-layer poll-until-diverges approach (resolve-published-version-ids.ts) existed to work around CDN propagation delay, but added a whole poll/backoff/ timeout/classification machine to get one field. The Management API is strongly consistent, so a single get-by-id call right after publish is enough — no polling needed. mapping-version-updater.ts now calls apiClient.contentMethods.getContentItem and apiClient.pageMethods.getPage directly per published id and reads properties.versionID off the result. Deleted resolve-published-version-ids.ts and its test; rewrote mapping-version-updater.test.ts to mock getApiClient instead of the filesystem getters / Fetch-layer poll. Co-Authored-By: Claude Sonnet 5 --- src/lib/mappers/mapping-version-updater.ts | 100 +++----- .../mappers/resolve-published-version-ids.ts | 232 ------------------ .../tests/mapping-version-updater.test.ts | 73 +++--- .../resolve-published-version-ids.test.ts | 121 --------- 4 files changed, 65 insertions(+), 461 deletions(-) delete mode 100644 src/lib/mappers/resolve-published-version-ids.ts delete mode 100644 src/lib/mappers/tests/resolve-published-version-ids.test.ts diff --git a/src/lib/mappers/mapping-version-updater.ts b/src/lib/mappers/mapping-version-updater.ts index f2f5f2d4..d5303178 100644 --- a/src/lib/mappers/mapping-version-updater.ts +++ b/src/lib/mappers/mapping-version-updater.ts @@ -2,25 +2,18 @@ * Mapping Version Updater * * After publishing, updates the mappings with the new targetVersionIDs. - * PROD-2311: versionIDs are resolved by polling the Fetch layer until each item - * diverges from its pre-publish baseline (see resolve-published-version-ids.ts), - * rather than reading a start-of-run filesystem snapshot that misses same-run creations. + * PROD-2311: versionIDs are read directly from the Management API right after + * publish, instead of a start-of-run filesystem snapshot that misses items + * created during the same run. The Management API is strongly consistent (no + * CDN propagation delay), so a single call per item is enough — no polling. */ -import { getLogger } from "../../core/state"; +import * as mgmtApi from "@agility/management-sdk"; +import { getApiClient, getLogger } from "../../core/state"; import { ContentItemMapper } from "./content-item-mapper"; import { PageMapper } from "./page-mapper"; import ansiColors from "ansi-colors"; import { MappingUpdateResult } from "../../types"; -import { - resolvePublishedVersionIDs, - makeContentVersionFetcher, - makePageVersionFetcher, - POLL_TIMEOUT_MS, -} from "./resolve-published-version-ids"; - -/** Human-readable timeout for the "did not appear" warning message (PROD-2311). */ -const POLL_TIMEOUT_SECONDS = Math.round(POLL_TIMEOUT_MS / 1000); // Re-export type for convenience export { MappingUpdateResult }; @@ -74,25 +67,18 @@ export async function updateContentMappingsAfterPublish( try { const contentMapper = new ContentItemMapper(sourceGuid, targetGuid, locale); + const apiClient = getApiClient(); + + for (const targetContentId of uniqueContentIds) { + let targetItem: mgmtApi.ContentItem; + try { + targetItem = await apiClient.contentMethods.getContentItem(targetContentId, targetGuid, locale); + } catch (error: any) { + errors.push(`Target content item ${targetContentId} not found on target instance: ${error.message}`); + continue; + } - // PROD-2311: resolve each published item's fresh versionID by polling the - // Fetch layer until it diverges from its pre-publish baseline, instead of - // reading the start-of-run filesystem snapshot (which misses items created - // during this same run). Baseline = mapping's current targetVersionID (0 = new). - const items = uniqueContentIds.map((id) => ({ - id, - baseline: contentMapper.getContentItemMappingByContentID(id, "target")?.targetVersionID ?? 0, - })); - - const fetchItem = makeContentVersionFetcher(targetGuid, locale); - const { resolved, missingCreates } = await resolvePublishedVersionIDs(items, fetchItem, { - guid: targetGuid, - mode: "preview", - }); - - // Record the diverged versionIDs into the mappings. - for (const [targetContentId, observed] of Array.from(resolved.entries())) { - const result = contentMapper.updateTargetVersionID(targetContentId, observed.versionID); + const result = contentMapper.updateTargetVersionID(targetContentId, targetItem.properties.versionID); if (result.success) { updated++; changes.push({ @@ -100,25 +86,15 @@ export async function updateContentMappingsAfterPublish( oldVersion: result.oldVersionID!, newVersion: result.newVersionID!, changed: result.oldVersionID !== result.newVersionID, - name: observed.name, - refName: observed.refName, - modelName: observed.modelName, + name: targetItem.fields?.title || targetItem.fields?.name || `Item ${targetContentId}`, + refName: targetItem.properties?.referenceName, + modelName: targetItem.properties?.definitionName, }); } else { errors.push(`No mapping found for target content ID ${targetContentId}`); } } - // New items that never propagated to the Fetch API within the timeout. These - // are non-blocking mapping warnings (the content itself did publish). - for (const targetContentId of missingCreates) { - errors.push( - `target content item ${targetContentId} did not appear on the Fetch API within ${POLL_TIMEOUT_SECONDS}s after publish; mapping version not updated` - ); - } - // Note: unchangedUpdates (baseline never diverged, e.g. no-op republish) are - // intentionally left as-is — the existing targetVersionID is already correct. - return { updated, errors, changes }; } catch (error: any) { errors.push(`Content mapping update failed: ${error.message}`); @@ -149,22 +125,18 @@ export async function updatePageMappingsAfterPublish( try { const pageMapper = new PageMapper(sourceGuid, targetGuid, locale); + const apiClient = getApiClient(); + + for (const targetPageId of uniquePageIds) { + let targetPage: mgmtApi.PageItem; + try { + targetPage = await apiClient.pageMethods.getPage(targetPageId, targetGuid, locale); + } catch (error: any) { + errors.push(`Target page ${targetPageId} not found on target instance: ${error.message}`); + continue; + } - // PROD-2311: resolve fresh versionIDs by polling the Fetch layer until each - // page diverges from its pre-publish baseline (see updateContentMappingsAfterPublish). - const items = uniquePageIds.map((id) => ({ - id, - baseline: pageMapper.getPageMappingByPageID(id, "target")?.targetVersionID ?? 0, - })); - - const fetchItem = makePageVersionFetcher(targetGuid, locale); - const { resolved, missingCreates } = await resolvePublishedVersionIDs(items, fetchItem, { - guid: targetGuid, - mode: "preview", - }); - - for (const [targetPageId, observed] of Array.from(resolved.entries())) { - const result = pageMapper.updateTargetVersionID(targetPageId, observed.versionID); + const result = pageMapper.updateTargetVersionID(targetPageId, targetPage.properties.versionID); if (result.success) { updated++; changes.push({ @@ -172,20 +144,14 @@ export async function updatePageMappingsAfterPublish( oldVersion: result.oldVersionID!, newVersion: result.newVersionID!, changed: result.oldVersionID !== result.newVersionID, - name: observed.name, - refName: observed.refName, + name: targetPage.title || targetPage.name || `Page ${targetPageId}`, + refName: targetPage.name ? `/${targetPage.name}` : undefined, }); } else { errors.push(`No mapping found for target page ID ${targetPageId}`); } } - for (const targetPageId of missingCreates) { - errors.push( - `target page ${targetPageId} did not appear on the Fetch API within ${POLL_TIMEOUT_SECONDS}s after publish; mapping version not updated` - ); - } - return { updated, errors, changes }; } catch (error: any) { errors.push(`Page mapping update failed: ${error.message}`); diff --git a/src/lib/mappers/resolve-published-version-ids.ts b/src/lib/mappers/resolve-published-version-ids.ts deleted file mode 100644 index e4ee64b1..00000000 --- a/src/lib/mappers/resolve-published-version-ids.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Resolve Published Version IDs (PROD-2311) - * - * After auto-publish, the post-publish bookkeeping needs each just-published - * item's new targetVersionID. The old approach read the start-of-run filesystem - * snapshot, which does not contain items CREATED during the same run — so those - * lookups failed ("not found in filesystem") and the mapping's targetVersionID - * was left stale, causing change-detection drift on the next sync. - * - * Instead we poll the Fetch layer (via @agility/content-fetch, preview mode) - * until each item's observed versionID DIVERGES from its pre-publish baseline: - * - updated item: baseline = mapping.targetVersionID → wait until observed != baseline - * - created item: baseline = 0 (absent) → wait until it appears (any versionID) - * - * Why this is safe (no source mismatch / no drift): for PUBLISHED items the - * preview and fetch layers return the same version, and content-fetch shares - * the datasource with content-sync (the pull). So a versionID read here equals - * what the pull writes and what change-detection later compares against. - * - * The Fetch-API sync status is used ONLY as a backoff hint (wait smartly while a - * CDN sync is in progress) — never as an exit condition. The exit condition is - * divergence, or the hard timeout. - */ - -import * as agilityFetch from "@agility/content-fetch"; -import { Auth } from "../../core/auth"; -import { getApiKeysForGuid } from "../../core/state"; -import { getFetchApiStatus, waitForFetchApiSync, FetchApiSyncMode } from "../shared/get-fetch-api-status"; - -// getApi is a runtime export of @agility/content-fetch but is not surfaced in -// the package's type declarations, so we reach it via a cast. -const getApi = (agilityFetch as any).getApi as (config: any) => any; - -/** Max wall-clock to wait for CDN propagation before giving up (per locale batch). */ -export const POLL_TIMEOUT_MS = 60_000; -/** Delay between polls when NO sync is in progress (the post-publish registration-race window). */ -export const POLL_BACKOFF_MS = 2_000; -/** Hard iteration cap as a belt-and-suspenders backstop on top of the wall-clock deadline. */ -export const POLL_MAX_ITERATIONS = 1_000; - -/** What we observe for a single item on the Fetch layer (null = not present yet). */ -export interface ObservedItem { - versionID: number; - name?: string; - refName?: string; - modelName?: string; -} - -export interface VersionResolutionItem { - id: number; - /** Pre-publish versionID from the mapping; 0 means the item is new (absent). */ - baseline: number; -} - -export interface VersionResolutionResult { - /** id -> the freshly observed item (versionID diverged from baseline). */ - resolved: Map; - /** Items never observed on the Fetch layer before the timeout (e.g. a created item that never propagated) — surface as non-blocking warnings. */ - missingCreates: number[]; - /** Items observed but whose versionID never diverged from baseline (e.g. no-op republish) — keep the existing mapping value silently. */ - unchangedUpdates: number[]; -} - -/** Injectable dependencies so the poll loop is unit-testable without real timers/network. */ -export interface PollDeps { - getStatus: (guid: string, mode: FetchApiSyncMode) => Promise<{ inProgress: boolean }>; - waitForSync: (guid: string, mode: FetchApiSyncMode) => Promise; - sleep: (ms: number) => Promise; - now: () => number; -} - -const defaultDeps: PollDeps = { - getStatus: async (guid, mode) => getFetchApiStatus(guid, mode, false), - waitForSync: async (guid, mode) => { - await waitForFetchApiSync(guid, mode, true /* silent */); - }, - sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - now: () => Date.now(), -}; - -export interface PollOptions { - guid: string; - mode?: FetchApiSyncMode; - timeoutMs?: number; - backoffMs?: number; - maxIterations?: number; - deps?: Partial; -} - -/** - * Poll the Fetch layer until each item diverges from its baseline (or timeout). - * - * @param items items to resolve, each with its pre-publish baseline - * @param fetchItem resolves an item's current Fetch-layer state (null if not present yet) - */ -export async function resolvePublishedVersionIDs( - items: VersionResolutionItem[], - fetchItem: (id: number) => Promise, - options: PollOptions -): Promise { - const mode: FetchApiSyncMode = options.mode ?? "preview"; - const timeoutMs = options.timeoutMs ?? POLL_TIMEOUT_MS; - const backoffMs = options.backoffMs ?? POLL_BACKOFF_MS; - const maxIterations = options.maxIterations ?? POLL_MAX_ITERATIONS; - const deps: PollDeps = { ...defaultDeps, ...options.deps }; - - const resolved = new Map(); - // id -> baseline for the items still awaiting divergence - const remaining = new Map(items.map((i) => [i.id, i.baseline])); - // ids observed on the Fetch layer at least once (present, even if not yet diverged). - // Used to distinguish "never appeared" (warn) from "appeared but unchanged" (keep silently). - const seen = new Set(); - - const deadline = deps.now() + timeoutMs; - let iterations = 0; - - while (remaining.size > 0 && deps.now() < deadline && iterations < maxIterations) { - iterations++; - - // Backoff hint: if a CDN sync is in progress, wait for it to finish before - // fetching (efficient). inProgress is a HINT ONLY — not an exit condition. - const status = await deps.getStatus(options.guid, mode); - if (status.inProgress) { - await deps.waitForSync(options.guid, mode); - } - - // Fetch each remaining item; resolve the ones that have diverged. - for (const [id, baseline] of Array.from(remaining.entries())) { - const observed = await fetchItem(id); - if (observed) seen.add(id); - if (observed && observed.versionID !== baseline) { - resolved.set(id, observed); - remaining.delete(id); - } - } - - if (remaining.size === 0) break; - - // Not fully resolved. If no sync was in progress, we're in the propagation - // window (or the sync hasn't registered yet) — sleep before re-polling so we - // don't hot-loop. If a sync WAS in progress we already waited on it above, so - // loop straight back and re-check status. - if (!status.inProgress) { - await deps.sleep(backoffMs); - } - } - - // Classify whatever never diverged by whether we ever OBSERVED it on the Fetch - // layer — not by baseline. Newly-created items already carry a non-zero - // create-time targetVersionID in the mapping (addMapping), so baseline === 0 is - // not a reliable "is new" signal; "never observed" is. - const missingCreates: number[] = []; - const unchangedUpdates: number[] = []; - for (const id of Array.from(remaining.keys())) { - if (!seen.has(id)) { - // Never appeared on the Fetch API within the timeout (e.g. a created item - // that never propagated) — a real problem, surface as a non-blocking warning. - missingCreates.push(id); - } else { - // Observed, but its versionID never diverged from the baseline (e.g. a - // no-op republish). The mapping's existing value is already correct, so - // keep it silently — no drift, no error. - unchangedUpdates.push(id); - } - } - - return { resolved, missingCreates, unchangedUpdates }; -} - -/** - * Build a content-fetch API client for the target instance (preview layer), - * mirroring how download-sync-sdk.ts configures the sync client: - * previewKey + `${determineFetchUrl(guid)}/${guid}` as the baseUrl. - */ -function buildFetchApi(guid: string): any { - const { previewKey: apiKey } = getApiKeysForGuid(guid); - const fetchUrl = new Auth().determineFetchUrl(guid); - return getApi({ - guid, - apiKey, - isPreview: true, - // content-fetch composes `${baseUrl}/${fetch|preview}/${locale}/item/{id}`, - // so the guid must be part of baseUrl (same shape the sync SDK uses). - baseUrl: `${fetchUrl}/${guid}`, - }); -} - -/** Returns a fetcher that reads a content item's current versionID from the Fetch layer. */ -export function makeContentVersionFetcher( - guid: string, - locale: string -): (id: number) => Promise { - const api = buildFetchApi(guid); - return async (contentID: number) => { - try { - const item = await api.getContentItem({ contentID, locale }); - const versionID = item?.properties?.versionID; - if (typeof versionID !== "number") return null; - return { - versionID, - name: item?.fields?.title || item?.fields?.name || `Item ${contentID}`, - refName: item?.properties?.referenceName, - modelName: item?.properties?.definitionName, - }; - } catch { - // Not on the CDN yet (or transient) — treat as "not present, keep polling". - return null; - } - }; -} - -/** Returns a fetcher that reads a page's current versionID from the Fetch layer. */ -export function makePageVersionFetcher( - guid: string, - locale: string -): (id: number) => Promise { - const api = buildFetchApi(guid); - return async (pageID: number) => { - try { - const page = await api.getPage({ pageID, locale }); - const versionID = page?.properties?.versionID; - if (typeof versionID !== "number") return null; - return { - versionID, - name: page?.title || page?.name || `Page ${pageID}`, - refName: page?.name ? `/${page.name}` : undefined, - }; - } catch { - return null; - } - }; -} diff --git a/src/lib/mappers/tests/mapping-version-updater.test.ts b/src/lib/mappers/tests/mapping-version-updater.test.ts index d606b3f9..f98c3d5d 100644 --- a/src/lib/mappers/tests/mapping-version-updater.test.ts +++ b/src/lib/mappers/tests/mapping-version-updater.test.ts @@ -2,30 +2,20 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { resetState, setState } from "core/state"; +import * as stateModule from "core/state"; + +// Mock the API client so no real network calls happen +const mockGetContentItem = jest.fn(); +const mockGetPage = jest.fn(); -// Mock the filesystem getters to avoid real I/O beyond what we control -jest.mock("lib/getters/filesystem/get-content-items", () => ({ - getContentItemsFromFileSystem: jest.fn(), -})); -jest.mock("lib/getters/filesystem/get-pages", () => ({ - getPagesFromFileSystem: jest.fn(), -})); - -// Import after mocks are in place -import { getContentItemsFromFileSystem } from "lib/getters/filesystem/get-content-items"; -import { getPagesFromFileSystem } from "lib/getters/filesystem/get-pages"; import { updateContentMappingsAfterPublish, updatePageMappingsAfterPublish, updateMappingsAfterPublish, - VersionChangeDetail, } from "lib/mappers/mapping-version-updater"; import { ContentItemMapper } from "lib/mappers/content-item-mapper"; import { PageMapper } from "lib/mappers/page-mapper"; -const mockGetContentItems = getContentItemsFromFileSystem as jest.MockedFunction; -const mockGetPages = getPagesFromFileSystem as jest.MockedFunction; - let tmpDir: string; beforeAll(() => { @@ -45,8 +35,12 @@ beforeEach(() => { jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); jest.spyOn(console, "error").mockImplementation(() => {}); - mockGetContentItems.mockReturnValue([]); - mockGetPages.mockReturnValue([]); + mockGetContentItem.mockReset(); + mockGetPage.mockReset(); + jest.spyOn(stateModule, "getApiClient").mockReturnValue({ + contentMethods: { getContentItem: mockGetContentItem }, + pageMethods: { getPage: mockGetPage }, + } as any); }); afterEach(() => { @@ -99,25 +93,26 @@ describe("updateContentMappingsAfterPublish", () => { const result = await updateContentMappingsAfterPublish([], SRC, TGT, LOCALE); expect(result.updated).toBe(0); expect(result.errors).toHaveLength(0); + expect(mockGetContentItem).not.toHaveBeenCalled(); }); it("deduplicates published content IDs before processing", async () => { - mockGetContentItems.mockReturnValue([]); + mockGetContentItem.mockRejectedValue(new Error("not found")); const result = await updateContentMappingsAfterPublish([100, 100, 100], SRC, TGT, LOCALE); - // 100 is not found in filesystem → one error, not three + // 100 is not found on the target → one error, not three expect(result.errors).toHaveLength(1); + expect(mockGetContentItem).toHaveBeenCalledTimes(1); }); - it("records an error when a content item is not found in target filesystem", async () => { - mockGetContentItems.mockReturnValue([]); + it("records an error when a content item is not found on the target instance", async () => { + mockGetContentItem.mockRejectedValue(new Error("404")); const result = await updateContentMappingsAfterPublish([999], SRC, TGT, LOCALE); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0]).toMatch(/999/); }); it("records an error when no mapping exists for a target content ID", async () => { - const targetItem = makeContentItem({ contentID: 200, properties: { versionID: 7 } }); - mockGetContentItems.mockReturnValue([targetItem]); + mockGetContentItem.mockResolvedValue(makeContentItem({ contentID: 200, properties: { versionID: 7 } })); const result = await updateContentMappingsAfterPublish([200], SRC, TGT, LOCALE); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0]).toMatch(/200/); @@ -125,18 +120,17 @@ describe("updateContentMappingsAfterPublish", () => { it("updates the mapping successfully when item and mapping exist", async () => { seedContentMapper(10, 20, 5); - const targetItem = makeContentItem({ contentID: 20, properties: { versionID: 9 } }); - mockGetContentItems.mockReturnValue([targetItem]); + mockGetContentItem.mockResolvedValue(makeContentItem({ contentID: 20, properties: { versionID: 9 } })); const result = await updateContentMappingsAfterPublish([20], SRC, TGT, LOCALE); expect(result.updated).toBe(1); expect(result.errors).toHaveLength(0); expect(result.changes[0].newVersion).toBe(9); + expect(mockGetContentItem).toHaveBeenCalledWith(20, TGT, LOCALE); }); it("tracks change.changed as false when versionID is already up to date", async () => { seedContentMapper(10, 20, 5); - const targetItem = makeContentItem({ contentID: 20, properties: { versionID: 5 } }); - mockGetContentItems.mockReturnValue([targetItem]); + mockGetContentItem.mockResolvedValue(makeContentItem({ contentID: 20, properties: { versionID: 5 } })); const result = await updateContentMappingsAfterPublish([20], SRC, TGT, LOCALE); expect(result.changes[0].changed).toBe(false); }); @@ -149,18 +143,18 @@ describe("updatePageMappingsAfterPublish", () => { const result = await updatePageMappingsAfterPublish([], SRC, TGT, LOCALE); expect(result.updated).toBe(0); expect(result.errors).toHaveLength(0); + expect(mockGetPage).not.toHaveBeenCalled(); }); - it("records an error when a page is not found in target filesystem", async () => { - mockGetPages.mockReturnValue([]); + it("records an error when a page is not found on the target instance", async () => { + mockGetPage.mockRejectedValue(new Error("404")); const result = await updatePageMappingsAfterPublish([999], SRC, TGT, LOCALE); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0]).toMatch(/999/); }); it("records an error when no mapping exists for a target page ID", async () => { - const targetPage = makePage({ pageID: 500, properties: { versionID: 3 } }); - mockGetPages.mockReturnValue([targetPage]); + mockGetPage.mockResolvedValue(makePage({ pageID: 500, properties: { versionID: 3 } })); const result = await updatePageMappingsAfterPublish([500], SRC, TGT, LOCALE); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0]).toMatch(/500/); @@ -168,12 +162,12 @@ describe("updatePageMappingsAfterPublish", () => { it("updates the mapping successfully when page and mapping exist", async () => { seedPageMapper(1, 50, 1); - const targetPage = makePage({ pageID: 50, properties: { versionID: 8 } }); - mockGetPages.mockReturnValue([targetPage]); + mockGetPage.mockResolvedValue(makePage({ pageID: 50, properties: { versionID: 8 } })); const result = await updatePageMappingsAfterPublish([50], SRC, TGT, LOCALE); expect(result.updated).toBe(1); expect(result.errors).toHaveLength(0); expect(result.changes[0].newVersion).toBe(8); + expect(mockGetPage).toHaveBeenCalledWith(50, TGT, LOCALE); }); }); @@ -190,12 +184,10 @@ describe("updateMappingsAfterPublish", () => { it("processes both content and page IDs", async () => { seedContentMapper(10, 20, 1); - const targetContent = makeContentItem({ contentID: 20, properties: { versionID: 2 } }); - mockGetContentItems.mockReturnValue([targetContent]); + mockGetContentItem.mockResolvedValue(makeContentItem({ contentID: 20, properties: { versionID: 2 } })); seedPageMapper(1, 50, 1); - const targetPage = makePage({ pageID: 50, properties: { versionID: 2 } }); - mockGetPages.mockReturnValue([targetPage]); + mockGetPage.mockResolvedValue(makePage({ pageID: 50, properties: { versionID: 2 } })); const { result } = await updateMappingsAfterPublish([20], [50], SRC, TGT, LOCALE); expect(result.contentMappingsUpdated).toBe(1); @@ -203,8 +195,8 @@ describe("updateMappingsAfterPublish", () => { }); it("accumulates errors from both content and page processing", async () => { - mockGetContentItems.mockReturnValue([]); - mockGetPages.mockReturnValue([]); + mockGetContentItem.mockRejectedValue(new Error("404")); + mockGetPage.mockRejectedValue(new Error("404")); const { result } = await updateMappingsAfterPublish([999], [888], SRC, TGT, LOCALE); expect(result.errors.length).toBeGreaterThanOrEqual(2); }); @@ -215,8 +207,7 @@ describe("updateMappingsAfterPublish", () => { describe("VersionChangeDetail structure", () => { it("includes expected fields in change objects", async () => { seedContentMapper(10, 20, 5); - const targetItem = makeContentItem({ contentID: 20, properties: { versionID: 9 } }); - mockGetContentItems.mockReturnValue([targetItem]); + mockGetContentItem.mockResolvedValue(makeContentItem({ contentID: 20, properties: { versionID: 9 } })); const result = await updateContentMappingsAfterPublish([20], SRC, TGT, LOCALE); const change = result.changes[0]; expect(change).toMatchObject({ diff --git a/src/lib/mappers/tests/resolve-published-version-ids.test.ts b/src/lib/mappers/tests/resolve-published-version-ids.test.ts deleted file mode 100644 index cfba0734..00000000 --- a/src/lib/mappers/tests/resolve-published-version-ids.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { resolvePublishedVersionIDs, ObservedItem, PollDeps } from "../resolve-published-version-ids"; - -// Injected deps that never touch the network or real timers. -function makeDeps(overrides: Partial = {}): Partial { - return { - getStatus: jest.fn().mockResolvedValue({ inProgress: false }), - waitForSync: jest.fn().mockResolvedValue(undefined), - sleep: jest.fn().mockResolvedValue(undefined), - now: () => 1_000, // constant clock → termination is driven by maxIterations, not wall-clock - ...overrides, - }; -} - -const obs = (versionID: number): ObservedItem => ({ versionID }); - -describe("resolvePublishedVersionIDs", () => { - it("resolves an item once its versionID diverges from the baseline", async () => { - const fetchItem = jest.fn().mockResolvedValue(obs(7)); - - const result = await resolvePublishedVersionIDs([{ id: 1, baseline: 5 }], fetchItem, { - guid: "g", - deps: makeDeps(), - }); - - expect(result.resolved.get(1)).toEqual(obs(7)); - expect(result.missingCreates).toEqual([]); - expect(result.unchangedUpdates).toEqual([]); - }); - - it("resolves a created item (baseline 0) as soon as it appears", async () => { - const fetchItem = jest.fn().mockResolvedValue(obs(2)); - - const result = await resolvePublishedVersionIDs([{ id: 4, baseline: 0 }], fetchItem, { - guid: "g", - deps: makeDeps(), - }); - - expect(result.resolved.get(4)).toEqual(obs(2)); - expect(result.missingCreates).toEqual([]); - }); - - it("classifies a new item that never appears as a missing create (non-blocking)", async () => { - const fetchItem = jest.fn().mockResolvedValue(null); // never on the CDN - - const result = await resolvePublishedVersionIDs([{ id: 2, baseline: 0 }], fetchItem, { - guid: "g", - maxIterations: 3, - deps: makeDeps(), - }); - - expect(result.resolved.size).toBe(0); - expect(result.missingCreates).toEqual([2]); - expect(result.unchangedUpdates).toEqual([]); - }); - - it("keeps a no-op update (baseline never diverges) with no error", async () => { - const fetchItem = jest.fn().mockResolvedValue(obs(9)); // same as baseline forever - - const result = await resolvePublishedVersionIDs([{ id: 3, baseline: 9 }], fetchItem, { - guid: "g", - maxIterations: 3, - deps: makeDeps(), - }); - - expect(result.resolved.size).toBe(0); - expect(result.missingCreates).toEqual([]); - expect(result.unchangedUpdates).toEqual([3]); - }); - - it("flags a never-observed item as missing even with a non-zero baseline (created items carry a create-time version)", async () => { - const fetchItem = jest.fn().mockResolvedValue(null); // never on the CDN - - const result = await resolvePublishedVersionIDs([{ id: 8, baseline: 42 }], fetchItem, { - guid: "g", - maxIterations: 3, - deps: makeDeps(), - }); - - // Classification keys off "never observed", not baseline === 0, so a created - // item that already had a create-time versionID is still surfaced (not kept silently). - expect(result.resolved.size).toBe(0); - expect(result.missingCreates).toEqual([8]); - expect(result.unchangedUpdates).toEqual([]); - }); - - it("backs off with sleep when no sync is in progress, then resolves on a later poll", async () => { - const fetchItem = jest - .fn() - .mockResolvedValueOnce(null) // first poll: not propagated yet - .mockResolvedValueOnce(obs(2)); // second poll: appeared - const deps = makeDeps(); - - const result = await resolvePublishedVersionIDs([{ id: 4, baseline: 0 }], fetchItem, { - guid: "g", - backoffMs: 1234, - deps, - }); - - expect(result.resolved.get(4)).toEqual(obs(2)); - expect(deps.sleep).toHaveBeenCalledWith(1234); // backoff was used in the race window - expect(deps.waitForSync).not.toHaveBeenCalled(); // no sync was in progress - }); - - it("waits for an in-progress sync instead of sleeping", async () => { - const fetchItem = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(obs(3)); - const deps = makeDeps({ - getStatus: jest - .fn() - .mockResolvedValueOnce({ inProgress: true }) // first iteration: sync running - .mockResolvedValue({ inProgress: false }), - }); - - const result = await resolvePublishedVersionIDs([{ id: 5, baseline: 1 }], fetchItem, { - guid: "g", - deps, - }); - - expect(result.resolved.get(5)).toEqual(obs(3)); - expect(deps.waitForSync).toHaveBeenCalledTimes(1); - }); -});