Setting the file. One moment.
Member Completion · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page This file
Number 45.103
Position 103 of 182
Type JavaScript
Size 32 KB
Lines 684 lib/ member-completion.js
JavaScript · 684 lines · 32 KB
14
// * The report's denominator is the key snapshot; recovery passes select within it, may add a
15 // newly supplied key to it, and never shrink it.
16 // * The report reads only the journal: latest row per source key, in append order.
17 // * Telemetry is aggregate, derived from the same rows, and carries no record values.
18
19 const fs = require ( 'node:fs/promises' );
20 const path = require ( 'node:path' );
21 const crypto = require ( 'node:crypto' );
22 const localState = require ( './local-state.js' );
23 const R = require ( './member-outcome-registry.js' );
24
25 const MEMBER_POPULATION_FILE = 'execution/member-population.json' ;
26 const POPULATION_BASIS = 'records-supplied-to-member-pass' ;
27 const SELECTION_MODES = Object. freeze ([ 'full' , 'failed-only' , 'deferred-only' , 'unresolved-only' , 'missing-only' ]);
28 const TELEMETRY_SKILL = 'rp-execute-import' ;
29 const TELEMETRY_STAGE = 'import' ;
30 const TELEMETRY_ENTITY = 'member' ;
31 const SNAPSHOT_MISSING = 'member-population-snapshot-missing' ;
32 const MEMBER_API_SURFACE = 'members/v1' ;
33 // Severity merge: the most severe grouped record wins.
34 const SEVERITY_RANK = Object. freeze ({ info: 0 , cosmetic: 1 , degraded: 2 , blocking: 3 });
35
36 // The dimensions rp-telemetry's recorder folds on (telemetry-recorder.js classKeyFor), in its
37 // order. Grouping by anything else lets finalize merge two emitted events order-dependently.
38 function recorderSignature ( event ) {
39 return [event.event_type, event.stage, event.entity_type, event.wix_api_surface,
40 event.source_api_surface, event.wix_app_id, event.error_code, event.subtype]
41 . map (( p ) => (p === null || p === undefined ? '' : String (p))). join ( '|' );
42 }
43 const GUID_IN_TEXT = / [0-9a-f] {8} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {12} / gi ;
44
45 function requireString ( value , field ) {
46 if ( ! value || typeof value !== 'string' ) throw new Error ( `${ field } must be a non-empty string` );
47 return value;
48 }
49
50 function normalizeMode ( mode ) {
51 const value = mode === undefined || mode === null ? 'full' : mode;
52 if ( ! SELECTION_MODES . includes (value)) {
53 throw new Error ( `member pass mode must be one of ${ SELECTION_MODES . join ( ', ' ) }` );
54 }
55 return value;
56 }
57
58 function defaultSourceStableKey ( record ) {
59 return record ? record.sourceStableKey : undefined ;
60 }
61
62 // --- population gate ---------------------------------------------------------------------
63
64 // Pure. Checks the WHOLE supplied list before anything is written. Never deduplicates and never
65 // drops a record: a defect is reported with its counts, and the caller writes nothing.
66 function validateMemberPopulation ( records , { getSourceStableKey = defaultSourceStableKey, snapshotKeys = null } = {}) {
67 if ( ! Array. isArray (records)) throw new Error ( 'member population must be an array of supplied records' );
68 const keys = [];
69 const counts = new Map ();
70 let missingSourceKey = 0 ;
71 for ( const record of records) {
72 const key = getSourceStableKey (record);
73 if ( typeof key !== 'string' || key. trim () === '' ) {
74 missingSourceKey += 1 ;
75 keys. push ( null );
76 continue ;
77 }
78 keys. push (key);
79 counts. set (key, (counts. get (key) || 0 ) + 1 );
80 }
81 let duplicateSourceKey = 0 ;
82 for ( const n of counts. values ()) if (n > 1 ) duplicateSourceKey += n;
83 let unknownSourceKey = 0 ;
84 if (snapshotKeys) {
85 const known = new Set (snapshotKeys);
86 for ( const key of keys) if (key !== null && ! known. has (key)) unknownSourceKey += 1 ;
87 }
88 const defect = { missingSourceKey, duplicateSourceKey, unknownSourceKey };
89 return {
90 ok: missingSourceKey === 0 && duplicateSourceKey === 0 && unknownSourceKey === 0 ,
91 keys,
92 distinctKeys: [ ... counts. keys ()]. sort (),
93 extracted: records. length ,
94 defect,
95 failingRecords: missingSourceKey + duplicateSourceKey + unknownSourceKey,
96 };
97 }
98
99 // --- population snapshot -----------------------------------------------------------------
100
101 function populationPath ( projectDir ) {
102 return path. join (projectDir, MEMBER_POPULATION_FILE );
103 }
104
105 function validateSnapshot ( snapshot ) {
106 if ( ! snapshot || typeof snapshot !== 'object' || Array. isArray (snapshot)) throw new Error ( `${ MEMBER_POPULATION_FILE } must be an object` );
107 if (snapshot.schemaVersion !== 1 ) throw new Error ( `${ MEMBER_POPULATION_FILE }.schemaVersion must be 1` );
108 if ( ! Array. isArray (snapshot.keys) || snapshot.keys. some (( key ) => typeof key !== 'string' || ! key)) {
109 throw new Error ( `${ MEMBER_POPULATION_FILE }.keys must be an array of source keys` );
110 }
111 if ( new Set (snapshot.keys).size !== snapshot.keys. length ) throw new Error ( `${ MEMBER_POPULATION_FILE }.keys must be unique` );
112 return snapshot;
113 }
114
115 async function readMemberPopulation ( projectDir ) {
116 let text;
117 try {
118 text = await fs. readFile ( populationPath (projectDir), 'utf8' );
119 } catch (error) {
120 if (error && error.code === 'ENOENT' ) return null ;
121 throw error;
122 }
123 return validateSnapshot ( JSON . parse (text));
124 }
125
126 function buildMemberPopulation ({ runId , keys , sourceFile = null , sourceChecksum = null , now = null }) {
127 return validateSnapshot ({
128 schemaVersion: 1 ,
129 runId: requireString (runId, 'runId' ),
130 createdAt: now || new Date (). toISOString (),
131 sourceFile: sourceFile === null ? null : String (sourceFile),
132 sourceChecksum: sourceChecksum === null ? null : String (sourceChecksum),
133 // Keys only, sorted: no names, emails or other record values.
134 keys: [ ... keys]. sort (),
135 });
136 }
137
138 async function writeMemberPopulation ( projectDir , snapshot ) {
139 validateSnapshot (snapshot);
140 const filePath = populationPath (projectDir);
141 await fs. mkdir (path. dirname (filePath), { recursive: true });
142 const tempPath = `${ filePath }.${ process . pid }.${ Date . now () }.tmp` ;
143 await fs. writeFile (tempPath, `${ JSON . stringify ( snapshot , null , 2 ) } \n ` , 'utf8' );
144 await fs. rename (tempPath, filePath);
145 return filePath;
146 }
147
148 // --- journal rows ------------------------------------------------------------------------
149
150 function isMemberRow ( row ) {
151 return row && row.targetEntityType === R . MEMBER_TARGET_ENTITY_TYPE && row.operation === R . MEMBER_OPERATION ;
152 }
153
154 // Latest member row per source key, in append order (the journal fold, restricted to member
155 // rows), plus the pairing check: every terminal row shares its attemptId with exactly one
156 // earlier `started` row for the same key. Pairs are counted per (sourceStableKey, attemptId):
157 // a second `started` for one pair, a terminal with no earlier start for its pair, or a second
158 // terminal for one pair is a projection error -- a corrupted journal never reads clean.
159 function foldMemberJournal ( rows ) {
160 const memberRows = (rows || []). filter (isMemberRow);
161 const latestByKey = localState. foldAttemptsBySourceKey (memberRows);
162 const startsByPair = new Map ();
163 const terminalSeen = new Set ();
164 const pairingErrors = [];
165 const pairOf = ( row ) => JSON . stringify ([row.sourceStableKey, row.attemptId]);
166 for ( const row of memberRows) {
167 const pair = pairOf (row);
168 const error = ( code ) => pairingErrors. push ({ code, sourceStableKey: row.sourceStableKey, attemptId: row.attemptId });
169 if (row.status === 'started' ) {
170 const starts = (startsByPair. get (pair) || 0 ) + 1 ;
171 startsByPair. set (pair, starts);
172 if (starts > 1 ) error ( 'duplicate-started' );
173 continue ;
174 }
175 if ( ! startsByPair. has (pair)) error ( 'terminal-without-started' );
176 else if (terminalSeen. has (pair)) error ( 'duplicate-terminal' );
177 terminalSeen. add (pair);
178 }
179 return { memberRows, latestByKey, pairingErrors };
180 }
181
182 function buildStartedRow ({ sourceStableKey , runId , phase , writeSpecId , mode , now = null }) {
183 return {
184 schemaVersion: 1 ,
185 attemptId: crypto. randomUUID (),
186 sourceStableKey: requireString (sourceStableKey, 'sourceStableKey' ),
187 writeSpecId: requireString (writeSpecId, 'writeSpecId' ),
188 operation: R . MEMBER_OPERATION ,
189 targetEntityType: R . MEMBER_TARGET_ENTITY_TYPE ,
190 status: 'started' ,
191 runId: requireString (runId, 'runId' ),
192 phase: requireString (phase, 'phase' ),
193 startedAt: now || new Date (). toISOString (),
194 ... (mode && mode !== 'full' ? { selection: { offset: null , limit: null , filters: { mode } } } : {}),
195 };
196 }
197
198 function intOrNull ( value ) {
199 if (value === null || value === undefined ) return null ;
200 const n = Number (value);
201 return Number. isInteger (n) ? n : null ;
202 }
203
204 // The terminal follow-on row for a started row (markAttempt shape: same attemptId).
205 // Every field telemetry and the projection need is structured here; nothing downstream parses
206 // a message.
207 function buildMemberTerminalRow ( started , result , { builderFindings = [], knownMemberId = null , runId = null , phase = null , now = null } = {}) {
208 const cls = R . classifyMemberResult (result);
209 const policy = R . POLICY [cls];
210 const findings = [ ... (builderFindings || []), ... ((result && result.findings) || [])];
211 for ( const finding of findings) {
212 if ( ! finding || ! R . isMemberFinding (finding.code)) throw new Error ( `unknown member finding: ${ JSON . stringify ( finding && finding . code ) }` );
213 }
214 const row = {
215 ... started,
216 runId: runId || started.runId,
217 phase: phase || started.phase,
218 status: policy.journalStatus,
219 outcomeCode: result.outcome,
220 findingCodes: findings. map (( finding ) => finding.code),
221 completedAt: now || new Date (). toISOString (),
222 };
223 delete row.startedAt;
224 if (policy.matchedBy) row.matchedBy = policy.matchedBy;
225 if (result.memberId) row.targetId = String (result.memberId);
226 if (policy.journalStatus === 'deferred' ) row.reason = result.outcome;
227 if (cls === 'ambiguous' && Number. isInteger (result.matchCount)) row.matchCount = result.matchCount;
228 if (cls === 'failed-not-sent' ) row.requestSent = false ;
229 if (policy.failureOutcome) {
230 const error = result.error || {};
231 const message = String (error.message === undefined ? '' : error.message). slice ( 0 , 300 );
232 row.wixApiSurface = result.wixApiSurface || 'members/v1' ;
233 row.failure = {
234 sourceId: started.sourceStableKey,
235 targetEntityId: knownMemberId ? String (knownMemberId) : null ,
236 operation: R . MEMBER_OPERATION ,
237 httpStatus: intOrNull (error.status),
238 errorCode: R . deriveMemberErrorCode (error),
239 scopeGroupName: null ,
240 outcome: policy.failureOutcome,
241 message,
242 };
243 // An identifier in the message is extracted into a field before anything could shorten it.
244 const identifiers = [ ...new Set ((message. match ( GUID_IN_TEXT ) || []). map (( id ) => id. toLowerCase ()))];
245 if (identifiers. length ) row.failure.identifiersInMessage = identifiers;
246 }
247 const retried = findings. find (( finding ) => finding.code === R . MEMBER_FINDING . CREATE_RETRIED );
248 if (retried) {
249 row.retry = {
250 count: 1 ,
251 firstErrorCode: R . deriveMemberErrorCode ({
252 status: retried.firstErrorStatus,
253 applicationErrorCode: retried.firstErrorApplicationCode,
254 outcomeKnown: retried.firstErrorOutcomeKnown,
255 }),
256 firstErrorStatus: intOrNull (retried.firstErrorStatus),
257 };
258 }
259 return row;
260 }
261
262 // Which records a pass revisits. A subset pass selects within the snapshot by the latest row.
263 function selectMemberRecords ( records , keys , mode , latestByKey ) {
264 const out = [];
265 records. forEach (( record , index ) => {
266 const key = keys[index];
267 const latest = latestByKey. get (key) || null ;
268 const status = latest ? latest.status : null ;
269 let take;
270 switch (mode) {
271 case 'full' : take = true ; break ;
272 case 'failed-only' : take = Boolean (status && status. startsWith ( 'failed' )); break ;
273 case 'deferred-only' : take = status === 'deferred' ; break ;
274 case 'unresolved-only' : take = status === 'started' || status === 'succeeded_unverified' ; break ;
275 case 'missing-only' : take = latest === null ; break ;
276 default : take = false ;
277 }
278 if (take) out. push ({ record, sourceStableKey: key });
279 });
280 return out;
281 }
282
283 function notSentError ( error ) {
284 return {
285 message: String (error.message === undefined ? error : error.message). slice ( 0 , 300 ),
286 status: null ,
287 applicationErrorCode: null ,
288 outcomeKnown: true ,
289 requestSent: false ,
290 code: error.code === undefined ? null : error.code,
291 };
292 }
293
294 // The member pass. The caller supplies how to build and send one member (`ensure`) and how to
295 // persist the crosswalk; everything about order, identity and journaling lives here.
296 //
297 // ensure(record) -> { result, builderFindings?, knownMemberId? }
298 // persistCrosswalk(record, result) called before the terminal row whenever result.memberId
299 //
300 // Returns { ok, gate, snapshot, mode, selected }. `ok: false` means the population gate refused:
301 // zero journal rows and zero Wix calls were made. A subset pass with no snapshot (no full pass
302 // ran yet) takes its snapshot from the supplied records, exactly as a full pass would, and then
303 // selects within it: the supplied records are the population either way. A subset pass adds
304 // supplied keys the snapshot lacks; it never drops one.
305 //
306 // An `ensure` that throws an error marked requestSent false (refused before anything was sent)
307 // ends that record as member-failed, not sent, and the pass goes on. Any other throw stops the
308 // pass and leaves the started row dangling (unresolved), because its outcome is unknown.
309 async function runMemberPass ({
310 projectDir ,
311 records ,
312 runId ,
313 phase = 'import' ,
314 writeSpecId = 'members' ,
315 mode = 'full' ,
316 sample = false ,
317 getSourceStableKey = defaultSourceStableKey,
318 sourceFile = null ,
319 sourceChecksum = null ,
320 ensure ,
321 persistCrosswalk = null ,
322 } = {}) {
323 requireString (projectDir, 'projectDir' );
324 requireString (runId, 'runId' );
325 const selection = normalizeMode (mode);
326 if ( typeof ensure !== 'function' ) throw new Error ( 'runMemberPass needs an ensure(record) function' );
327 if ( ! Array. isArray (records)) throw new Error ( 'runMemberPass needs the supplied records array' );
328
329 let snapshot = selection === 'full' ? null : await readMemberPopulation (projectDir);
330 const gate = validateMemberPopulation (records, { getSourceStableKey });
331 if ( ! gate.ok) return { ok: false , gate, snapshot, mode: selection, selected: 0 };
332
333 // The denominator only grows: a key a subset pass supplies that the snapshot lacks (a record
334 // added at source since the full pass) joins it. --missing-only then writes it; another subset
335 // mode reports it as having no journal row yet.
336 const known = new Set (snapshot ? snapshot.keys : []);
337 const added = gate.distinctKeys. filter (( key ) => ! known. has (key));
338 if ( ! snapshot || added. length ) {
339 // A grown snapshot keeps the original pass's sourceFile/sourceChecksum: provenance of the
340 // first population, not a digest of the current key list (nothing compares it to the keys).
341 snapshot = snapshot
342 ? buildMemberPopulation ({ runId: snapshot.runId, keys: [ ... snapshot.keys, ... added], sourceFile: snapshot.sourceFile, sourceChecksum: snapshot.sourceChecksum, now: snapshot.createdAt })
343 : buildMemberPopulation ({ runId, keys: gate.distinctKeys, sourceFile, sourceChecksum });
344 // A sample pass writes nothing that a real pass would read as its denominator.
345 if ( ! sample) await writeMemberPopulation (projectDir, snapshot);
346 }
347
348 const journalOptions = { sample: Boolean (sample) };
349 const journal = await localState. loadAttemptJournal (projectDir, journalOptions);
350 const { latestByKey } = foldMemberJournal (journal.rows);
351 const selected = selectMemberRecords (records, gate.keys, selection, latestByKey);
352
353 for ( const { record , sourceStableKey } of selected) {
354 const started = buildStartedRow ({ sourceStableKey, runId, phase, writeSpecId, mode: selection });
355 await localState. appendAttempt (projectDir, started, journalOptions);
356 let outcome;
357 try {
358 outcome = await ensure (record);
359 } catch (error) {
360 if ( ! R . memberRequestNotSent (error)) throw error;
361 outcome = { result: { outcome: R . MEMBER_OUTCOME . FAILED , memberId: null , error: notSentError (error), findings: [] } };
362 }
363 const result = outcome && outcome.result;
364 R . classifyMemberResult (result); // an unknown code stops here and leaves the start dangling (unresolved), never a guessed bucket
365 if (result.memberId && typeof persistCrosswalk === 'function' ) await persistCrosswalk (record, result);
366 const terminal = buildMemberTerminalRow (started, result, {
367 builderFindings: outcome.builderFindings || [],
368 knownMemberId: outcome.knownMemberId || null ,
369 });
370 await localState. appendAttempt (projectDir, terminal, journalOptions);
371 }
372 return { ok: true , gate, snapshot, mode: selection, selected: selected. length };
373 }
374
375 // --- projection --------------------------------------------------------------------------
376
377 function classOfRow ( row ) {
378 if ( ! R . isMemberOutcome (row.outcomeCode)) throw new Error ( `member journal row ${ row . sourceStableKey }: unknown outcome code ${ JSON . stringify ( row . outcomeCode ) }` );
379 let cls;
380 if (row.outcomeCode === R . MEMBER_OUTCOME . RECONCILED_EXISTING ) {
381 cls = row.matchedBy === 'crosswalk' ? 'reconciled-crosswalk' : row.matchedBy === 'loginEmail' ? 'reconciled-login-email' : null ;
382 if ( ! cls) throw new Error ( `member journal row ${ row . sourceStableKey }: reconciled row without matchedBy` );
383 } else if (row.outcomeCode === R . MEMBER_OUTCOME . FAILED ) {
384 cls = row.status === 'succeeded_unverified' ? 'failed-unknown' : row.status === 'failed_terminal' ? 'failed-rejected' : row.status === 'failed_retryable' ? 'failed-not-sent' : null ;
385 if ( ! cls) throw new Error ( `member journal row ${ row . sourceStableKey }: member-failed with status ${ row . status }` );
386 } else {
387 cls = R . POLICY_CLASSES . find (( name ) => R . POLICY [name].outcome === row.outcomeCode);
388 }
389 const policy = R . POLICY [cls];
390 if (policy.journalStatus !== row.status) {
391 throw new Error ( `member journal row ${ row . sourceStableKey }: ${ row . outcomeCode } must carry status ${ policy . journalStatus }, not ${ row . status }` );
392 }
393 return cls;
394 }
395
396 // A terminal row missing a structured field telemetry or the report needs fails the projection:
397 // filling it from prose is the thing this design forbids.
398 function assertTerminalRowFields ( row , cls ) {
399 const label = `member journal row ${ row . sourceStableKey }` ;
400 if ( ! Array. isArray (row.findingCodes)) throw new Error ( `${ label }: findingCodes must be an array` );
401 for ( const code of row.findingCodes) {
402 if ( ! R . isMemberFinding (code)) throw new Error ( `${ label }: unknown finding code ${ JSON . stringify ( code ) }` );
403 }
404 if ( R . POLICY [cls].failureOutcome) {
405 const failure = row.failure;
406 if ( ! failure || typeof failure !== 'object' ) throw new Error ( `${ label }: failure is required` );
407 if ( ! ( 'httpStatus' in failure)) throw new Error ( `${ label }: failure.httpStatus must be present` );
408 if ( typeof failure.errorCode !== 'string' || ! failure.errorCode || failure.errorCode. length > R . ERROR_CODE_MAX || / \s / . test (failure.errorCode)) {
409 throw new Error ( `${ label }: failure.errorCode must be a short machine code` );
410 }
411 }
412 if (row.retry !== undefined ) {
413 if ( ! row.retry || ! Number. isInteger (row.retry.count) || row.retry.count < 1 || typeof row.retry.firstErrorCode !== 'string' || ! row.retry.firstErrorCode) {
414 throw new Error ( `${ label }: retry must carry count >= 1 and firstErrorCode` );
415 }
416 }
417 }
418
419 function zeros ( codes ) {
420 return Object. fromEntries (codes. map (( code ) => [code, 0 ]));
421 }
422
423 function emptyMemberOutcomes () {
424 return {
425 entity: R . MEMBER_ENTITY ,
426 populationBasis: POPULATION_BASIS ,
427 byOutcome: zeros ( R . MEMBER_OUTCOMES ),
428 byClass: Object. fromEntries ( R . POLICY_CLASSES . map (( cls ) => [cls, {
429 outcome: R . POLICY [cls].outcome,
430 count: 0 ,
431 projection: R . POLICY [cls].projection,
432 nextAction: R . POLICY [cls].nextAction,
433 }])),
434 byFinding: zeros ( R . MEMBER_FINDINGS ),
435 populationDefect: { missingSourceKey: 0 , duplicateSourceKey: 0 , unknownSourceKey: 0 },
436 danglingStarted: 0 ,
437 unexpectedSkipped: 0 ,
438 projectionErrors: [],
439 };
440 }
441
442 function defectEvent ( gate ) {
443 const d = gate.defect;
444 return {
445 event_type: 'pipeline_defect' ,
446 subtype: 'state_inconsistency' ,
447 stage: TELEMETRY_STAGE ,
448 skill: TELEMETRY_SKILL ,
449 entity_type: TELEMETRY_ENTITY ,
450 severity: 'blocking' ,
451 count: Math. max ( 1 , gate.failingRecords),
452 what_happened: `member population gate refused the pass before any write: ${ d . missingSourceKey } missing, ${ d . duplicateSourceKey } duplicate, ${ d . unknownSourceKey } unknown source keys` ,
453 };
454 }
455
456 // Folded rows -> completion inputs + telemetry events. `gate` is the pass's gate result; a
457 // refused gate projects the defect without reading the journal.
458 function projectMemberCompletion ({ rows = [], snapshot = null , runId = null , gate = null } = {}) {
459 const memberOutcomes = emptyMemberOutcomes ();
460
461 if (gate && ! gate.ok) {
462 memberOutcomes.populationDefect = { ... gate.defect };
463 const inScope = gate.distinctKeys. length ;
464 memberOutcomes.unexpectedSkipped = inScope;
465 return {
466 completion: {
467 entityCompleteness: [{
468 entity: R . MEMBER_ENTITY , subtype: 'all' , verificationKind: 'none' ,
469 extracted: gate.extracted, inScope, attempted: 0 , unexpectedSkipped: inScope,
470 reasons: [ `population gate refused the pass: ${ gate . defect . missingSourceKey } missing, ${ gate . defect . duplicateSourceKey } duplicate, ${ gate . defect . unknownSourceKey } unknown source keys; no member was written` ],
471 }],
472 unresolved: [],
473 droppedFields: [],
474 operatorActions: [],
475 memberOutcomes,
476 statuses: [ 'incomplete_with_mismatches' ],
477 },
478 telemetryEvents: [ defectEvent (gate)],
479 pairingErrors: [],
480 };
481 }
482
483 if ( ! snapshot) throw new Error ( 'projectMemberCompletion needs the member population snapshot' );
484 validateSnapshot (snapshot);
485 const { latestByKey , pairingErrors } = foldMemberJournal (rows);
486
487 const unresolvedKeys = [];
488 const unexpectedKeys = [];
489 const ambiguousKeys = [];
490 const droppedByFinding = {};
491 const aggregates = new Map ();
492 let attempted = 0 ;
493
494 // One event per distinct aggregation key -- exactly the recorder's class signature
495 // (classKeyFor) -- with `count` = records carrying that key. retry_count, recovered and
496 // severity are NOT in the key: records sharing a signature merge under the severity-merge rule, so the
497 // recorder's finalize fold leaves every emitted event unchanged. A record contributes at most
498 // once to a key, even when two of its findings map to the same key. This pass emits each key
499 // once; a later member pass in the same telemetry run that emits the same signature is folded
500 // by the recorder's own documented rules, unchanged.
501 const addEvent = ( recordKeys , event , { outcomeCode = null , findingCode = null , retryCount = 0 , recovered = false } = {}) => {
502 const groupKey = recorderSignature (event);
503 if (recordKeys. has (groupKey)) return ;
504 recordKeys. add (groupKey);
505 let agg = aggregates. get (groupKey);
506 if ( ! agg) {
507 agg = { event: { ... event, count: 0 }, outcomeCodes: new Set (), findingCodes: new Set (), allRecovered: true };
508 if (event.event_type === 'error' ) agg.event.retry_count = 0 ;
509 aggregates. set (groupKey, agg);
510 }
511 agg.event.count += 1 ;
512 if ( SEVERITY_RANK [event.severity] > SEVERITY_RANK [agg.event.severity]) agg.event.severity = event.severity;
513 if (event.event_type === 'error' ) {
514 agg.event.retry_count += retryCount;
515 agg.allRecovered = agg.allRecovered && recovered;
516 }
517 if (outcomeCode) agg.outcomeCodes. add (outcomeCode);
518 if (findingCode) agg.findingCodes. add (findingCode);
519 };
520
521 for ( const key of snapshot.keys) {
522 const row = latestByKey. get (key);
523 if ( ! row) { unexpectedKeys. push (key); continue ; }
524 attempted += 1 ;
525 if (row.status === 'started' ) { unresolvedKeys. push (key); memberOutcomes.danglingStarted += 1 ; continue ; }
526 const cls = classOfRow (row);
527 assertTerminalRowFields (row, cls);
528 const policy = R . POLICY [cls];
529 memberOutcomes.byOutcome[row.outcomeCode] += 1 ;
530 memberOutcomes.byClass[cls].count += 1 ;
531 for ( const code of new Set (row.findingCodes)) memberOutcomes.byFinding[code] += 1 ;
532 if (policy.projection === R . PROJECTION . UNRESOLVED ) unresolvedKeys. push (key);
533 if (cls === 'ambiguous' ) ambiguousKeys. push (key);
534 const droppedCodes = cls === 'written'
535 ? [ ...new Set (row.findingCodes)]. filter (( code ) => R . FINDING_POLICY [code].droppedField)
536 : [];
537 for ( const code of droppedCodes) droppedByFinding[code] = (droppedByFinding[code] || 0 ) + 1 ;
538
539 // Telemetry: only records this run concluded. A record concluded earlier is not re-emitted.
540 if ( ! runId || row.runId !== runId) continue ;
541 const retryCount = row.retry ? row.retry.count : 0 ;
542 const recordKeys = new Set ();
543 const base = { stage: TELEMETRY_STAGE , skill: TELEMETRY_SKILL , entity_type: TELEMETRY_ENTITY };
544 if (policy.telemetry) {
545 const t = policy.telemetry;
546 if (t.event_type === 'error' ) {
547 addEvent (recordKeys, {
548 event_type: 'error' , ... base, wix_api_surface: row.wixApiSurface || MEMBER_API_SURFACE ,
549 severity: t.severity, error_code: row.failure.errorCode,
550 }, { outcomeCode: row.outcomeCode, retryCount, recovered: false });
551 } else {
552 addEvent (recordKeys, { event_type: t.event_type, subtype: t.subtype, ... base, wix_api_surface: MEMBER_API_SURFACE , severity: t.severity }, { outcomeCode: row.outcomeCode });
553 }
554 }
555 if (cls === 'written' && retryCount > 0 ) {
556 addEvent (recordKeys, {
557 event_type: 'error' , ... base, wix_api_surface: MEMBER_API_SURFACE , severity: 'info' , error_code: row.retry.firstErrorCode,
558 }, { outcomeCode: row.outcomeCode, retryCount, recovered: true });
559 }
560 for ( const code of droppedCodes) {
561 addEvent (recordKeys, {
562 event_type: 'fidelity_loss' , subtype: 'dropped_field' , ... base, wix_api_surface: MEMBER_API_SURFACE , severity: 'degraded' ,
563 observed_shapes: [{ contact: { phones: 'array' } }],
564 }, { findingCode: code });
565 }
566 }
567 memberOutcomes.unexpectedSkipped = unexpectedKeys. length ;
568 memberOutcomes.projectionErrors = pairingErrors. map (( error ) => ({ code: error.code, sourceStableKey: error.sourceStableKey }));
569
570 const byProjection = { imported: 0 , alreadyPresentByCrosswalk: 0 , deferred: 0 , failed: 0 , unresolved: 0 };
571 for ( const cls of R . POLICY_CLASSES ) byProjection[ R . POLICY [cls].projection] += memberOutcomes.byClass[cls].count;
572 byProjection.unresolved += memberOutcomes.danglingStarted;
573 const partitionSum = Object. values (byProjection). reduce (( a , b ) => a + b, 0 ) + unexpectedKeys. length ;
574 const partitionMismatch = partitionSum !== snapshot.keys. length ;
575
576 const reasons = [];
577 for ( const cls of R . POLICY_CLASSES ) {
578 const entry = memberOutcomes.byClass[cls];
579 if ( ! entry.count) continue ;
580 const label = R . POLICY [cls].reasonLine || `${ entry . outcome }${ cls . startsWith ( 'failed-' ) ? ` (${ { 'failed-unknown' : 'outcome unknown' , 'failed-not-sent' : 'not sent' }[ cls ] || 'rejected'})` : ''}` ;
581 reasons. push ( `${ label }: ${ entry . count }${ entry . nextAction ? ` -- ${ entry . nextAction }` : ''}` );
582 }
583 if (memberOutcomes.danglingStarted) reasons. push ( `write started and never concluded: ${ memberOutcomes . danglingStarted } -- re-run with --unresolved-only: reads the member back by login email` );
584 if (unexpectedKeys. length ) reasons. push ( `no journal row at all: ${ unexpectedKeys . length }` );
585
586 const events = [ ... aggregates. values ()]. map (({ event , outcomeCodes , findingCodes , allRecovered }) => {
587 if (event.event_type === 'error' ) event.recovered = allRecovered;
588 event.what_happened = findingCodes.size
589 ? `${ event . count } written member record(s): contact.phones dropped (${ [ ... findingCodes ]. sort (). join ( ', ' ) })`
590 : `${ event . count } member record(s) ended ${ [ ... outcomeCodes ]. sort (). join ( ', ' ) }${ event . error_code ? ` with error ${ event . error_code }` : ''}` ;
591 return event;
592 });
593
594 return {
595 completion: {
596 entityCompleteness: [{
597 entity: R . MEMBER_ENTITY , subtype: 'all' , verificationKind: 'none' ,
598 extracted: snapshot.keys. length , inScope: snapshot.keys. length , attempted,
599 ... byProjection,
600 unexpectedSkipped: unexpectedKeys. length ,
601 reasons,
602 }],
603 unresolved: unresolvedKeys. length ? [{ entity: R . MEMBER_ENTITY , kind: 'unmapped_source' , count: unresolvedKeys. length , sourceIds: unresolvedKeys }] : [],
604 droppedFields: Object. entries (droppedByFinding). map (([ code , n ]) => ({
605 entity: R . MEMBER_ENTITY , field: R . FINDING_POLICY [code].droppedField, recordCount: n, reasonCode: code,
606 })),
607 operatorActions: ambiguousKeys. length ? [{ ... R . OPERATOR_ACTION_AMBIGUOUS , entity: R . MEMBER_ENTITY , affectedCount: ambiguousKeys. length }] : [],
608 memberOutcomes,
609 statuses: pairingErrors. length || partitionMismatch ? [ 'incomplete_with_mismatches' ] : [],
610 },
611 telemetryEvents: events,
612 pairingErrors,
613 };
614 }
615
616 // Read the journal (and the snapshot, when the pass did not hand one over) and project.
617 //
618 // An optional `journalRows` lets a caller that already loaded the journal once
619 // (to also call `attachAttemptReasons` for the non-member rows) hand those rows over directly,
620 // so `loadAttemptJournal` is called once per report, not once per entity. When omitted,
621 // `projectMemberPass` loads the journal itself exactly as it always has, so every existing
622 // caller is unaffected.
623 async function projectMemberPass ( projectDir , { runId , pass = null , sample = false , journalRows = null } = {}) {
624 requireString (projectDir, 'projectDir' );
625 if (pass && pass.ok === false ) return projectMemberCompletion ({ gate: pass.gate, runId });
626 const snapshot = (pass && pass.snapshot) || await readMemberPopulation (projectDir);
627 if ( ! snapshot) throw Object. assign ( new Error ( `no ${ MEMBER_POPULATION_FILE }: run a full member pass before projecting members` ), { code: SNAPSHOT_MISSING });
628 const rows = journalRows !== null
629 ? journalRows
630 : ( await localState. loadAttemptJournal (projectDir, { sample: Boolean (sample) })).rows;
631 return projectMemberCompletion ({ rows, snapshot, runId, gate: pass ? pass.gate : null });
632 }
633
634 // Merge the member fragment into the run's completion input, replacing any earlier members row.
635 function mergeMemberCompletion ( completionInput = {}, fragment ) {
636 const withoutMembers = ( list ) => (list || []). filter (( row ) => row.entity !== R . MEMBER_ENTITY );
637 return {
638 ... completionInput,
639 entityCompleteness: [ ... withoutMembers (completionInput.entityCompleteness), ... fragment.entityCompleteness],
640 unresolved: [ ... withoutMembers (completionInput.unresolved), ... fragment.unresolved],
641 droppedFields: [ ... withoutMembers (completionInput.droppedFields), ... fragment.droppedFields],
642 operatorActions: [ ... withoutMembers (completionInput.operatorActions), ... fragment.operatorActions],
643 statuses: [ ... (completionInput.statuses || []), ... fragment.statuses],
644 memberOutcomes: fragment.memberOutcomes,
645 };
646 }
647
648 // Emit once per aggregated event. Telemetry must never abort a migration, so failures are
649 // counted and returned, not thrown.
650 async function emitMemberTelemetry ( events , record ) {
651 let emitted = 0 ;
652 const failed = [];
653 for ( const event of events || []) {
654 try {
655 await record (event);
656 emitted += 1 ;
657 } catch (error) {
658 failed. push ({ event_type: event.event_type, error: String (error && error.message). slice ( 0 , 200 ) });
659 }
660 }
661 return { emitted, failed };
662 }
663
664 module . exports = {
665 MEMBER_POPULATION_FILE,
666 POPULATION_BASIS,
667 SELECTION_MODES,
668 SNAPSHOT_MISSING,
669 TELEMETRY_SKILL,
670 TELEMETRY_STAGE,
671 validateMemberPopulation,
672 readMemberPopulation,
673 buildMemberPopulation,
674 writeMemberPopulation,
675 foldMemberJournal,
676 buildStartedRow,
677 buildMemberTerminalRow,
678 selectMemberRecords,
679 runMemberPass,
680 projectMemberCompletion,
681 projectMemberPass,
682 mergeMemberCompletion,
683 emitMemberTelemetry,
684 };