Setting the file. One moment. Write Verification · Wix Replatform · wix/skills · Skills Docsconst onlyServerPhysical
— line 157
This file
- Number
- 45.170
- Position
- 170 of 182
- Type
- JavaScript
- Size
- 30 KB
- Lines
- 409
lib/write-verification.js
JavaScript·409 lines·30 KB
'../resources/rp-target-wix/lib/wix-build.js'
);
6const { choiceNames } = require('../resources/rp-target-wix/lib/variant-correlation.js');
7const email = value => typeof value === 'string' ? value.trim().toLowerCase() : value;
8// Mirrors the same named+numeric HTML-entity decode every source adapter already needs for its
9// own "plain text fields are HTML-entity-encoded but never DOM-parsed" trap (e.g.
10// rp-import-codegen's product-transform.js decodeEntities): a source HTML body commonly carries
11// numeric/hex entities (LIVE-VERIFIED: WooCommerce ships an en dash as `–`) that Wix's own
12// server-side HTML->Ricos conversion decodes into the real character on read-back. Comparing
13// visible text without first decoding the SENT side's entities the same way would see every such
14// entity as a permanent mismatch, which is exactly the false failure this comparator exists to
15// avoid. Kept local (not required from a project's transform code) because write-verification.js
16// must not depend on any per-project generated file.
17// Live-diagnosed gap (2026-09-28): a 6-entry map covered only the XML-reserved five plus nbsp.
18// WordPress's own `wptexturize` (which runs on every post/product description by default)
19// rewrites plain typography into exactly these named entities -- straight quotes into curly
20// ones, `--`/`---` into en/em dashes, `...` into an ellipsis -- so real WooCommerce/WordPress
21// content routinely contains `—`, `…`, `’`, `©`, and similar even when the
22// author never typed a literal entity. Any of these left undecoded on the "expected" (sent) side
23// while Wix's Ricos round-trip decodes them on the "actual" (read-back) side produced exactly the
24// false verification mismatch this comparator was built to eliminate, just for a different name
25// than the numeric-entity case it already handled. Extended to the common WordPress/WooCommerce
26// typographic and symbol set, not just the XML-reserved five -- still a static table, not the
27// full HTML5 named-character-reference list, because this file has no external dependency and a
28// generated description is human-authored prose, not arbitrary markup.
29const NAMED_HTML_ENTITIES = {
30 amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
31 // wptexturize's own typographic substitutions
32 mdash: '—', ndash: '–', hellip: '…',
33 lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”',
34 sbquo: '‚', bdquo: '„', laquo: '«', raquo: '»',
35 // Common symbols that show up in product/blog copy
36 copy: '©', reg: '®', trade: '™', deg: '°',
37 plusmn: '±', times: '×', divide: '÷', middot: '·',
38 sect: '§', para: '¶', bull: '•', dagger: '†',
39 Dagger: '‡', permil: '‰', prime: '′', Prime: '″',
40 euro: '€', pound: '£', yen: '¥', cent: '¢', curren: '¤',
41 // BUG FIX: still no Latin-1 accented-letter entities -- common in
42 // WooCommerce/WordPress content authored or copy-pasted via older tools/CMSes. The full HTML4
43 // Latin-1-Supplement letter block, both cases (both are real, distinct named references).
44 agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä', aring: 'å', aelig: 'æ', ccedil: 'ç',
45 egrave: 'è', eacute: 'é', ecirc: 'ê', euml: 'ë',
46 igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï',
47 eth: 'ð', ntilde: 'ñ',
48 ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö', oslash: 'ø',
49 ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü',
50 yacute: 'ý', thorn: 'þ', yuml: 'ÿ', szlig: 'ß',
51 Agrave: 'À', Aacute: 'Á', Acirc: 'Â', Atilde: 'Ã', Auml: 'Ä', Aring: 'Å', AElig: 'Æ', Ccedil: 'Ç',
52 Egrave: 'È', Eacute: 'É', Ecirc: 'Ê', Euml: 'Ë',
53 Igrave: 'Ì', Iacute: 'Í', Icirc: 'Î', Iuml: 'Ï',
54 ETH: 'Ð', Ntilde: 'Ñ',
55 Ograve: 'Ò', Oacute: 'Ó', Ocirc: 'Ô', Otilde: 'Õ', Ouml: 'Ö', Oslash: 'Ø',
56 Ugrave: 'Ù', Uacute: 'Ú', Ucirc: 'Û', Uuml: 'Ü',
57 Yacute: 'Ý', THORN: 'Þ',
58};
59function decodeHtmlEntities(value) {
60 // BUG FIX: the numeric-hex branch's prefix was a literal lowercase `x`
61 // (`#x?`), with no `i` flag on the regex -- so an uppercase `—` matched neither
62 // alternative (the hex-prefix alternative requires the literal "x", and "X" is not itself a
63 // valid hex digit once the optional "x" fails to consume it) and was left completely
64 // undecoded, even though the handler below already anticipates `entity[1] === 'X'`. Widened to
65 // `#[xX]?` so both cases of the hex prefix match; the named-entity branch (`[a-zA-Z]+`) is
66 // deliberately left case-sensitive, since HTML named references are themselves case-sensitive
67 // (`&` is a valid reference, `&` is not) and NAMED_HTML_ENTITIES is keyed accordingly.
68 return String(value == null ? '' : value).replace(/&(#[xX]?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity) => {
69 if (entity[0] === '#') {
70 const code = entity[1] === 'x' || entity[1] === 'X' ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
71 return Number.isFinite(code) ? String.fromCodePoint(code) : match;
72 }
73 return Object.prototype.hasOwnProperty.call(NAMED_HTML_ENTITIES, entity) ? NAMED_HTML_ENTITIES[entity] : match;
74 });
75}
76// BUG FIX: this used to strip ALL whitespace (`.replace(/\s+/g, '')`)
77// on top of normalizedDescriptionText's own collapse-to-single-space -- which destroys the very
78// word boundaries a re-serialization difference needs to be judged against. "Fresh Roasted Coffee
79// Beans" (correct) and "FreshRoasted CoffeeBeans" (a genuine lost word boundary) both collapsed to
80// the identical "FreshRoastedCoffeeBeans" once every space vanished, so a real word-merge/split
81// corruption verified as a pass. normalizedDescriptionText already collapses every run of
82// whitespace (including tag-boundary spaces introduced by stripping markup) to exactly one space
83// and trims the ends -- since decodeHtmlEntities+normalizedDescriptionText run identically on both
84// the sent and read-back sides, that single-space normalization is already symmetric and already
85// tolerates markup/entity re-serialization; no further whitespace removal is needed or safe.
86// Inline elements do not separate words when rendered ("<span>Brand</span><span>Acme</span>"
87// displays "BrandAcme", and Stores V3 reads it back that way, LIVE-VERIFIED 2026-10-15), so they
88// are removed without a space; every other tag still separates words.
89const INLINE_TAG = /<\/?(?:a|abbr|b|bdi|bdo|cite|code|data|dfn|em|font|i|kbd|mark|q|s|samp|small|span|strong|sub|sup|time|u|var)\b[^>]*>/gi;
90// Tags (and comments) are removed BEFORE entities are decoded: an attribute value may carry encoded
91// markup (LIVE-OBSERVED 2026-10-22: class="[&:has(...)>*]:..." in descriptions pasted from a
92// chat UI), and decoding first turns its > into a '>' that ends the tag early and leaks attribute
93// text into the "visible" text. Wix renders only the element text, so a read-back never matched.
94const stripMarkup = html => html.replace(/<!--[\s\S]*?-->/g, ' ').replace(/<[^>]*>/g, ' ');
95const visibleDescriptionText = html => normalizedDescriptionText(decodeHtmlEntities(stripMarkup(String(html == null ? '' : html).replace(INLINE_TAG, ''))));
96// LIVE-VERIFIED (2026-10-15): the stored Ricos description keeps every <br> as
97// a "\n" text node, but the plainDescription read-back serializes a <br> run either as one
98// space ("here<br />and" -> "here and", 2026-09-27) or as nothing, trimming the whitespace beside
99// it ("Tools! <br><br>Next" -> "Tools!Next"). At a <br> position, accept exactly those two
100// outcomes; every other character and every other whitespace must still match.
101const BREAK_MARK = '\uE000';
102const BREAK_RUN = /(?:\s| )*(?:<br\b[^>]*>(?:\s| )*)+/gi;
103const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
104function descriptionTextMatches(expectedHtml, actualHtml) {
105 const wantsText = visibleDescriptionText(expectedHtml);
106 const actualText = visibleDescriptionText(actualHtml);
107 if (wantsText === '') return false;
108 if (wantsText === actualText) return true;
109 const segments = visibleDescriptionText(String(expectedHtml).replace(BREAK_RUN, ` ${BREAK_MARK} `)).split(new RegExp(`\\s*${BREAK_MARK}\\s*`));
110 if (segments.length < 2) return false;
111 return new RegExp(`^${segments.map(escapeRegExp).join(' ?')}$`).test(actualText);
112}
113const digest = value => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
114const own = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k);
115
116// Expected fields come from the source projection. Extra server fields do not satisfy
117// missing fields, and null/empty/false/zero remain distinct. No heuristic normalization.
118function assertProjection(value, path = 'expected') {
119 if (value === undefined) throw new Error(`Undefined verification value at ${path}; omit the key or supply an explicit JSON value`);
120 if (value && typeof value === 'object') {
121 if (Array.isArray(value)) for (let i = 0; i < value.length; i++) assertProjection(value[i], `${path}[${i}]`);
122 else for (const [key, child] of Object.entries(value)) assertProjection(child, `${path}.${key}`);
123 }
124}
125// LIVE-VERIFIED (2026-10-20, live product canary): Stores V3 accepts `displayName` on a
126// url-set media item but never returns it on read -- neither on the item nor under item.image.
127// The ingested file keeps a name in `image.filename` (extension included), so that is the
128// per-item name that survives the rehost. A sent displayName is matched by, in order:
129// an echoed displayName (exact), else the filename whose stem (or whole name) equals the
130// displayName as the writer sends it (trimmed, at most 80 characters), or the filename equal to
131// the sent url's own decoded file name. Anything else -- no filename, a different file, a swapped
132// slot -- does not match. Counting items never stands in for this check.
133const MEDIA_DISPLAY_NAME_SENT_MAX = 80;
134function sentUrlFileName(url) {
135 if (typeof url !== 'string') return undefined;
136 try {
137 const name = new URL(url).pathname.split('/').pop();
138 return name ? decodeURIComponent(name) : undefined;
139 } catch { return undefined; }
140}
141function mediaItemNameMatches(item, child) {
142 const sent = item.displayName.trim().slice(0, MEDIA_DISPLAY_NAME_SENT_MAX);
143 const echoed = child?.displayName || child?.image?.displayName || child?.video?.displayName;
144 if (typeof echoed === 'string' && echoed !== '') return echoed === item.displayName || echoed === sent;
145 const filename = child?.image?.filename || child?.video?.filename;
146 if (typeof filename !== 'string' || filename === '') return false;
147 const stem = filename.replace(/\.[^./]+$/, '');
148 // LIVE-VERIFIED (2026-10-22): the stored file name replaces each '"' of the sent title with '_'.
149 const stored = sent.replace(/"/g, '_');
150 return filename === sent || stem === sent || stem === stored || filename === stored || filename === sentUrlFileName(item.url);
151}
152// LIVE-VERIFIED (2026-10-21): a PHYSICAL product is created with the required empty
153// product-level physicalProperties, and Stores V3 reads it back with a shippingWeightRange it
154// derives from the variant weights. Only that server-derived key is tolerated beside an empty
155// expected object; variant weights are still compared where they were sent.
156const SERVER_DERIVED_PHYSICAL_KEYS = new Set(['shippingWeightRange']);
157const onlyServerPhysical = a => a !== null && typeof a === 'object' && !Array.isArray(a) && Object.keys(a).every(k => SERVER_DERIVED_PHYSICAL_KEYS.has(k));
158// LIVE-VERIFIED (2026-10-21): variant choices sent as optionChoiceNames read back as
159// optionChoiceIds. The comparison view resolves them through the product's option/choice
160// dictionary; an id that does not resolve to exactly one option and choice stays as returned, so
161// it cannot match a sent name.
162// LIVE-VERIFIED (2026-10-31): Stores V3 reads a variant's stock flag back as
163// inventoryStatus.inStock, not under the inventoryItem the writer sent it in; the view places the
164// read flag where it was sent. A read that does carry inventoryItem.inStock is left as returned.
165function variantInventoryView(variant) {
166 const inStock = variant?.inventoryStatus?.inStock;
167 if (typeof inStock !== 'boolean' || variant.inventoryItem?.inStock !== undefined) return variant;
168 return { ...variant, inventoryItem: { ...(variant.inventoryItem || {}), inStock } };
169}
170function variantComparisonView(variant, product) {
171 const view = variantInventoryView(variant);
172 if (!Array.isArray(view?.choices)) return view;
173 try {
174 return { ...view, choices: view.choices.map(c => c?.optionChoiceNames ? c : { optionChoiceNames: choiceNames(c, product) }) };
175 } catch { return view; }
176}
177// LIVE-VERIFIED (2026-10-21, Tuti first-batch probe): the Media Manager file created from a
178// url-set product media item keeps the sent displayName with the ORIGINAL file's extension
179// appended ("ceramic-mug" -> "ceramic-mug.jpg" for source file ceramic-mug-1.jpg). A title landed
180// when the stored name equals it exactly, or equals it plus that file's own extension. The
181// original file name, another extension or a case-changed title does not count.
182function mediaTitleLanded(landedName, sentItem) {
183 if (typeof landedName !== 'string' || !sentItem || typeof sentItem.displayName !== 'string' || sentItem.displayName === '') return false;
184 if (landedName === sentItem.displayName) return true;
185 const ext = (sentUrlFileName(sentItem.url) || '').match(/\.[A-Za-z0-9]{1,5}$/);
186 return Boolean(ext) && landedName === `${sentItem.displayName}${ext[0]}`;
187}
188// A within-write media dependency is verified per intended item, never by counting: every sent
189// item must have landed as its own type in its slot, and the same comparator product read-back
190// uses must match its altText/displayName evidence. Two slots holding one media ID are a
191// substitution unless both slots were sent the same source URL (a repeated source image). An item
192// sent with neither altText nor displayName has no surviving identity, so it cannot be verified.
193function correlateProductMedia(expectedItems, actualItems) {
194 if (!Array.isArray(expectedItems) || !expectedItems.length) throw new Error('correlateProductMedia: expectedItems must be a non-empty array');
195 const blank = value => typeof value !== 'string' || value.trim() === '';
196 if (expectedItems.some(item => !item || blank(item.altText) && blank(item.displayName))) return { verified: false, correlatable: false, reasons: ['media-identity-uncorrelatable'], mediaIds: [] };
197 const items = Array.isArray(actualItems) ? actualItems : [];
198 const mediaIds = items.map(item => item?.mediaType === 'VIDEO' ? item.video?.id : (item?.mediaType === undefined || item?.mediaType === 'IMAGE') ? item.image?.id : undefined);
199 const reasons = [];
200 if (items.length !== expectedItems.length) reasons.push('media-count-mismatch');
201 expectedItems.forEach((item, i) => { if (!mediaIds[i]) reasons.push(`media-not-landed:${i}`); });
202 const comparison = compareProjection({ media: { itemsInfo: { items: expectedItems } } }, { media: { itemsInfo: { items } } });
203 for (const check of comparison.checks) if (!check.matches) reasons.push(`media-mismatch:${check.path}`);
204 for (let i = 0; i < mediaIds.length; i++) for (let j = i + 1; j < mediaIds.length; j++) {
205 if (mediaIds[i] && mediaIds[i] === mediaIds[j] && expectedItems[i]?.url !== expectedItems[j]?.url) reasons.push(`media-duplicate-substitution:${i}:${j}`);
206 }
207 return { verified: reasons.length === 0, correlatable: true, reasons: [...new Set(reasons)], mediaIds };
208}
209function leafScore(wanted, actual) {
210 if (wanted && typeof wanted === 'object') return Object.entries(wanted).reduce((n, [k, v]) => n + leafScore(v, actual?.[k]), 0);
211 return isDeepStrictEqual(wanted, actual) ? 1 : 0;
212}
213// Pairs an unordered list one to one (Contacts V5 address items). `matches` must be
214// the caller's own final comparison of one item, so pairing can never disagree with it. Full matches come
215// from a maximum bipartite matching, so a less specific item never takes the only actual item a
216// more specific one can match. A wanted item left without a full match is paired with its most
217// similar leftover actual item, so a changed field reports as changed, not dropped; with none
218// left it pairs with undefined. Returns [wanted, actual | undefined] in wanted order.
219function pairUnordered(wanted, actual, matches) {
220 if (typeof matches !== 'function') throw new Error('pairUnordered requires the caller\'s item comparison');
221 const pool = Array.isArray(actual) ? actual : [];
222 const owner = new Array(pool.length).fill(-1);
223 const assign = (i, seen) => {
224 for (let j = 0; j < pool.length; j++) {
225 if (seen[j] || !matches(wanted[i], pool[j])) continue;
226 seen[j] = true;
227 if (owner[j] < 0 || assign(owner[j], seen)) { owner[j] = i; return true; }
228 }
229 return false;
230 };
231 for (let i = 0; i < wanted.length; i++) assign(i, new Array(pool.length).fill(false));
232 const pairedTo = new Array(wanted.length).fill(-1);
233 owner.forEach((i, j) => { if (i >= 0) pairedTo[i] = j; });
234 const free = new Set(pool.map((_, j) => j).filter(j => owner[j] < 0));
235 for (let i = 0; i < wanted.length; i++) {
236 if (pairedTo[i] >= 0 || !free.size) continue;
237 let best = -1, bestScore = -1;
238 for (const j of free) { const score = leafScore(wanted[i], pool[j]); if (score > bestScore) { best = j; bestScore = score; } }
239 pairedTo[i] = best; free.delete(best);
240 }
241 return wanted.map((item, i) => [item, pairedTo[i] >= 0 ? pool[pairedTo[i]] : undefined]);
242}
243function compareProjection(expected, actual, options = {}) {
244 assertProjection(expected);
245 const checks = projectionChecks(expected, actual, options);
246 return { verified: checks.length > 0 && checks.every(c => c.matches), checks };
247}
248// The comparison itself, also used to pair one unordered item with its own rules.
249function projectionChecks(expected, actual, { kind = '', path = '' } = {}) {
250 const checks = [];
251 const itemVerifies = (e, a, p) => { const sub = projectionChecks(e, a, { kind, path: p }); return sub.length > 0 && sub.every(c => c.matches); };
252 function compare(e, a, p, present) {
253 if (kind === 'contact' && p === 'email.email') {
254 checks.push({ path: p, matches: present && email(e) === email(a) });
255 } else if (p === 'plainDescription' && typeof e === 'string') {
256 // LIVE-VERIFIED (2026-09-27): Stores V3's Get Product does not
257 // echo plainDescription -- it converts the HTML to a Ricos document server-side and reads
258 // back a RE-SERIALIZATION of that document, not the sent bytes (e.g. a source <strong>
259 // becomes <span style="font-weight: 700">, and HTML entities get decoded). That is Wix's
260 // own conversion, unavoidable and benign, so this leaf compares VISIBLE TEXT -- the same
261 // extractor wix-build.js's composeProductDescription uses to decide whether a description
262 // is real content -- never markup. A genuinely different or lost description still fails:
263 // this tolerates re-serialization, not a text change, and never passes on empty text.
264 checks.push({ path: p, matches: present && typeof a === 'string' && descriptionTextMatches(e, a) });
265 } else if (/(^|\.)amount$/.test(p) && typeof e === 'string' && /^-?\d+(\.\d+)?$/.test(e.trim())) {
266 // LIVE-VERIFIED (2026-10-15): Stores V3 normalizes decimal strings on
267 // read-back -- a sent price "65.00" comes back "65", "21.20" comes back "21.2". The money
268 // value is identical, so an amount leaf compares numerically; a different amount (or a
269 // missing / non-numeric one) still fails.
270 checks.push({ path: p, matches: present && typeof a === 'string' && /^-?\d+(\.\d+)?$/.test(a.trim()) && Number(e) === Number(a) });
271 } else if (Array.isArray(e) && /(^|\.)media\.itemsInfo\.items$/.test(p)) {
272 // LIVE-VERIFIED (2026-09-27): every product image is rehosted
273 // to Wix's own CDN (static.wixstatic.com) under a server-minted id/url the instant it
274 // ingests -- expected, unavoidable platform behavior, not data loss. The url this
275 // comparator sent will therefore never appear in the read-back, so identity here is never
276 // the url (or a filename derived from it): it is the item COUNT, plus -- for whichever
277 // items the writer's own record shows we asked to carry alt text -- that altText survives
278 // the rehost verbatim (LIVE-VERIFIED: Wix echoes it back exactly, both on the item itself
279 // and nested under item.image). A missing/extra image, or a changed altText, still fails.
280 //
281 // KNOWN LIMITATION: this cannot detect two
282 // images swapped between slots, or an entirely wrong image landing in a slot, when neither
283 // altText nor displayName was set on the sent side -- common for sources with no alt text.
284 // `buildMedia` (wix-build.js) sends only `{url, altText, displayName}` per item; the sent
285 // `url` is the ONLY other candidate per-item identity and it is exactly the field this
286 // comment already establishes never survives the rehost, so nothing else is available to
287 // compare here without new information from Wix's API. This check also assumes rehosted
288 // items keep the SENT order (`a[i]` correlates to `e[i]` throughout this block) -- that
289 // assumption is undocumented and has not been independently live-verified either; if Wix's
290 // rehost does not preserve order, the existing altText/displayName checks below could
291 // already be comparing against the wrong item, not just this count check.
292 checks.push({ path: p, matches: Array.isArray(a) && e.length === a.length });
293 e.forEach((item, i) => {
294 const child = Array.isArray(a) ? a[i] : undefined;
295 const alt = item && typeof item === 'object' ? item.altText : undefined;
296 if (typeof alt === 'string' && alt.trim() !== '') {
297 // `??` only falls through on null/undefined -- an actual side that carries altText as
298 // an EMPTY STRING (the rehost keeping the field but losing its value) must still count
299 // as "did not survive", the same as a missing field entirely. `||` falls through on any
300 // falsy value, which is what "only check the field when non-empty" means everywhere else
301 // in this comparator (see isBlank-style checks in wix-build.js).
302 const actualAlt = child?.altText || child?.image?.altText;
303 checks.push({ path: `${p}[${i}].altText`, matches: Boolean(child) && actualAlt === alt });
304 }
305 // wix-build.js's buildMedia writes displayName on every media item it constructs whenever
306 // the source record has one (domains/stores/entities/product.json: displayName "must not
307 // be flattened away"). Only check it when the writer's own record shows we asked to carry
308 // it -- the same "only check what we actually asked for" pattern altText uses above -- so
309 // a product with no displayName at all is never penalized for it being absent. Wix does
310 // not echo displayName; see mediaItemNameMatches for what stands in for it on read.
311 const displayName = item && typeof item === 'object' ? item.displayName : undefined;
312 if (typeof displayName === 'string' && displayName.trim() !== '') {
313 checks.push({ path: `${p}[${i}].displayName`, matches: Boolean(child) && mediaItemNameMatches(item, child) });
314 }
315 });
316 } else if (Array.isArray(e) && kind === 'contact' && p === 'addresses') {
317 // Contact addresses are an unordered set; the read-back comparison pairs them the same way.
318 checks.push({ path: p, matches: Array.isArray(a) && e.length === a.length });
319 pairUnordered(e, a, (v, candidate) => itemVerifies(v, candidate, `${p}[]`)).forEach(([v, match], i) => compare(v, match, `${p}[${i}]`, match !== undefined));
320 } else if (Array.isArray(e)) {
321 checks.push({ path: p, matches: Array.isArray(a) && e.length === a.length });
322 e.forEach((v, i) => compare(v, a?.[i], `${p}[${i}]`, Array.isArray(a) && i < a.length));
323 } else if (e && typeof e === 'object') {
324 if (!Object.keys(e).length && p === 'physicalProperties') checks.push({ path: p, matches: present && onlyServerPhysical(a) });
325 else if (!Object.keys(e).length) checks.push({ path: p, matches: present && isDeepStrictEqual(e, a) });
326 else for (const [k, v] of Object.entries(e)) compare(v, a?.[k], p ? `${p}.${k}` : k, own(a, k));
327 } else checks.push({ path: p, matches: present && isDeepStrictEqual(e, a) });
328 }
329 compare(expected, actual, path, actual !== undefined);
330 return checks;
331}
332function contactIdentity(expected, actual, id) {
333 const checks = [{ path: 'id', matches: contactId(id) && actual?.id === id && !actual?._dryRunPlaceholder }];
334 const hasExternal = typeof expected?.externalId === 'string' && expected.externalId.trim() !== '';
335 const hasEmail = typeof expected?.email?.email === 'string' && email(expected.email.email) !== '';
336 checks.push({ path: 'source-identity', matches: hasExternal || hasEmail });
337 if (hasExternal) checks.push({ path: 'externalId', matches: actual?.externalId === expected.externalId });
338 if (hasEmail) checks.push({ path: 'email.email', matches: email(actual?.email?.email) === email(expected.email.email) });
339 return { verified: checks.every(c => c.matches), checks };
340}
341function verifyContact(expected, actual, id) {
342 const identity = contactIdentity(expected, actual, id);
343 const values = compareProjection(expected, actual, { kind: 'contact' });
344 return { verified: identity.verified && values.verified, checks: [...identity.checks, ...values.checks] };
345}
346function subscriptionExpected(product) {
347 const value = product?.subscriptionDetails;
348 const entries = value?.subscriptions;
349 if (!Array.isArray(entries) || !entries.length || entries.some(s => !s?.title) || new Set(entries.map(s => s.title)).size !== entries.length) {
350 throw new Error('Subscription verification requires nonempty, uniquely titled subscriptions');
351 }
352 if (typeof value.allowOneTimePurchases !== 'boolean' || entries.some(s =>
353 !['title', 'description', 'frequency', 'interval', 'autoRenewal'].every(k => own(s, k)))) {
354 throw new Error('Subscription verification requires the complete expected field projection');
355 }
356 return JSON.parse(JSON.stringify(value));
357}
358function verifySubscriptionEvidence(evidence) {
359 try {
360 if (!evidence || evidence.dryRun || !opaqueId(evidence.targetId)) throw new Error('Invalid evidence');
361 const expected = subscriptionExpected({ subscriptionDetails: evidence.expected });
362 const actual = evidence.actual;
363 const entries = actual?.subscriptionDetails?.subscriptions;
364 const checks = [{ path: 'product.id', matches: actual?.id === evidence.targetId && !actual?._dryRunPlaceholder },
365 { path: 'subscriptionDetails.subscriptions.length', matches: Array.isArray(entries) && entries.length === expected.subscriptions.length },
366 { path: 'subscriptionDetails.allowOneTimePurchases', matches: actual?.subscriptionDetails?.allowOneTimePurchases === expected.allowOneTimePurchases }];
367 for (const wanted of expected.subscriptions) {
368 const matches = (entries || []).filter(s => s?.title === wanted.title);
369 const entry = matches.length === 1 ? matches[0] : undefined;
370 checks.push({ path: `subscriptions:${wanted.title}.id`, matches: matches.length === 1 && opaqueId(entry?.id) });
371 checks.push(...compareProjection(wanted, entry, { path: `subscriptions:${wanted.title}` }).checks);
372 }
373 return { verified: checks.every(c => c.matches), checks };
374 } catch { return { verified: false, checks: [{ path: 'evidence', matches: false }] }; }
375}
376function verifyProduct(expected, actual, id, { marker, variants = [] } = {}) {
377 const checks = [{ path: 'id', matches: opaqueId(id) && actual?.id === id && !actual?._dryRunPlaceholder }];
378 // Marker must have been selected from the source, not filled from the returned entity.
379 checks.push({ path: 'source-marker', matches: Boolean(marker?.path) && marker.value !== undefined && marker.value !== '' });
380 if (marker?.path) {
381 const value = marker.path.split('.').reduce((v, k) => v?.[k], actual);
382 checks.push({ path: marker.path, matches: isDeepStrictEqual(marker.value, value) });
383 }
384 checks.push(...compareProjection(expected, actual).checks);
385 const targetVariants = actual?.variantsInfo?.variants;
386 const seen = new Set();
387 for (const v of variants) {
388 const matches = (targetVariants || []).filter(t => t?.id === v.variantId);
389 checks.push({ path: `variant:${v.variantId}`, matches: opaqueId(v.variantId) && !seen.has(v.variantId) && matches.length === 1 });
390 seen.add(v.variantId);
391 checks.push(...compareProjection(v.expected, variantComparisonView(matches[0], actual)).checks);
392 }
393 return { verified: checks.every(c => c.matches), checks };
394}
395const CONTRACT_DIGEST = digest({ version: 1, contact: 'source-key/externalId+email/exact-values', product: 'source-marker/variant-id/exact-values' });
396function receipt({ siteId, sourceKey, kind, expected, targetId, actual, marker, variants, operation, attempts = 1, dryRun = false }) {
397 const comparison = kind === 'contact' ? verifyContact(expected, actual, targetId) : verifyProduct(expected, actual, targetId, { marker, variants });
398 return { schemaVersion: 1, siteId, sourceKey, kind, targetId, expected, actual, marker, variants, operation, attempts, dryRun,
399 payloadDigest: digest({ expected, marker, variants }), contractDigest: CONTRACT_DIGEST,
400 ...comparison, verified: !dryRun && Boolean(siteId && sourceKey && operation) && comparison.verified };
401}
402function validReceipt(r, context = {}) {
403 if (!r || r.schemaVersion !== 1 || !['contact', 'product'].includes(r.kind) || !r.verified || r.dryRun || r.contractDigest !== CONTRACT_DIGEST) return false;
404 if (!r.expected || r.payloadDigest !== digest({ expected: r.expected, marker: r.marker, variants: r.variants })) return false;
405 for (const key of ['siteId', 'sourceKey', 'targetId', 'payloadDigest', 'kind']) if (context[key] !== undefined && r[key] !== context[key]) return false;
406 try { return receipt(r).verified; } catch { return false; }
407}
408module.exports = { assertProjection, email, digest, compareProjection, pairUnordered, correlateProductMedia, variantComparisonView, onlyServerPhysical, mediaTitleLanded, contactIdentity, verifyContact, subscriptionExpected,
409 verifySubscriptionEvidence, verifyProduct, receipt, validReceipt, CONTRACT_DIGEST };