From 0d2b89a4e53b72e84f379b53c4184c491ba72976 Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 2 Nov 2026 16:15:16 -0500 Subject: [PATCH] CI: wait for the Fetch API to show a round's edits before syncing The v1.3.0-beta.0 tag run failed one check: round 3 edited the same item on both sides, then synced seconds later, before A's edit had reached A's Fetch API feed (which the CLI pulls). The CLI saw only B's edit, rightly reported it as a target-only conflict, and the --overwrite resolve copied A's previous value onto B; once A's edit arrived, the no-op preflight wanted another update. Before each round's edits the harness now snapshots every content item's version on both instances (Management API), and after them waits until the Fetch API sync feed lists each changed item at its new version (up to 5 minutes) before syncing. The feed is a log of changes, not a full listing, so only the changed items are compared. A replay of the failed run (same seed, beta tier) passed every check. Co-Authored-By: Claude Opus 5.5 --- ci/permutation/fixtures.mjs | 72 +++++++++++++++++++++++++++++++++++++ ci/permutation/run.mjs | 24 ++++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/ci/permutation/fixtures.mjs b/ci/permutation/fixtures.mjs index d3d214a..1b0f908 100644 --- a/ci/permutation/fixtures.mjs +++ b/ci/permutation/fixtures.mjs @@ -150,6 +150,78 @@ export async function waitForFetchSync(token, guid, locale, ids, { timeoutMs = 1 throw new Error(`items ${[...missing].join(", ")} not in the Fetch API sync feed after ${timeoutMs / 1000}s`); } +// Each content item's latest version (contentItemID) in the Management API, by content ID. Taken +// before and after a round's edits, the difference is what the round changed. +export async function contentVersions(c, guid, locale) { + const lists = (await c.containerMethods.getContainerList(guid)).filter( + (x) => x.referenceName && x.contentDefinitionType === 1 + ); + const versions = new Map(); + for (const l of lists) { + let rows = []; + try { + const r = await c.contentMethods.getContentItems(l.referenceName, guid, locale, { + take: 250, + skip: 0, + filter: "", + fields: "", + sortDirection: "", + sortField: "", + showDeleted: false, + }); + rows = (r?.items ?? []).flat(Infinity); + } catch { + continue; + } + for (const row of rows) { + const id = row.contentID ?? row.itemContainerID; + if (id > 0 && row.contentItemID > 0) versions.set(id, Math.max(versions.get(id) ?? 0, row.contentItemID)); + } + } + return versions; +} + +// Items whose version went up (or that are new) between two contentVersions snapshots. +export function changedSince(before, after) { + return new Map([...after].filter(([id, v]) => v > (before.get(id) ?? 0))); +} + +// The Fetch API sync feed (a log of changes) lags the Management API, and every sync and verify +// reads it. Wait until it lists each given item at least at the given version, so a sync never acts +// on an edit it can't see yet. Returns the seconds waited; throws on timeout. +export async function waitForFetchVersions(token, guid, locale, wanted, { timeoutMs = 5 * 60 * 1000 } = {}) { + if (wanted.size === 0) return 0; + const { mgmt, api } = hosts(guid); + const keyRes = await fetch(`${mgmt}/oauth/GetPreviewKey?guid=${guid}`, { + headers: { Authorization: `Bearer ${token}`, "Cache-Control": "no-cache" }, + }); + if (!keyRes.ok) throw new Error(`GetPreviewKey: HTTP ${keyRes.status}`); + const apiKey = (await keyRes.text()).replace(/^"|"$/g, ""); + + const started = Date.now(); + let behind = []; + while (Date.now() - started < timeoutMs) { + const have = new Map(); + let syncToken = 0; + for (let page = 0; page < 100; page++) { + const res = await fetch(`${api}/${guid}/preview/${locale}/sync/items?syncToken=${syncToken}&pageSize=500`, { + headers: { APIKey: apiKey, "Cache-Control": "no-cache" }, + }); + if (!res.ok) throw new Error(`Fetch API sync/items: HTTP ${res.status}`); + const body = await res.json(); + for (const item of body.items ?? []) { + have.set(item.contentID, Math.max(have.get(item.contentID) ?? 0, item.properties?.versionID ?? 0)); + } + if (!body.items?.length || !body.syncToken || body.syncToken === syncToken) break; + syncToken = body.syncToken; + } + behind = [...wanted].filter(([id, v]) => (have.get(id) ?? 0) < v).map(([id]) => id); + if (behind.length === 0) return Math.round((Date.now() - started) / 1000); + await new Promise((r) => setTimeout(r, 10_000)); + } + throw new Error(`Fetch API still behind after ${timeoutMs / 1000}s on items ${behind.slice(0, 10).join(", ")}`); +} + // After a sync: each target copy's Next (and NextID) must hold the TARGET ID of its partner. export async function checkReferenceCycles(c, guid, locale, fixture, toTarget) { const problems = []; diff --git a/ci/permutation/run.mjs b/ci/permutation/run.mjs index 3b38056..28b3388 100644 --- a/ci/permutation/run.mjs +++ b/ci/permutation/run.mjs @@ -29,7 +29,14 @@ import { spawnSync } from "node:child_process"; import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { CHANGES, QUICK_ROUNDS, client, conflict, drift, readField, rng } from "./changes.mjs"; -import { checkReferenceCycles, seedReferenceCycles, waitForFetchSync } from "./fixtures.mjs"; +import { + changedSince, + checkReferenceCycles, + contentVersions, + seedReferenceCycles, + waitForFetchSync, + waitForFetchVersions, +} from "./fixtures.mjs"; const { SOURCE_GUID: A, TARGET_GUID: B, AGILITY_TOKEN, CLI } = process.env; const TIER = process.env.TIER || "quick"; @@ -224,6 +231,10 @@ for (let i = 0; i < plan.length; i++) { }; const toCtx = { c: api, guid: to, locale: LOCALE, round, side: forward ? "target" : "source", r, state: state[to] }; const applied = []; + const versionsBefore = { + [from]: await contentVersions(api, from, LOCALE), + [to]: await contentVersions(api, to, LOCALE), + }; for (const name of plan[i]) { try { @@ -256,6 +267,17 @@ for (let i = 0; i < plan.length; i++) { `\n── round ${round} (${direction}): ${applied.map((a) => `${a.area}/${a.change}`).join(", ") || "no changes"}` ); + // The sync reads both instances through the Fetch API: wait until it shows this round's edits + for (const guid of [from, to]) { + try { + const changed = changedSince(versionsBefore[guid], await contentVersions(api, guid, LOCALE)); + const waited = await waitForFetchVersions(AGILITY_TOKEN, guid, LOCALE, changed); + if (changed.size) console.log(` Fetch API showed ${changed.size} edited item(s) on ${guid} after ${waited}s`); + } catch (e) { + record(round, direction, "apply:fetch-catch-up", false, String(e?.message ?? e).slice(0, 200)); + } + } + const { reported } = syncStep(round, direction); let kept = false;