Setting the file. One moment.
Multilingual Public · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page async function collectPages
— line 151
This file
Number 45.111
Position 111 of 182
Type JavaScript
Size 26 KB
Lines 509 lib/ multilingual-public.js
JavaScript · 509 lines · 26 KB
=
Object.
freeze
([
'single'
,
'multi'
,
'unknown'
,
'not-applicable'
]);
9 const TRANSLATION_STATES = Object. freeze ([ 'value' , 'absent' , 'public-unavailable' ]);
10 const PAIRING_METHODS = Object. freeze ([ 'client-key-echo' , 'api-stable-id' ]);
11 const DECISION_KINDS = Object. freeze ({
12 'confirm-setup' : [ 'LANG_TARGET_SETUP_CONFIRMATION_REQUIRED' ],
13 'decline-setup' : [ 'LANG_TARGET_SETUP_CONFIRMATION_REQUIRED' ],
14 'accept-gap' : [ 'LANG_PUBLIC_SOURCE_GAP' , 'LANG_PUBLIC_IDENTITY_UNRESOLVED' , 'LANG_TARGET_FIELD_UNSUPPORTED' , 'LANG_PRIVATE_SOURCE_DEFERRED' , 'LANG_FRONTEND_DEFERRED' ],
15 'ack-overwrite-notice' : [ 'LANG_TARGET_TRANSLATION_OVERWRITTEN' ],
16 'accept-retained-removal' : [ 'LANG_SOURCE_TRANSLATION_REMOVED' ],
17 'authorize-journal-removal' : [ 'LANG_RECOVERY_JOURNAL_REMOVE' ],
18 'request-restore' : [ 'LANG_TARGET_TRANSLATION_OVERWRITTEN' ],
19 });
20
21 const canonical = ( value ) => JSON . stringify (value, ( key , current ) => (
22 current && typeof current === 'object' && ! Array. isArray (current)
23 ? Object. fromEntries (Object. keys (current). sort (). map (( name ) => [name, current[name]]))
24 : current
25 ));
26 const digest = ( value ) => crypto. createHash ( 'sha256' ). update ( typeof value === 'string' || Buffer. isBuffer (value) ? value : canonical (value)). digest ( 'hex' );
27 const stableId = ( ... parts ) => digest (parts). slice ( 0 , 24 );
28
29 function requireObject ( value , label ) {
30 if ( ! value || typeof value !== 'object' || Array. isArray (value)) throw new Error ( `${ label } must be an object` );
31 return value;
32 }
33
34 function requireString ( value , label ) {
35 if ( typeof value !== 'string' || ! value. trim ()) throw new Error ( `${ label } must be a non-empty string` );
36 return value. trim ();
37 }
38
39 function uniqueStrings ( values , label ) {
40 if ( ! Array. isArray (values)) throw new Error ( `${ label } must be an array` );
41 const result = values. map (( value , index ) => requireString (value, `${ label }[${ index }]` ));
42 if ( new Set (result).size !== result. length ) throw new Error ( `${ label } must not contain duplicates` );
43 return result;
44 }
45
46 function normalizeTopology ( input ) {
47 requireObject (input, 'topology' );
48 const mode = requireString (input.mode, 'topology.mode' );
49 if ( ! TOPOLOGY_MODES . includes (mode)) throw new Error ( `topology.mode must be one of ${ TOPOLOGY_MODES . join ( ', ' ) }` );
50 const additionalLocales = (input.additionalLocales || []). map (( row , index ) => {
51 requireObject (row, `topology.additionalLocales[${ index }]` );
52 const locale = requireString (row.locale, `topology.additionalLocales[${ index }].locale` );
53 const state = row.state || 'reachable' ;
54 if ( ! [ 'reachable' , 'unreachable' ]. includes (state)) throw new Error ( `topology.additionalLocales[${ index }].state is invalid` );
55 return { locale, state, evidenceRefs: uniqueStrings (row.evidenceRefs || [], `topology.additionalLocales[${ index }].evidenceRefs` ) };
56 });
57 if ( new Set (additionalLocales. map (( row ) => row.locale)).size !== additionalLocales. length ) throw new Error ( 'topology.additionalLocales must not contain duplicate locales' );
58 if (mode === 'single' && additionalLocales. length ) throw new Error ( 'single topology cannot have additional locales' );
59 if (mode === 'multi' && ! additionalLocales. length ) throw new Error ( 'multi topology requires at least one additional locale' );
60 return {
61 schemaVersion: 1 ,
62 mode,
63 defaultLocaleRef: input.defaultLocaleRef || null ,
64 additionalLocales,
65 evidence: Array. isArray (input.evidence) ? input.evidence : [],
66 };
67 }
68
69 function normalizeTranslationState ( value , label ) {
70 requireObject (value, label);
71 const state = requireString (value.state, `${ label }.state` );
72 if ( ! TRANSLATION_STATES . includes (state)) throw new Error ( `${ label }.state must be one of ${ TRANSLATION_STATES . join ( ', ' ) }` );
73 if (state === 'value' && ! Object . prototype .hasOwnProperty. call (value, 'value' )) throw new Error ( `${ label }.value is required` );
74 if (state !== 'value' && Object . prototype .hasOwnProperty. call (value, 'value' )) throw new Error ( `${ label }.value is forbidden for ${ state }` );
75 return state === 'value' ? { state, value: value.value } : { state };
76 }
77
78 function normalizeContentRows ( rows , topology ) {
79 if ( ! Array. isArray (rows)) throw new Error ( 'multilingual content must be an array' );
80 const locales = new Set (topology.additionalLocales. map (( row ) => row.locale));
81 if (topology.mode !== 'multi' && rows. length ) throw new Error ( 'only proven multi topology may produce multilingual rows' );
82 return rows. map (( row , index ) => {
83 requireObject (row, `content[${ index }]` );
84 const sourceObject = requireObject (row.sourceObject, `content[${ index }].sourceObject` );
85 const member = requireObject (row.member, `content[${ index }].member` );
86 const defaultValue = normalizeTranslationState (row.default, `content[${ index }].default` );
87 if (defaultValue.state !== 'value' ) throw new Error ( `content[${ index }].default must carry the default value` );
88 const normalized = {
89 rowId: row.rowId || stableId (sourceObject.type, sourceObject.id, member.path, member.stableId || '' ),
90 sourceObject: {
91 type: requireString (sourceObject.type, `content[${ index }].sourceObject.type` ),
92 id: requireString (sourceObject.id, `content[${ index }].sourceObject.id` ),
93 },
94 member: {
95 path: requireString (member.path, `content[${ index }].member.path` ),
96 ... (member.stableId ? { stableId: requireString (member.stableId, `content[${ index }].member.stableId` ) } : {}),
97 },
98 default: defaultValue,
99 translations: {},
100 evidenceRefs: uniqueStrings (row.evidenceRefs || [], `content[${ index }].evidenceRefs` ),
101 };
102 for ( const locale of locales) {
103 if ( ! row.translations || ! Object . prototype .hasOwnProperty. call (row.translations, locale)) {
104 throw new Error ( `content[${ index }] must record value, absent, or public-unavailable for ${ locale }` );
105 }
106 normalized.translations[locale] = normalizeTranslationState (row.translations[locale], `content[${ index }].translations.${ locale }` );
107 }
108 for ( const locale of Object. keys (row.translations || {})) if ( ! locales. has (locale)) throw new Error ( `content[${ index }] names undeclared locale ${ locale }` );
109 return normalized;
110 });
111 }
112
113 function buildMemberPairings ( bindings , additionalLocaleCount ) {
114 if (additionalLocaleCount === 0 ) return [];
115 if ( ! Array. isArray (bindings)) throw new Error ( 'member bindings must be an array' );
116 return bindings. map (( row , index ) => {
117 requireObject (row, `bindings[${ index }]` );
118 const method = requireString (row.method, `bindings[${ index }].method` );
119 if ( ! PAIRING_METHODS . includes (method)) throw new Error ( `bindings[${ index }].method cannot use text or position` );
120 const normalized = {
121 sourceObjectId: requireString (row.sourceObjectId, `bindings[${ index }].sourceObjectId` ),
122 sourceMemberId: requireString (row.sourceMemberId, `bindings[${ index }].sourceMemberId` ),
123 targetObjectId: requireString (row.targetObjectId, `bindings[${ index }].targetObjectId` ),
124 targetMemberId: requireString (row.targetMemberId, `bindings[${ index }].targetMemberId` ),
125 method,
126 requestDigest: requireString (row.requestDigest, `bindings[${ index }].requestDigest` ),
127 readbackDigest: requireString (row.readbackDigest, `bindings[${ index }].readbackDigest` ),
128 };
129 return { ... normalized, pairingId: stableId (normalized) };
130 });
131 }
132
133 function buildObjectPairings ( bindings , additionalLocaleCount ) {
134 if (additionalLocaleCount === 0 ) return [];
135 if ( ! Array. isArray (bindings)) throw new Error ( 'object bindings must be an array' );
136 return bindings. map (( row , index ) => {
137 requireObject (row, `objectBindings[${ index }]` );
138 const method = requireString (row.method, `objectBindings[${ index }].method` );
139 if ( ! PAIRING_METHODS . includes (method)) throw new Error ( `objectBindings[${ index }].method cannot use text or position` );
140 const normalized = {
141 sourceObjectId: requireString (row.sourceObjectId, `objectBindings[${ index }].sourceObjectId` ),
142 targetObjectId: requireString (row.targetObjectId, `objectBindings[${ index }].targetObjectId` ),
143 method,
144 requestDigest: requireString (row.requestDigest, `objectBindings[${ index }].requestDigest` ),
145 readbackDigest: requireString (row.readbackDigest, `objectBindings[${ index }].readbackDigest` ),
146 };
147 return { ... normalized, pairingId: stableId (normalized) };
148 });
149 }
150
151 async function collectPages ( fetchPage , label = 'page' ) {
152 if ( typeof fetchPage !== 'function' ) throw new Error ( 'fetchPage must be a function' );
153 const items = [];
154 const seen = new Set ();
155 let cursor = null ;
156 do {
157 if (seen. has (cursor)) throw new Error ( `${ label } pagination repeated cursor ${ cursor }` );
158 seen. add (cursor);
159 // eslint-disable-next-line no-await-in-loop
160 const page = await fetchPage (cursor);
161 requireObject (page, label);
162 if ( ! Array. isArray (page.items)) throw new Error ( `${ label }.items must be an array` );
163 items. push ( ... page.items);
164 cursor = page.nextCursor || null ;
165 } while (cursor);
166 return items;
167 }
168
169 function capabilityKey ( row ) {
170 return [row.sourceType, row.sourcePath, row.locale || '*' ]. join ( '|' );
171 }
172
173 function buildCapabilityManifest ({ localeSettings = {}, locales = [], schemas = [], contents = [], recoveryRows = [] } = {}) {
174 const fields = [];
175 for ( const schema of schemas) {
176 requireObject (schema, 'schema' );
177 for ( const field of schema.fields || []) {
178 const row = {
179 schemaId: requireString (schema.id, 'schema.id' ),
180 schemaRevision: schema.revision || null ,
181 application: schema.application || null ,
182 frontend: schema.frontend === true ,
183 entityType: requireString (schema.entityType, 'schema.entityType' ),
184 sourceType: field.sourceType || schema.sourceType || schema.entityType,
185 sourcePath: requireString (field.sourcePath || field.path, 'schema field sourcePath' ),
186 keyTemplate: field.keyTemplate || null ,
187 concreteKey: field.concreteKey || null ,
188 type: field.type || 'string' ,
189 writable: field.writable !== false ,
190 flags: Array. isArray (field.flags) ? field.flags : [],
191 constraints: field.constraints || {},
192 parentRequirements: Array. isArray (field.parentRequirements) ? field.parentRequirements : [],
193 };
194 fields. push (row);
195 }
196 }
197 const liveContents = normalizeTranslationContents (contents, schemas);
198 return {
199 schemaVersion: 1 ,
200 localeSettings,
201 locales,
202 schemas: schemas. map (( row ) => ({ id: row.id, revision: row.revision || null , entityType: row.entityType, frontend: row.frontend === true })),
203 fields,
204 contents: liveContents,
205 recoveryRows,
206 digest: digest ({ localeSettings, locales, schemas, contents: liveContents, recoveryRows }),
207 };
208 }
209
210 function typedFieldValue ( field ) {
211 if ( Object . prototype .hasOwnProperty. call (field, 'textValue' )) return field.textValue;
212 if ( Object . prototype .hasOwnProperty. call (field, 'richContent' )) return field.richContent;
213 if ( Object . prototype .hasOwnProperty. call (field, 'image' )) return field.image;
214 if ( Object . prototype .hasOwnProperty. call (field, 'video' )) return field.video;
215 if ( Object . prototype .hasOwnProperty. call (field, 'value' )) return field.value;
216 return undefined ;
217 }
218
219 function memberIdFromConcreteKey ( concreteKey , keyTemplate ) {
220 if ( typeof concreteKey !== 'string' || typeof keyTemplate !== 'string' ) return null ;
221 const marker = keyTemplate. indexOf ( '()' );
222 if (marker < 0 || keyTemplate. indexOf ( '()' , marker + 2 ) >= 0 ) return null ;
223 const prefix = `${ keyTemplate . slice ( 0 , marker ) }(` ;
224 const suffix = `)${ keyTemplate . slice ( marker + 2 ) }` ;
225 if ( ! concreteKey. startsWith (prefix) || ! concreteKey. endsWith (suffix)) return null ;
226 const memberId = concreteKey. slice (prefix. length , concreteKey. length - suffix. length );
227 return memberId || null ;
228 }
229
230 function concreteKeyFor ( capability , memberPath , targetMemberId ) {
231 if (targetMemberId && capability.keyTemplate?. includes ( '()' )) {
232 return capability.keyTemplate. replace ( '()' , `(${ targetMemberId })` );
233 }
234 const candidate = capability.concreteKey || capability.keyTemplate || memberPath;
235 return targetMemberId && candidate. includes ( '()' )
236 ? candidate. replace ( '()' , `(${ targetMemberId })` )
237 : candidate;
238 }
239
240 function normalizeTranslationContents ( contents , schemas = []) {
241 if ( ! Array. isArray (contents)) throw new Error ( 'translation contents must be an array' );
242 const schemaById = new Map (schemas. map (( row ) => [row.id, row]));
243 const rows = [];
244 for ( const content of contents) {
245 requireObject (content, 'translation content' );
246 if (content.fields && ! Array. isArray (content.fields)) {
247 const schemaId = requireString (content.schemaId, 'translation content.schemaId' );
248 const entityId = requireString (content.entityId, 'translation content.entityId' );
249 const locale = requireString (content.locale, 'translation content.locale' );
250 const entityType = schemaById. get (schemaId)?.entityType || content.entityType || schemaId;
251 for ( const [ mapKey , rawField ] of Object. entries (content.fields)) {
252 const field = requireObject (rawField, `translation content.fields.${ mapKey }` );
253 const value = typedFieldValue (field);
254 const keyTemplate = field.schemaFieldKey || mapKey;
255 rows. push ({
256 schemaId,
257 entityType,
258 locale,
259 targetObjectId: content.parentEntityId || entityId,
260 targetMemberId: content.parentEntityId ? entityId : memberIdFromConcreteKey (mapKey, keyTemplate),
261 key: mapKey,
262 keyTemplate,
263 value,
264 published: field.published === true ,
265 contentId: content.id || null ,
266 publishStatus: content.publishStatus || null ,
267 });
268 }
269 continue ;
270 }
271 rows. push (content);
272 }
273 return rows;
274 }
275
276 function fieldStateDigest ( value , published ) {
277 return digest ({ state: value === undefined ? 'missing' : 'value' , value: value === undefined ? null : value, published: published === true });
278 }
279
280 function findPairing ( pairings , contentRow ) {
281 if ( ! contentRow.member.stableId) return null ;
282 return pairings. find (( row ) => row.sourceObjectId === contentRow.sourceObject.id && row.sourceMemberId === contentRow.member.stableId) || null ;
283 }
284
285 function findObjectPairing ( pairings , contentRow ) {
286 return pairings. find (( row ) => row.sourceObjectId === contentRow.sourceObject.id) || null ;
287 }
288
289 function findLiveField ( liveContents , identity ) {
290 return liveContents. find (( row ) => row.locale === identity.locale && row.entityType === identity.entityType
291 && ( ! row.schemaId || row.schemaId === identity.schemaId)
292 && row.targetObjectId === identity.targetObjectId
293 && (row.targetMemberId || null ) === (identity.targetMemberId || null )
294 && row.key === identity.concreteKey) || null ;
295 }
296
297 function reasonForGap ( state ) {
298 if (state === 'public-unavailable' ) return 'LANG_PRIVATE_SOURCE_DEFERRED' ;
299 return 'LANG_PUBLIC_SOURCE_GAP' ;
300 }
301
302 function latestVerifiedJournalRow ( rows , predicate ) {
303 let latest = null ;
304 for ( const row of rows) {
305 if (row.outcome !== 'verified' || ! predicate (row)) continue ;
306 if ( ! latest || String (row.verifiedAt || '' ) >= String (latest.verifiedAt || '' )) latest = row;
307 }
308 return latest;
309 }
310
311 function buildPlan ({ topology , contentRows , objectPairings = [], pairings = [], capabilities , liveContents = [], journalRows = [], priorPlanRows = [] }) {
312 const normalizedTopology = normalizeTopology (topology);
313 const sourceRows = normalizeContentRows (contentRows, normalizedTopology);
314 if (normalizedTopology.mode !== 'multi' ) return { schemaVersion: 1 , rows: [], inputDigest: digest ({ topology: normalizedTopology, contentRows: sourceRows, capabilities }) };
315 const capabilityRows = capabilities.fields || [];
316 const rows = [];
317 const currentKeys = new Set ();
318 for ( const source of sourceRows) {
319 for ( const localeRow of normalizedTopology.additionalLocales) {
320 const locale = localeRow.locale;
321 const translated = source.translations[locale];
322 const rowId = stableId (source.rowId, locale);
323 currentKeys. add (rowId);
324 const base = { rowId, sourceRowId: source.rowId, locale, sourceObject: source.sourceObject, member: source.member, evidenceRefs: source.evidenceRefs };
325 if (localeRow.state !== 'reachable' ) {
326 rows. push ({ ... base, state: 'source-gap' , decision: 'skip-unsupported' , reasonCode: 'LANG_PUBLIC_TOPOLOGY_UNRESOLVED' , recovery: 'none' });
327 continue ;
328 }
329 if (translated.state === 'absent' ) {
330 const previous = latestVerifiedJournalRow (journalRows, ( entry ) => entry.rowId === rowId && entry.identity)
331 || priorPlanRows. find (( entry ) => entry.rowId === rowId && entry.identity);
332 const live = previous ? findLiveField (liveContents, previous.identity) : null ;
333 const liveDigest = live ? fieldStateDigest (live.value, live.published) : null ;
334 const authored = previous && journalRows. some (( entry ) => entry.outcome === 'verified'
335 && entry.identityDigest === digest (previous.identity) && entry.replacementDigest === liveDigest);
336 if (authored) {
337 rows. push ({ ... previous, sourceState: 'absent' , target: live, state: 'migration-authored-source-absent' , decision: 'skip-absent-leave-target' , reasonCode: 'LANG_SOURCE_TRANSLATION_REMOVED' , recovery: previous.recovery || 'none' });
338 } else {
339 rows. push ({ ... base, sourceState: 'absent' , state: 'source-gap' , decision: 'skip-equal' , reasonCode: null , recovery: 'none' });
340 }
341 continue ;
342 }
343 if (translated.state !== 'value' ) {
344 rows. push ({ ... base, state: 'source-gap' , decision: 'skip-unsupported' , reasonCode: reasonForGap (translated.state), recovery: 'none' });
345 continue ;
346 }
347 const capability = capabilityRows. find (( entry ) => ! entry.frontend && entry.writable !== false
348 && entry.sourceType === source.sourceObject.type && entry.sourcePath === source.member.path);
349 if ( ! capability) {
350 const frontend = capabilityRows. some (( entry ) => entry.frontend && entry.sourceType === source.sourceObject.type && entry.sourcePath === source.member.path);
351 rows. push ({ ... base, sourceValue: translated.value, state: 'unsupported-field' , decision: 'skip-unsupported' , reasonCode: frontend ? 'LANG_FRONTEND_DEFERRED' : 'LANG_TARGET_FIELD_UNSUPPORTED' , recovery: 'none' });
352 continue ;
353 }
354 const objectPairing = findObjectPairing (objectPairings, source);
355 if ( ! objectPairing) {
356 rows. push ({ ... base, sourceValue: translated.value, state: 'blocked' , decision: 'blocked' , reasonCode: 'LANG_PUBLIC_IDENTITY_UNRESOLVED' , recovery: 'none' });
357 continue ;
358 }
359 let targetObjectId = objectPairing.targetObjectId;
360 let targetMemberId = null ;
361 if (source.member.stableId) {
362 const pairing = findPairing (pairings, source);
363 if ( ! pairing) {
364 rows. push ({ ... base, sourceValue: translated.value, state: 'blocked' , decision: 'blocked' , reasonCode: 'LANG_PUBLIC_IDENTITY_UNRESOLVED' , recovery: 'none' });
365 continue ;
366 }
367 targetObjectId = pairing.targetObjectId;
368 targetMemberId = pairing.targetMemberId;
369 }
370 const requiresMemberIdentity = [capability.keyTemplate, capability.concreteKey]
371 . some (( key ) => typeof key === 'string' && key. includes ( '()' ));
372 if (requiresMemberIdentity && ! targetMemberId) {
373 rows. push ({ ... base, sourceValue: translated.value, state: 'blocked' , decision: 'blocked' , reasonCode: 'LANG_PUBLIC_IDENTITY_UNRESOLVED' , recovery: 'none' });
374 continue ;
375 }
376 const identity = { locale, entityType: capability.entityType, targetObjectId, targetMemberId, concreteKey: concreteKeyFor (capability, source.member.path, targetMemberId), schemaId: capability.schemaId };
377 const live = findLiveField (liveContents, identity);
378 const liveValue = live && Object . prototype .hasOwnProperty. call (live, 'value' ) ? live.value : undefined ;
379 const published = live?.published === true ;
380 const replacementDigest = fieldStateDigest (translated.value, true );
381 const priorDigest = fieldStateDigest (liveValue, published);
382 const identityDigest = digest (identity);
383 const verifiedJournal = journalRows. find (( entry ) => entry.outcome === 'verified' && entry.identityDigest === identityDigest
384 && entry.replacementDigest === priorDigest);
385 const preparedJournal = journalRows. find (( entry ) => entry.outcome === 'prepared' && entry.identityDigest === identityDigest
386 && entry.sourceDigest === digest (translated.value) && entry.replacementDigest === replacementDigest);
387 let state;
388 let decision;
389 let reasonCode = null ;
390 if (liveValue === undefined ) { state = live ? 'missing-field' : 'missing-record' ; decision = live ? 'patch-unpublished' : 'create-unpublished' ; }
391 else if ( canonical (liveValue) === canonical (translated.value) && published) { state = 'equal-published' ; decision = preparedJournal ? 'finalize-prepared' : 'skip-equal' ; }
392 else if ( canonical (liveValue) === canonical (translated.value)) { state = 'equal-unpublished' ; decision = 'publish-only' ; }
393 else if (verifiedJournal) { state = 'different-migration-authored' ; decision = 'update-migration-authored' ; }
394 else { state = liveValue === '' || liveValue === null ? 'empty' : 'different' ; decision = 'overwrite-after-backup' ; reasonCode = state === 'different' ? 'LANG_TARGET_TRANSLATION_OVERWRITTEN' : null ; }
395 const recovery = preparedJournal?.id || preparedJournal?.recoveryRowId
396 || (decision === 'skip-equal' || decision === 'publish-only' ? 'none' : stableId (identity, priorDigest, replacementDigest));
397 rows. push ({ ... base, identity, ... (live?.contentId ? { contentId: live.contentId } : {}), sourceValue: translated.value, target: live || null , state, decision, reasonCode, priorDigest, replacementDigest, recovery, ... (preparedJournal ? { preparedRecovery: recovery } : {}) });
398 }
399 }
400 for ( const previous of priorPlanRows) {
401 if (currentKeys. has (previous.rowId) || previous.sourceState !== 'absent' ) continue ;
402 const live = findLiveField (liveContents, previous.identity || {});
403 const liveDigest = live ? fieldStateDigest (live.value, live.published) : null ;
404 const authored = journalRows. some (( entry ) => entry.outcome === 'verified' && entry.identityDigest === digest (previous.identity) && entry.replacementDigest === liveDigest);
405 if ( ! authored) continue ;
406 rows. push ({ ... previous, target: live, state: 'migration-authored-source-absent' , decision: 'skip-absent-leave-target' , reasonCode: 'LANG_SOURCE_TRANSLATION_REMOVED' , recovery: previous.recovery || 'none' });
407 }
408 return { schemaVersion: 1 , rows, inputDigest: digest ({ topology: normalizedTopology, contentRows: sourceRows, objectPairings, pairings, capabilities, liveContents }) };
409 }
410
411 function actionId ( reasonCode , rowId ) { return `lang:${ reasonCode }:${ rowId }` ; }
412
413 function scopeDigest ( row ) {
414 return digest ({ rowId: row.rowId, reasonCode: row.reasonCode, state: row.state, decision: row.decision, identity: row.identity || null , recovery: row.recovery || null });
415 }
416
417 function normalizeDecision ( input , openActions , existing = []) {
418 requireObject (input, 'decision' );
419 const kind = requireString (input.kind, 'decision.kind' );
420 if ( ! DECISION_KINDS [kind]) throw new Error ( `unsupported multilingual decision kind ${ kind }` );
421 const action = openActions. find (( row ) => row.actionId === input.actionId);
422 if ( ! action) throw new Error ( `decision action is not open: ${ input . actionId }` );
423 if ( ! DECISION_KINDS [kind]. includes (action.reasonCode)) throw new Error ( `${ kind } cannot resolve ${ action . reasonCode }` );
424 if (input.scopeDigest !== action.scopeDigest) throw new Error ( 'decision scope digest is stale' );
425 const decidedBy = input.decidedBy;
426 if ( ! [ 'site-owner' , 'cutover-operator' ]. includes (decidedBy)) throw new Error ( 'decidedBy must be site-owner or cutover-operator' );
427 const recordedBy = input.recordedBy;
428 if (recordedBy !== 'import-operator' ) throw new Error ( 'migration-local decisions must be recorded by the Import Operator' );
429 if (kind === 'request-restore' ) requireString (input.recoveryRowId, 'decision.recoveryRowId' );
430 const normalized = {
431 decisionId: stableId (input.actionId, kind, input.scopeDigest, input.evidenceRef, existing. length ),
432 actionId: input.actionId,
433 reasonCode: action.reasonCode,
434 kind,
435 scopeDigest: input.scopeDigest,
436 affectedIds: uniqueStrings (input.affectedIds || action.affectedIds || [], 'decision.affectedIds' ),
437 decidedBy,
438 recordedBy,
439 evidenceRef: requireString (input.evidenceRef, 'decision.evidenceRef' ),
440 recordedAt: input.recordedAt || new Date (). toISOString (),
441 ... (input.recoveryRowId ? { recoveryRowId: input.recoveryRowId } : {}),
442 };
443 if ( ! Number. isFinite (Date. parse (normalized.recordedAt))) throw new Error ( 'decision.recordedAt must be an ISO timestamp' );
444 return normalized;
445 }
446
447 async function writeJsonAtomic ( file , value ) {
448 await fsp. mkdir (path. dirname (file), { recursive: true });
449 const temp = `${ file }.${ process . pid }.${ Date . now () }.tmp` ;
450 await fsp. writeFile (temp, `${ JSON . stringify ( value , null , 2 ) } \n ` );
451 await fsp. rename (temp, file);
452 }
453
454 async function appendNdjson ( file , value ) {
455 await fsp. mkdir (path. dirname (file), { recursive: true });
456 await fsp. appendFile (file, `${ JSON . stringify ( value ) } \n ` );
457 }
458
459 async function artifactDigests ( projectDir , files ) {
460 const result = {};
461 for ( const relative of uniqueStrings (files, 'files' )) {
462 if (path. isAbsolute (relative) || relative. split ( / [ \\ /] / ). includes ( '..' )) throw new Error ( 'default artifact paths must be project-relative' );
463 // eslint-disable-next-line no-await-in-loop
464 result[relative] = digest ( await fsp. readFile (path. join (projectDir, relative)));
465 }
466 return result;
467 }
468
469 function assertDefaultArtifactsUnchanged ( before , after ) {
470 if ( canonical (before) !== canonical (after)) {
471 const changed = [ ...new Set ([ ... Object. keys (before || {}), ... Object. keys (after || {})])]
472 . filter (( file ) => before?.[file] !== after?.[file]);
473 throw Object. assign ( new Error ( `multilingual sidecar changed default artifacts: ${ changed . join ( ', ' ) }` ), { code: 'LANG_DEFAULT_OUTPUT_CHANGED' , changed });
474 }
475 return true ;
476 }
477
478 function readNdjson ( file ) {
479 try { return fs. readFileSync (file, 'utf8' ). split ( / \r ? \n / ). filter (Boolean). map (( line ) => JSON . parse (line)); } catch (error) {
480 if (error.code === 'ENOENT' ) return [];
481 throw error;
482 }
483 }
484
485 module . exports = {
486 DECISION_KINDS,
487 TOPOLOGY_MODES,
488 actionId,
489 appendNdjson,
490 artifactDigests,
491 assertDefaultArtifactsUnchanged,
492 buildCapabilityManifest,
493 buildMemberPairings,
494 buildObjectPairings,
495 buildPlan,
496 canonical,
497 capabilityKey,
498 collectPages,
499 digest,
500 fieldStateDigest,
501 normalizeContentRows,
502 normalizeDecision,
503 normalizeTranslationContents,
504 normalizeTopology,
505 readNdjson,
506 scopeDigest,
507 stableId,
508 writeJsonAtomic,
509 };