Setting the file. One moment.
Data Extension Schema · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
— line 273
This file
Number 41.138
Position 138 of 174
Type JavaScript
Size 33 KB
Lines 673 lib/ data-extension-schema.js
JavaScript · 673 lines · 33 KB
13
// Contacts caveat: the DES supported-objects table still omits contacts, so the V5 contact target
14 // is marked `requiresLiveVerification`. The transport fails closed with CONTACTS_V5_DES_UNVERIFIED
15 // when the gating List call is refused, and never falls back to V4.
16 //
17 // WHY A MERGE HELPER EXISTS AT ALL. Re-checked against the live schema 2026-09-03: Update Data
18 // Extension Schema is a PUT that, in the API's own words, "updates a user-defined data extension
19 // schema, OVERRIDING the existing data". `jsonSchema` is required and replaces what was there. So
20 // a setup step that PUTs only the fields the migration needs DELETES every field the merchant
21 // created. Merging is not a refinement of this contract, it is the contract -- and the failure it
22 // prevents is silent, permanent and on the merchant's own data.
23 //
24 // The rest of the shape, same re-check:
25 // * List is GET /schema-service/v1/schemas?fqdn=&namespaces=&fields=ARCHIVED, and it returns
26 // GLOBAL and user-defined schemas together, so the `_user_fields` one must be selected by
27 // namespace rather than by position.
28 // * Archived fields are hidden unless `fields=ARCHIVED` is requested. A key that collides with
29 // an archived field cannot be reused, so the read must ask for them or the collision check is
30 // blind.
31 // * Update requires `id` and the CURRENT `revision`; id, fqdn and namespace are immutable.
32 // * The namespace carries a byte budget: `maxLimitBytes` (10,000 observed) against
33 // `currentSizeBytes`, shared with whatever the merchant already stores there.
34 // * The eCommerce order object does NOT support filtering (the supported-objects table says so
35 // for `wix.ecom.*.order`), so `x-wix-filterable` is refused for it rather than passed through.
36
37 const SCHEMA_SERVICE = 'https://www.wixapis.com/schema-service/v1/schemas' ;
38
39 const USER_FIELDS_NAMESPACE = '_user_fields' ;
40
41 // The supported target matrix, no more. `supportsFiltering` follows the DES
42 // supported-objects table (Products: yes; Orders: no). Contacts are absent from that table, so
43 // filtering is treated as unsupported rather than assumed.
44 const CONTACTS_FQDN = 'wix.contacts.*.contact' ;
45 const SUPPORTED_TARGETS = {
46 'wix.ecom.*.order' : { targetRef: 'ecom/order' , supportsFiltering: false },
47 'wix.stores.catalog.*.product' : { targetRef: 'stores/product' , supportsFiltering: true },
48 [ CONTACTS_FQDN ]: { targetRef: 'crm/contact' , supportsFiltering: false , contactWriteSurface: 'contacts-v5' , requiresLiveVerification: true },
49 };
50
51 // Known DES objects that are deliberately OUT of this phase. Named so the refusal says why.
52 const OUT_OF_SCOPE_TARGETS = new Set ([
53 'wix.ecom.*.checkout' ,
54 'wix.bookings.*.booking' ,
55 'wix.bookings.services.*.service' ,
56 ]);
57
58 // Deliberately just `string`. The full Wix JSON Schema type and format table has not been
59 // re-verified for this slice, and shipping a guessed type list is how an unsupported type reaches
60 // a live setup call. Widening this set means reading the JSON Schema article first.
61 const VERIFIED_FIELD_TYPES = new Set ([ 'string' ]);
62
63 // Same reasoning as the type set: the format table has not been re-verified, so nothing is
64 // accepted until it is. The two order invoice fields are plain strings and need no format.
65 const VERIFIED_STRING_FORMATS = new Set ();
66
67 const PERMISSION_IDENTITIES = new Set ([ 'apps' , 'owning-app' , 'users' , 'users-of-users' ]);
68
69 const PERMISSION_PRESETS = {
70 // A migration-owned field is not visitor-facing: `users-of-users` is site visitors, and this
71 // data exists for the merchant and the migration, so it is left out.
72 'migration-internal' : { read: [ 'apps' , 'users' ], write: [ 'users' ] },
73 };
74
75 function fail ( errors , code , detail ) {
76 errors. push ({ code, detail });
77 return errors;
78 }
79
80 function validateExtendedFieldIntent ( field , { fqdn } = {}) {
81 const errors = [];
82 if ( ! field || typeof field !== 'object' ) {
83 return { valid: false , errors: fail ([], 'field-not-an-object' , 'a field intent must be an object' ) };
84 }
85 if ( ! field.key || typeof field.key !== 'string' ) {
86 fail (errors, 'missing-field-key' , 'a field intent needs a key' );
87 } else if ( ! / ^ [A-Za-z][A-Za-z0-9_] *$ / . test (field.key)) {
88 fail (errors, 'invalid-field-key' , `"${ field . key }" is not a usable JSON Schema property name` );
89 }
90 if ( ! field.fieldType) {
91 fail (errors, 'missing-field-type' , `field "${ field . key }" has no fieldType` );
92 } else if ( ! VERIFIED_FIELD_TYPES . has (field.fieldType)) {
93 fail (errors, 'unverified-field-type' , `fieldType "${ field . fieldType }" is not verified for this slice; only ${ [ ... VERIFIED_FIELD_TYPES ]. join ( ', ' ) } is` );
94 }
95 if (field.format) {
96 if (field.fieldType !== 'string' ) {
97 fail (errors, 'format-type-mismatch' , `format "${ field . format }" cannot apply to fieldType "${ field . fieldType }"` );
98 } else if ( ! VERIFIED_STRING_FORMATS . has (field.format)) {
99 // Accepting any string as a format let an unsupported one reach a live setup call.
100 fail (errors, 'unverified-field-format' , `format "${ field . format }" is not verified for this slice` );
101 }
102 }
103 if (field.filterable === true ) {
104 const target = SUPPORTED_TARGETS [fqdn];
105 if ( ! target || target.supportsFiltering === false ) {
106 fail (errors, 'filterable-unsupported-target' , `${ fqdn || 'this target'} does not support filtering, so x-wix-filterable cannot be requested` );
107 }
108 }
109 // LIVE-VERIFIED 2026-09-04: the API rejects a string field with no `maxLength` --
110 // `MANDATORY_FIELD_MISSING: <key>.maxLength`. It is required, not optional, so a field intent
111 // without one cannot be provisioned at all.
112 if (field.fieldType === 'string' && field.maxLength === undefined ) {
113 fail (errors, 'missing-max-length' , `field "${ field . key }" needs a maxLength; the API rejects a string field without one` );
114 }
115 if (field.maxLength !== undefined && ( ! Number. isInteger (field.maxLength) || field.maxLength < 1 )) {
116 fail (errors, 'invalid-max-length' , `field "${ field . key }" has a non-positive maxLength` );
117 }
118
119 const permissions = resolvePermissions (field);
120 if ( ! permissions) {
121 fail (errors, 'unknown-permissions-preset' , `field "${ field . key }" names no known permissionsPreset and supplies no explicit permissions` );
122 } else {
123 for ( const identity of [ ... (permissions.read || []), ... (permissions.write || [])]) {
124 if ( ! PERMISSION_IDENTITIES . has (identity)) {
125 fail (errors, 'unknown-permission-identity' , `"${ identity }" is not a permission identity` );
126 }
127 }
128 // Reading as a visitor without reading as a user is incoherent, and the asymmetry is how a
129 // field ends up visible on a storefront but invisible to the merchant who owns it.
130 if ((permissions.read || []). includes ( 'users-of-users' ) && ! (permissions.read || []). includes ( 'users' )) {
131 fail (errors, 'visitor-read-without-user-read' , `field "${ field . key }" grants users-of-users read without users read` );
132 }
133 }
134
135 return { valid: errors. length === 0 , errors };
136 }
137
138 function resolvePermissions ( field ) {
139 if (field.permissions) return field.permissions;
140 if (field.permissionsPreset) return PERMISSION_PRESETS [field.permissionsPreset] || null ;
141 return null ;
142 }
143
144 function buildExtendedFieldSchemaProperty ( field ) {
145 const property = { type: field.fieldType };
146 if (field.maxLength !== undefined ) property.maxLength = field.maxLength;
147 if (field.format) property.format = field.format;
148 if (field.description) property.description = field.description;
149 const permissions = resolvePermissions (field);
150 if (permissions) property[ 'x-wix-permissions' ] = { read: [ ... permissions.read], write: [ ... permissions.write] };
151 if (field.pii === true ) property[ 'x-wix-pii' ] = true ;
152 if (field.filterable === true ) property[ 'x-wix-filterable' ] = true ;
153 // Business Manager lists these as toggleable order columns, so the KEY becomes the column
154 // heading unless something friendlier is supplied. `replatformSourceInvoiceFileId` is not a
155 // heading a merchant should have to read.
156 //
157 // LIVE-VERIFIED 2026-09-05: the display keyword is plain JSON Schema `title`/`description`.
158 // The `x-wix-display` object the docs' own vocabulary defines -- with `label`, `hint` and
159 // `placeholder` -- is REJECTED outright by the schema service:
160 // `UNKNOWN_KEYWORD_AT_THIS_LEVEL: <field>.x-wix-display.label`. Do not reinstate it from the
161 // documentation; it does not work on a user-defined schema.
162 if (field.displayName) property.title = field.displayName;
163 if (field.hint) property.description = field.hint;
164 return property;
165 }
166
167 function buildDataExtensionSchema ({ fields = [] } = {}) {
168 const properties = {};
169 for ( const field of fields) properties[field.key] = buildExtendedFieldSchemaProperty (field);
170 return { type: 'object' , additionalProperties: false , properties };
171 }
172
173 function validateDataExtensionSchemaRequirement ( requirement ) {
174 const errors = [];
175 if ( ! requirement || requirement.kind !== 'extendedFieldSchema' ) {
176 fail (errors, 'not-an-extended-field-requirement' , 'requirement.kind must be "extendedFieldSchema"' );
177 return { valid: false , errors };
178 }
179 if (requirement.schemaKind !== 'data-extension-schema' ) {
180 fail (errors, 'unsupported-schema-kind' , `this helper implements data-extension-schema only, not "${ requirement . schemaKind }"` );
181 }
182 const target = SUPPORTED_TARGETS [requirement.fqdn];
183 if ( ! target) {
184 const why = OUT_OF_SCOPE_TARGETS . has (requirement.fqdn)
185 ? 'it is a known Data Extension Schema object but outside the supported target matrix'
186 : 'it is not a verified Data Extension Schema target' ;
187 fail (errors, 'unsupported-target-object' , `${ requirement . fqdn } is refused: ${ why }` );
188 } else if (requirement.extensionPoint) {
189 // Nested subrecord fields need an extension point nobody has verified yet.
190 fail (errors, 'unsupported-target-object' , `${ requirement . fqdn } extension point "${ requirement . extensionPoint }" is a nested subrecord, which this phase does not provision` );
191 }
192 if (requirement.targetRef && target && requirement.targetRef !== target.targetRef) {
193 fail (errors, 'target-ref-mismatch' , `targetRef "${ requirement . targetRef }" does not match ${ requirement . fqdn } (${ target . targetRef })` );
194 }
195 // Surfaces must not be mixed: a DES requirement is the V5 contact surface or no contact at all.
196 if (requirement.contactWriteSurface !== undefined ) {
197 if (requirement.fqdn !== CONTACTS_FQDN ) {
198 fail (errors, 'contact-surface-on-non-contact' , `contactWriteSurface is only meaningful for ${ CONTACTS_FQDN }` );
199 } else if (requirement.contactWriteSurface !== 'contacts-v5' ) {
200 fail (errors, 'contact-surface-mismatch' , `a data-extension-schema contact requirement is the contacts-v5 surface; "${ requirement . contactWriteSurface }" belongs to crm-contact-extended-field` );
201 }
202 }
203 // A migration-owned field belongs in the site-owner namespace. An app namespace would need a
204 // declared app-defined schema dependency, and this slice creates no app.
205 if (requirement.namespace !== USER_FIELDS_NAMESPACE ) {
206 fail (errors, 'namespace-not-user-fields' , `migration-owned fields live in ${ USER_FIELDS_NAMESPACE }, not "${ requirement . namespace }"` );
207 }
208 if ( ! Array. isArray (requirement.fields) || requirement.fields. length === 0 ) {
209 fail (errors, 'no-fields-declared' , 'a requirement with no fields provisions nothing' );
210 } else {
211 for ( const field of requirement.fields) {
212 const result = validateExtendedFieldIntent (field, { fqdn: requirement.fqdn });
213 for ( const error of result.errors) errors. push (error);
214 const expectedWritePath = `extendedFields.namespaces.${ requirement . namespace }.${ field . key }` ;
215 if (field.writePath && field.writePath !== expectedWritePath) {
216 fail (errors, 'write-path-mismatch' , `field "${ field . key }" declares writePath "${ field . writePath }" but its value lands at "${ expectedWritePath }"` );
217 }
218 }
219 }
220 return { valid: errors. length === 0 , errors };
221 }
222
223 function buildListDataExtensionSchemasRequest ({ fqdn , namespace = USER_FIELDS_NAMESPACE , includeArchived = true } = {}) {
224 const params = new URLSearchParams ();
225 params. set ( 'fqdn' , fqdn);
226 if (namespace) params. append ( 'namespaces' , namespace);
227 // Archived fields are hidden by default, and an archived key cannot be reused -- so the
228 // collision check is only meaningful when they are requested.
229 if (includeArchived) params. append ( 'fields' , 'ARCHIVED' );
230 return { method: 'GET' , url: `${ SCHEMA_SERVICE }?${ params . toString () }` };
231 }
232
233 // List returns global schemas alongside user-defined ones. Selecting by position would merge the
234 // migration's fields into somebody else's schema.
235 function selectUserFieldsSchema ( response , { fqdn , namespace = USER_FIELDS_NAMESPACE } = {}) {
236 const schemas = (response && response.dataExtensionSchemas) || [];
237 return schemas. find (( schema ) => schema.fqdn === fqdn && schema.namespace === namespace) || null ;
238 }
239
240 function archivedKeys ( jsonSchema ) {
241 const properties = (jsonSchema && jsonSchema.properties) || {};
242 return Object. keys (properties). filter (( key ) => properties[key] && properties[key][ 'x-wix-archived' ] === true );
243 }
244
245 function detectDataExtensionSchemaBreakingChanges ( existingJsonSchema , fields = []) {
246 const existing = (existingJsonSchema && existingJsonSchema.properties) || {};
247 const archived = new Set ( archivedKeys (existingJsonSchema));
248 const changes = [];
249
250 for ( const field of fields) {
251 if (archived. has (field.key)) {
252 changes. push ({ code: 'archived-key-collision' , key: field.key, detail: `"${ field . key }" exists as an archived field and its key cannot be reused` });
253 continue ;
254 }
255 const current = existing[field.key];
256 if ( ! current) continue ;
257
258 if (current.type !== field.fieldType) {
259 changes. push ({ code: 'field-type-change' , key: field.key, detail: `"${ field . key }" exists as ${ current . type } and the requirement asks for ${ field . fieldType }` });
260 }
261 if (field.pii === true && current[ 'x-wix-pii' ] !== true ) {
262 changes. push ({ code: 'pii-added-to-existing-field' , key: field.key, detail: `"${ field . key }" already exists without a PII classification, which cannot be added afterwards` });
263 }
264 if ( typeof current.maxLength === 'number' && typeof field.maxLength === 'number' && field.maxLength < current.maxLength) {
265 changes. push ({ code: 'validation-tightened' , key: field.key, detail: `"${ field . key }" would narrow maxLength from ${ current . maxLength } to ${ field . maxLength }, which can invalidate stored values` });
266 }
267 }
268 return { breaking: changes. length > 0 , changes };
269 }
270
271 // The merge. Every existing property survives untouched -- including archived ones and anything
272 // the merchant added -- and only absent keys are introduced.
273 function mergeDataExtensionSchema ( existingJsonSchema , fields = []) {
274 const existing = (existingJsonSchema && existingJsonSchema.properties) || {};
275 const merged = { ... existing };
276 const added = [];
277 const unchanged = [];
278 const relabelled = [];
279
280 for ( const field of fields) {
281 if ( Object . prototype .hasOwnProperty. call (existing, field.key)) {
282 // The field is already there. Its TYPE and constraints stay exactly as stored -- changing
283 // those is a breaking change and is refused elsewhere -- but display metadata is
284 // presentation only, and a label added after the field was created must still reach it.
285 // Without this the merge could never improve a column heading it had already written.
286 const wanted = buildExtendedFieldSchemaProperty (field);
287 const current = existing[field.key];
288 const displayOf = ( property ) => JSON . stringify ([property.title || null , property.description || null ]);
289 if ((wanted.title || wanted.description) && displayOf (current) !== displayOf (wanted)) {
290 merged[field.key] = { ... current, ... (wanted.title ? { title: wanted.title } : {}), ... (wanted.description ? { description: wanted.description } : {}) };
291 relabelled. push (field.key);
292 } else {
293 unchanged. push (field.key);
294 }
295 continue ;
296 }
297 merged[field.key] = buildExtendedFieldSchemaProperty (field);
298 added. push (field.key);
299 }
300
301 const preserved = Object. keys (existing);
302 // Carry the WHOLE existing schema forward and touch only `properties`. Rebuilding it from
303 // `type` + `properties` dropped every other top-level keyword -- `required`, `$schema`, `$id`,
304 // `title` -- and forced `additionalProperties: false` over whatever was there. Since Update is
305 // a PUT that overrides stored data, that is the same clobber this merge exists to prevent, one
306 // level up from the fields.
307 const base = existingJsonSchema ? { ... existingJsonSchema } : {};
308 base.properties = merged;
309 if (base.type === undefined ) base.type = 'object' ;
310 if (base.additionalProperties === undefined ) base.additionalProperties = false ;
311 return {
312 jsonSchema: base,
313 added,
314 unchanged,
315 relabelled,
316 preserved,
317 };
318 }
319
320 function buildCreateDataExtensionSchemaRequest ({ fqdn , namespace = USER_FIELDS_NAMESPACE , jsonSchema }) {
321 return {
322 method: 'POST' ,
323 url: SCHEMA_SERVICE ,
324 body: { dataExtensionSchema: { fqdn, namespace, jsonSchema } },
325 };
326 }
327
328 function buildUpdateDataExtensionSchemaRequest ({ schemaId , revision , mergedSchema }) {
329 if ( ! schemaId) throw new Error ( 'updating a data extension schema needs its id' );
330 if (revision === undefined || revision === null || revision === '' ) {
331 // Without the current revision the PUT either fails or clobbers a concurrent change.
332 throw new Error ( 'updating a data extension schema needs the CURRENT revision; the API uses it to reject conflicting changes' );
333 }
334 if ( ! mergedSchema || ! mergedSchema.properties) {
335 throw new Error ( 'updating a data extension schema needs the full merged jsonSchema; this PUT overrides whatever is stored' );
336 }
337 return {
338 method: 'PUT' ,
339 url: SCHEMA_SERVICE ,
340 body: { dataExtensionSchema: { id: schemaId, revision: String (revision), jsonSchema: mergedSchema } },
341 };
342 }
343
344 // One decision function, so the caller cannot accidentally skip the collision or budget checks.
345 function planDataExtensionSchemaProvisioning ({ requirement , existingSchema = null , approvedBreakingChanges = false } = {}) {
346 const validation = validateDataExtensionSchemaRequirement (requirement);
347 if ( ! validation.valid) {
348 return { action: 'blocked' , blocking: validation.errors, request: null };
349 }
350
351 const fields = requirement.fields;
352 if ( ! existingSchema) {
353 const jsonSchema = buildDataExtensionSchema ({ fields });
354 // No schema yet means no listed `maxLimitBytes` to check against, so the create path uses the
355 // limit observed on the live API. Without this an oversized first schema reached POST.
356 const budget = checkSizeBudget ({ maxLimitBytes: OBSERVED_NAMESPACE_MAX_LIMIT_BYTES , currentSizeBytes: 0 }, jsonSchema);
357 if (budget) {
358 return { action: 'blocked' , blocking: [budget], request: null };
359 }
360 return {
361 action: 'create' ,
362 blocking: [],
363 added: fields. map (( field ) => field.key),
364 unchanged: [],
365 preserved: [],
366 jsonSchema,
367 request: buildCreateDataExtensionSchemaRequest ({ fqdn: requirement.fqdn, namespace: requirement.namespace, jsonSchema }),
368 };
369 }
370
371 const breaking = detectDataExtensionSchemaBreakingChanges (existingSchema.jsonSchema, fields);
372 if (breaking.breaking && approvedBreakingChanges !== true ) {
373 return {
374 action: 'blocked' ,
375 blocking: breaking.changes. map (( change ) => ({ code: change.code, detail: change.detail })),
376 request: null ,
377 };
378 }
379
380 const merged = mergeDataExtensionSchema (existingSchema.jsonSchema, fields);
381
382 // Idempotency: a repeated run with the same requirements changes nothing.
383 if (merged.added. length === 0 && merged.relabelled. length === 0 ) {
384 return { action: 'noop' , blocking: [], added: [], unchanged: merged.unchanged, relabelled: [], preserved: merged.preserved, jsonSchema: merged.jsonSchema, request: null };
385 }
386
387 const budget = checkSizeBudget (existingSchema, merged.jsonSchema);
388 if (budget) {
389 return { action: 'blocked' , blocking: [budget], request: null };
390 }
391
392 return {
393 action: 'update' ,
394 blocking: [],
395 added: merged.added,
396 unchanged: merged.unchanged,
397 relabelled: merged.relabelled,
398 preserved: merged.preserved,
399 jsonSchema: merged.jsonSchema,
400 request: buildUpdateDataExtensionSchemaRequest ({
401 schemaId: existingSchema.id,
402 revision: existingSchema.revision,
403 mergedSchema: merged.jsonSchema,
404 }),
405 };
406 }
407
408 // The `_user_fields` namespace limit observed on the live Data Extension Schema API (2026-09-03).
409 // Used only when there is no existing schema whose listed `maxLimitBytes` could be read instead.
410 const OBSERVED_NAMESPACE_MAX_LIMIT_BYTES = 10000 ;
411
412 // The namespace is a shared byte budget, so the migration's fields can be refused by what the
413 // merchant already stores. Better to say so at setup than to fail mid-import.
414 function checkSizeBudget ( existingSchema , mergedJsonSchema ) {
415 const limit = Number (existingSchema.maxLimitBytes);
416 if ( ! Number. isFinite (limit) || limit <= 0 ) return null ;
417 const projected = Buffer. byteLength ( JSON . stringify (mergedJsonSchema), 'utf8' );
418 if (projected > limit) {
419 return {
420 // Code `schema-budget-exceeded`. The fix is to route excess fields to CMS,
421 // never to guess which fields to drop.
422 code: 'schema-budget-exceeded' ,
423 detail: `the merged schema is about ${ projected } bytes against a ${ limit }-byte namespace limit (currently ${ existingSchema . currentSizeBytes } bytes used)` ,
424 };
425 }
426 return null ;
427 }
428
429 // Step 7: re-read and record evidence. A create/update that reported success but did not produce
430 // the field is the case this catches.
431 // What a receipt has to have checked for its contract stamp to mean anything.
432 //
433 // This used to compare `property.type` and nothing else, while the stamp hashed maxLength,
434 // format, PII, filterability and permissions. So a schema could carry the right keys and types
435 // with WRONG permissions -- visitor-readable, say -- and the receipt would still say the private
436 // invoice contract passed. The `noop` path made that reachable without anyone tampering: an
437 // existing schema with matching keys is never rewritten, only verified.
438 //
439 // Presentation is deliberately absent, exactly as it is from the hash: `title` and `description`
440 // are the merchant's column headings and changing one must not invalidate receipts in flight.
441 function comparableProperty ( property ) {
442 if ( ! property || typeof property !== 'object' ) return null ;
443 const permissions = property[ 'x-wix-permissions' ];
444 return {
445 type: property.type,
446 maxLength: property.maxLength,
447 format: property.format,
448 pii: property[ 'x-wix-pii' ] === true ,
449 filterable: property[ 'x-wix-filterable' ] === true ,
450 permissions: permissions
451 ? { read: [ ... (permissions.read || [])]. sort (), write: [ ... (permissions.write || [])]. sort () }
452 : null ,
453 };
454 }
455
456 // A field intent, expressed the same way, so the two can be compared directly.
457 function expectedComparableProperty ( field ) {
458 return comparableProperty ( buildExtendedFieldSchemaProperty (field));
459 }
460
461 function describeFieldMismatch ( field , property ) {
462 const expected = expectedComparableProperty (field);
463 const actual = comparableProperty (property);
464 if ( ! actual) return 'the field is not on the schema' ;
465 const differences = [];
466 for ( const aspect of [ 'type' , 'maxLength' , 'format' , 'pii' , 'filterable' , 'permissions' ]) {
467 // An aspect the requirement does not state must be ABSENT from the schema, not merely
468 // unexamined. The stamp hashes the empty value just as it hashes a set one, so a `format` the
469 // requirement never asked for is a constraint outside the contract the receipt describes --
470 // and it can reject writes the contract says are fine.
471 if ( JSON . stringify (expected[aspect]) !== JSON . stringify (actual[aspect])) {
472 differences. push ( `${ aspect }: expected ${ JSON . stringify ( expected [ aspect ]) }, schema has ${ JSON . stringify ( actual [ aspect ]) }` );
473 }
474 }
475 return differences. length > 0 ? differences. join ( '; ' ) : null ;
476 }
477
478 function verifyDataExtensionSchemaFields ({ schema , fields = [], contractVersion = null } = {}) {
479 const properties = (schema && schema.jsonSchema && schema.jsonSchema.properties) || {};
480 const verified = [];
481 const missing = [];
482 const mismatched = [];
483 for ( const field of fields) {
484 const property = properties[field.key];
485 if ( ! property) {
486 missing. push (field.key);
487 continue ;
488 }
489 const difference = describeFieldMismatch (field, property);
490 if (difference) {
491 // Present but not what was asked for. Reported apart from `missing`, because "the field is
492 // not there" and "the field is there and readable by the wrong people" are different
493 // problems and only one of them is fixed by provisioning again.
494 mismatched. push ({ key: field.key, difference });
495 continue ;
496 }
497 verified. push ({
498 key: field.key,
499 fieldType: property.type,
500 maxLength: property.maxLength,
501 writePath: `extendedFields.namespaces.${ schema . namespace }.${ field . key }` ,
502 });
503 }
504 return {
505 verified,
506 missing,
507 mismatched,
508 passed: missing. length === 0 && mismatched. length === 0 ,
509 evidence: {
510 schemaId: schema && schema.id,
511 fqdn: schema && schema.fqdn,
512 namespace: schema && schema.namespace,
513 revision: schema && schema.revision,
514 verifiedAt: null ,
515 },
516 // Which contract this receipt was made against. A receipt that verified an earlier set of
517 // fields is structurally identical to one that verified the current set; without this a
518 // consumer cannot tell them apart, which is how a two-of-three receipt read as provisioned.
519 contractVersion: contractVersion || null ,
520 };
521 }
522
523 // The writer-side guard: generated import code may write an extended field only
524 // after setup verification passed for that exact field.
525 // The executor persists `{schemaVersion, verifications: [...]}` -- one entry per requirement --
526 // while a single verification result is `{passed, verified}`. This accepted only the latter, so
527 // handing it the actual on-disk receipt refused every write as unprovisioned. Both shapes are
528 // normalized here, because the caller reading a file should not have to know which one it holds.
529 function normalizeSetupVerification ( setupVerification ) {
530 if ( ! setupVerification) return [];
531 if (Array. isArray (setupVerification.verifications)) return setupVerification.verifications;
532 if (Array. isArray (setupVerification)) return setupVerification;
533 return [setupVerification];
534 }
535
536 // The contract version for an extendedFieldSchema requirement, computed from the requirement
537 // itself so ANY producer of one stamps the same value for the same contract.
538 //
539 // It lives here rather than in a domain adapter because the shipped producer is generic:
540 // `domain-knowledge.js` copies `setupRequirements` out of whatever entity it is summarising and
541 // knows nothing about invoices. While the stamp existed only on the invoice adapter, the real path
542 // emitted unstamped requirements and the writer refused every invoice — the guard worked and the
543 // chain did not reach it.
544 //
545 // Hashed over what a CONSUMER'S BEHAVIOUR depends on: where the fields live, what they are called,
546 // their types and caps, whether they are PII or filterable, and who may read and write them. A
547 // permission or format change alters whether storing a value is safe, so it must invalidate a
548 // receipt; labels and hints are presentation and deliberately excluded, so improving a column
549 // heading does not invalidate every receipt in flight.
550 function extendedFieldContractVersion ( requirement ) {
551 if ( ! requirement || ! Array. isArray (requirement.fields)) return null ;
552 // The legacy V4 contact path has no fqdn/key/fieldType to hash; it stamps its own material.
553 if (requirement.schemaKind === 'crm-contact-extended-field' ) {
554 // eslint-disable-next-line global-require
555 return require ( './contact-extended-fields.js' ). contactExtendedFieldContractVersion (requirement);
556 }
557 const material = [
558 requirement.fqdn || '' ,
559 requirement.namespace || '' ,
560 ... requirement.fields. map (( field ) => [
561 field.key,
562 field.fieldType,
563 field.maxLength,
564 field.format || '' ,
565 field.pii === true ? 'pii' : '' ,
566 field.filterable === true ? 'filterable' : '' ,
567 JSON . stringify ( resolvePermissions (field) || null ),
568 field.writePath || '' ,
569 ]. join ( ':' )). sort (),
570 ]. join ( '|' );
571 return crypto. createHash ( 'sha256' ). update (material). digest ( 'hex' ). slice ( 0 , 16 );
572 }
573
574 function stampRequirement ( requirement ) {
575 if ( ! requirement || requirement.kind !== 'extendedFieldSchema' ) return requirement;
576 return { ... requirement, contractVersion: extendedFieldContractVersion (requirement) };
577 }
578
579 function validateExtendedFieldWriterReferences ({ writePaths = [], setupVerification = null , contractVersion = null } = {}) {
580 const errors = [];
581 const results = normalizeSetupVerification (setupVerification);
582
583 // Provenance, not just shape. A receipt made against an older contract passes every structural
584 // check and still leaves fields unprovisioned, so the caller states which contract it is
585 // writing under and a receipt from any other one is refused.
586 // A path counts only if the entry that verified it is ITSELF stamped with the contract being
587 // written. Checking the receipt as a whole was not enough: the paths were unioned across every
588 // entry, so one stamped-but-empty verification legitimised an unstamped entry that supplied all
589 // of them. A receipt is a bag of independent claims, and provenance belongs to each claim rather
590 // than to the bag.
591 //
592 // One setup receipt covers several targets (an Order contract and a Product contract, say), so
593 // an entry stamped with a DIFFERENT contract is another requirement's claim, not a conflict: it
594 // is ignored rather than rejected. Only the entries on the writer's own contract count, and
595 // only their paths -- paths are never unioned across contracts. A receipt with no entry on this
596 // contract is refused, naming the stale contract when one is present.
597 let accepted = results;
598 if (contractVersion) {
599 accepted = results. filter (( result ) => result && result.contractVersion === contractVersion);
600 if (results. length > 0 && accepted. length === 0 ) {
601 const others = results. filter (( result ) => result && result.contractVersion);
602 if (others. length > 0 ) {
603 fail (errors, 'setup-receipt-contract-mismatch' ,
604 `no verification was produced against contract ${ contractVersion } (the receipt has ${ others . map (( r ) => r . contractVersion ). join ( ', ' ) }); re-run setup` );
605 } else {
606 fail (errors, 'setup-receipt-unstamped' ,
607 'no verification in the receipt carries this contract version, so none of it can be shown to match the fields being written; re-run setup' );
608 }
609 }
610 }
611 const verified = new Set (accepted. flatMap (( result ) => ((result && result.verified) || []). map (( entry ) => entry.writePath)));
612 // Every verification on this contract must have passed: one failed requirement invalidates the
613 // fields it was responsible for, and they are not distinguishable from here.
614 const passed = accepted. length > 0 && accepted. every (( result ) => result && result.passed === true );
615 for ( const writePath of writePaths) {
616 if ( ! passed) {
617 fail (errors, 'writes-before-verified-setup' , `"${ writePath }" would be written before setup verification passed` );
618 continue ;
619 }
620 if ( ! verified. has (writePath)) {
621 fail (errors, 'write-path-not-verified' , `"${ writePath }" is not among the fields setup verified` );
622 }
623 }
624 return { valid: errors. length === 0 , errors };
625 }
626
627 // Revision-conflict recovery. Update carries the listed revision; a concurrent
628 // merchant or app edit between List and Update makes it stale. Wix answers a stale revision with a
629 // 409 (other revisioned services name it INVALID_REVISION), so either signal counts. Anything else
630 // is a real failure and is not retried here.
631 const MAX_SCHEMA_WRITE_ATTEMPTS = 3 ;
632 const CONFLICT_EXHAUSTED_CODE = 'EXTENDED_FIELD_SCHEMA_CONFLICT_EXHAUSTED' ;
633 const CONTACTS_V5_UNVERIFIED_CODE = 'CONTACTS_V5_DES_UNVERIFIED' ;
634
635 function isRevisionConflict ( error ) {
636 if ( ! error) return false ;
637 if (error.status === 409 ) return true ;
638 const text = [error.code, error.applicationErrorCode, error.message]. filter (Boolean). join ( ' ' );
639 return /INVALID_REVISION | REVISION_MISMATCH | revision . * (conflict | stale | mismatch)/ i . test (text);
640 }
641
642 module . exports = {
643 SCHEMA_SERVICE,
644 CONTACTS_FQDN,
645 OUT_OF_SCOPE_TARGETS,
646 MAX_SCHEMA_WRITE_ATTEMPTS,
647 OBSERVED_NAMESPACE_MAX_LIMIT_BYTES,
648 CONFLICT_EXHAUSTED_CODE,
649 CONTACTS_V5_UNVERIFIED_CODE,
650 isRevisionConflict,
651 USER_FIELDS_NAMESPACE,
652 SUPPORTED_TARGETS,
653 VERIFIED_FIELD_TYPES,
654 VERIFIED_STRING_FORMATS,
655 PERMISSION_PRESETS,
656 validateExtendedFieldIntent,
657 buildExtendedFieldSchemaProperty,
658 buildDataExtensionSchema,
659 validateDataExtensionSchemaRequirement,
660 buildListDataExtensionSchemasRequest,
661 selectUserFieldsSchema,
662 archivedKeys,
663 detectDataExtensionSchemaBreakingChanges,
664 mergeDataExtensionSchema,
665 buildCreateDataExtensionSchemaRequest,
666 buildUpdateDataExtensionSchemaRequest,
667 planDataExtensionSchemaProvisioning,
668 verifyDataExtensionSchemaFields,
669 normalizeSetupVerification,
670 extendedFieldContractVersion,
671 stampRequirement,
672 validateExtendedFieldWriterReferences,
673 };