Setting the file. One moment.
Cms Item Quota · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
121
const collectionOf
— line 121
This file
Number 41.132
Position 132 of 174
Type JavaScript
Size 23 KB
Lines 343 lib/ cms-item-quota.js
JavaScript · 343 lines · 23 KB
// - the hold: one typed, shared hold file (state/holds/cms-item-quota.json). The first
15 // WDE0195 opens it; while it is open the client refuses CMS item creates before sending
16 // (a known non-write, journaled `deferred`), re-checks the quota on a timer and on every new
17 // run, and releases it by itself once the quota has room. No other entity is touched;
18 // - archive scope and packing: archive rows only for records the import actually writes, and
19 // one item per parent record carrying its same-parent sub-records.
20
21 const fs = require ( 'node:fs' );
22 const path = require ( 'node:path' );
23 const wixAuth = require ( '../../../lib/wix-auth.js' );
24
25 const WIXAPIS = 'https://www.wixapis.com' ;
26 const QUOTA_ERROR_CODE = 'WDE0195' ;
27 const HOLD_CODE = 'cms-item-quota-exhausted' ;
28 // The operator's explicit decision that the waiting archive data is not needed. Never automatic.
29 const SKIP_CODE = 'cms-archive-skipped-by-operator' ;
30 const HOLD_FILE = 'state/holds/cms-item-quota.json' ;
31 // How often a held run asks Wix again. One read per interval, never one per record.
32 const RECHECK_MS = 5 * 60 * 1000 ;
33 // Wix limits one item to 512 KB across its fields; packing stays well under it.
34 const ITEM_BYTES_LIMIT = 400 * 1024 ;
35
36 // Item creates that spend quota. Updates of an existing item do not, but `save` may create.
37 const ITEM_CREATE_PATHS = new Set ([ '/wix-data/v2/items' , '/wix-data/v2/items/save' , '/wix-data/v2/bulk/items/insert' , '/wix-data/v2/bulk/items/save' ]);
38 function consumesItemQuota ( request ) {
39 if ( ! request || String (request.method || '' ). toUpperCase () !== 'POST' ) return false ;
40 try { const url = new URL (request.url); return url.origin === WIXAPIS && ITEM_CREATE_PATHS . has (url.pathname. replace ( / \/ +$ / , '' )); } catch { return false ; }
41 }
42
43 // The client attaches the parsed body and `applicationErrorCode`; the message is a fallback for an
44 // error a caller rebuilt from text. The HTTP status is not checked: Wix sends 429 today.
45 function isCmsItemQuotaError ( error ) {
46 if ( ! error || typeof error !== 'object' ) return false ;
47 if (error.cmsItemQuota) return true ;
48 const code = error.applicationErrorCode || error.body?.details?.applicationError?.code;
49 if (code === QUOTA_ERROR_CODE ) return true ;
50 return new RegExp ( ` \\ b${ QUOTA_ERROR_CODE } \\ b` ). test ( String (error.body?.message || error.message || '' ));
51 }
52
53 // GET Site Data Usage (wix-data/v1/site-data-usage): limit and live item count. `consistentRead`
54 // skips Wix's cache; used just before deciding to release a hold.
55 function buildGetSiteDataUsageRequest ({ consistentRead = false } = {}) {
56 return { method: 'GET' , url: `${ WIXAPIS }/wix-data/v1/site-data-usage/v1/site-data-usage${ consistentRead ? '?consistentRead=true' : ''}` };
57 }
58 function parseSiteDataUsage ( response ) {
59 const usage = response && response.siteDataUsage;
60 const limit = Number (usage && usage.itemCountLimit);
61 const used = Number (usage && usage.totalUsedLive && usage.totalUsedLive.items);
62 if ( ! Number. isFinite (limit) || limit <= 0 || ! Number. isFinite (used) || used < 0 ) return null ;
63 return { limit, used, remaining: Math. max ( 0 , limit - used) };
64 }
65 // Never throws: a site or token that cannot read usage is `null`, and the first WDE0195 teaches
66 // the limit instead.
67 async function readCmsItemQuota ( wix , { consistentRead = false , now = Date.now } = {}) {
68 try {
69 // The client accepts it only as a marked read (wix-writers marks it; required lazily, it requires us).
70 const quota = parseSiteDataUsage ( await wix. send ( require ( './wix-writers' ). buildGetSiteDataUsageRequest ({ consistentRead })));
71 return quota ? { ... quota, source: 'site-data-usage' , checkedAt: new Date ( now ()). toISOString () } : null ;
72 } catch (error) {
73 if (wixAuth. isRunInterruption (error)) throw error;
74 return null ;
75 }
76 }
77
78 // The line the plan shows before consent. `expectedRows` is the archive (and every other custom
79 // collection) rows this import will create.
80 function quotaPlanLine ({ expectedRows , quota }) {
81 const expected = Number. isSafeInteger (expectedRows) && expectedRows >= 0 ? expectedRows : null ;
82 if (expected === null ) throw Object. assign ( new Error ( 'expectedRows must be a non-negative integer' ), { code: 'cms_item_quota_plan_invalid' });
83 if ( ! quota) {
84 return { status: 'unknown' , expectedRows: expected, limit: null , used: null , remaining: null , shortfall: null ,
85 summary: `About ${ expected } CMS items will be created. The site's CMS item quota could not be read; if the import reaches it, CMS rows wait in a hold and resume by themselves after more room is available.` };
86 }
87 const shortfall = Math. max ( 0 , expected - quota.remaining);
88 return { status: shortfall ? 'exceeds' : 'fits' , expectedRows: expected, limit: quota.limit, used: quota.used, remaining: quota.remaining, shortfall, checkedAt: quota.checkedAt || null ,
89 summary: shortfall
90 ? `About ${ expected } CMS items will be created, but the site has room for ${ quota . remaining } (plan limit ${ quota . limit }, ${ quota . used } used). The last ~${ shortfall } wait in a hold until the plan is upgraded or items are deleted; everything else imports normally.`
91 : `About ${ expected } CMS items will be created; the site has room for ${ quota . remaining } (plan limit ${ quota . limit }, ${ quota . used } used).` };
92 }
93
94 // ---------------------------------------------------------------- the shared hold ------------
95
96 function holdPath ( projectDir ) { return path. join (projectDir, HOLD_FILE ); }
97 function readHold ( projectDir ) {
98 try { return JSON . parse (fs. readFileSync ( holdPath (projectDir), 'utf8' )); } catch { return null ; }
99 }
100 function writeHold ( projectDir , value ) {
101 const file = holdPath (projectDir);
102 fs. mkdirSync (path. dirname (file), { recursive: true });
103 fs. writeFileSync ( `${ file }.tmp` , `${ JSON . stringify ( value , null , 2 ) } \n ` );
104 fs. renameSync ( `${ file }.tmp` , file);
105 }
106
107 function skipRefusal ( hold ) {
108 return Object. assign ( new Error ( `The operator decided the remaining CMS archive data is not needed (${ HOLD_FILE }); this item is skipped, not lost from the source` ), {
109 code: SKIP_CODE , archiveSkipped: true , cmsItemQuota: true , decision: hold.skip,
110 requestSent: false , requestApplied: false , outcomeKnown: true ,
111 });
112 }
113 function holdRefusal ( hold ) {
114 return Object. assign ( new Error ( `CMS item quota is used up (hold ${ HOLD_FILE }); this item waits and resumes when the quota frees` ), {
115 code: HOLD_CODE , quotaHold: true , deferReason: HOLD_CODE , cmsItemQuota: true ,
116 requestSent: false , requestApplied: false , outcomeKnown: true , hold: hold ? { heldAt: hold.heldAt, nextCheckAt: hold.nextCheckAt } : null ,
117 });
118 }
119 // The operator's skip names the archive collections it covers; any other collection (a CMS
120 // fallback entity's rows, source pages) keeps the ordinary hold.
121 const collectionOf = ( request ) => (request && request.body && request.body.dataCollectionId) || null ;
122 const skips = ( hold , request ) => Boolean (hold && hold.skip && hold.skip.collectionIds. includes ( collectionOf (request)));
123 // Only an insert always creates an item; a save may update one, which spends nothing.
124 const INSERT_PATHS = new Set ([ '/wix-data/v2/items' , '/wix-data/v2/bulk/items/insert' ]);
125 function isInsert ( request ) {
126 try { return INSERT_PATHS . has ( new URL (request.url).pathname. replace ( / \/ +$ / , '' )); } catch { return false ; }
127 }
128
129 // One gate per project (or per site, with no project) in this process, shared by every client:
130 // the quota is the site's, not one client's. The file carries the hold across processes and runs.
131 const gates = new Map ();
132 function quotaGate ({ projectDir = null , siteId = null , now = Date.now, recheckMs = RECHECK_MS } = {}) {
133 const key = projectDir ? `project:${ path . resolve ( projectDir ) }` : `site:${ siteId || ''}` ;
134 if ( ! gates. has (key)) gates. set (key, createGate ({ projectDir, now, recheckMs }));
135 return gates. get (key);
136 }
137
138 function createGate ({ projectDir , now , recheckMs }) {
139 let hold = projectDir ? readHold (projectDir) : null ;
140 // A new run (a new process) re-checks an inherited hold at its first write, whatever the timer says.
141 let checkedHere = false ;
142 let checking = null ;
143 let unsaved = 0 ;
144 const iso = ( ms ) => new Date (ms). toISOString ();
145 const save = () => { unsaved = 0 ; if (projectDir) writeHold (projectDir, hold); };
146 const held = () => Boolean (hold && hold.status === 'held' );
147 // Another process (the operator's skip, another run's release) may have changed the file.
148 const reload = () => { if (projectDir) hold = readHold (projectDir) || hold; };
149 function release ( reason , quota ) {
150 hold = { ... hold, status: 'released' , releasedAt: iso ( now ()), releaseReason: reason, ... (quota ? { quota } : {}) };
151 save ();
152 }
153 const refuse = () => { hold.refusedCount = (hold.refusedCount || 0 ) + 1 ; if ( ++ unsaved >= 100 ) save (); return holdRefusal (hold); };
154 // Ask Wix once. Room → release. Unreadable usage → one write (the first to claim it) is the
155 // probe; its own answer releases or extends the hold.
156 async function recheck ( readUsage ) {
157 checkedHere = true ;
158 reload ();
159 if ( ! held ()) return { verdict: 'send' };
160 hold = { ... hold, lastCheckedAt: iso ( now ()), nextCheckAt: iso ( now () + recheckMs), checks: (hold.checks || 0 ) + 1 };
161 const quota = readUsage ? await readUsage () : null ;
162 if (quota && quota.remaining > 0 ) { release ( 'quota-has-room' , quota); return { verdict: 'send' }; }
163 if (quota) { hold = { ... hold, quota }; save (); return { verdict: 'refuse' }; }
164 save ();
165 return { verdict: 'probe' , probeTaken: false };
166 }
167 return {
168 get hold () { return hold; },
169 held,
170 // Before a quota-spending write. Throws the operator's skip for a skipped archive collection,
171 // else the typed hold refusal while held.
172 async admit ({ request = null , readUsage = null } = {}) {
173 if ( ! checkedHere && ! checking) reload ();
174 if ( skips (hold, request)) throw skipRefusal (hold);
175 if ( ! held ()) return 'send' ;
176 if (checkedHere && Date. parse (hold.nextCheckAt || 0 ) > now ()) throw refuse ();
177 // Concurrent writers share one check.
178 if ( ! checking) checking = recheck (readUsage). finally (() => { checking = null ; });
179 const result = await checking;
180 if ( skips (hold, request)) throw skipRefusal (hold);
181 if (result.verdict === 'refuse' ) throw refuse ();
182 if (result.verdict === 'probe' ) { if (result.probeTaken) throw refuse (); result.probeTaken = true ; }
183 return result.verdict;
184 },
185 // A quota-spending insert succeeded: the quota has room. A save may have been an update.
186 passed ( request = null ) { if ( held () && ( ! request || isInsert (request))) release ( 'write-succeeded' ); },
187 // Wix answered WDE0195: open the hold (or push its next check out).
188 opened ({ request = null , error = null } = {}) {
189 const at = now ();
190 checkedHere = true ;
191 reload ();
192 if ( ! held ()) {
193 hold = { schemaVersion: 1 , ... (hold && hold.skip ? { skip: hold.skip } : {}), status: 'held' , code: HOLD_CODE , heldAt: iso (at), lastCheckedAt: iso (at), nextCheckAt: iso (at + recheckMs), checks: 0 , refusedCount: 0 ,
194 firstRefusal: { collectionId: collectionOf (request), sourceStableKey: request?.sourceStableKey || null , httpStatus: error?.status ?? null , errorCode: QUOTA_ERROR_CODE },
195 pending: 'Every CMS item refused while held is journaled `deferred` with reason cms-item-quota-exhausted.' ,
196 resume: 'Automatic: the import re-checks the quota every few minutes and at the start of every run, then writes the held rows again (`--deferred-only` selects them).' ,
197 blocker: { owner: 'site owner (billing)' , requiredInput: 'Upgrade the site plan to one with more CMS items, or delete CMS items' , recoveryCode: HOLD_CODE } };
198 } else {
199 hold = { ... hold, lastCheckedAt: iso (at), nextCheckAt: iso (at + recheckMs) };
200 }
201 save ();
202 },
203 // The fields that type a WDE0195 the client received: a hold deferral, or the operator's skip.
204 refusalFields ( request = null ) {
205 if ( skips (hold, request)) return { code: SKIP_CODE , archiveSkipped: true , cmsItemQuota: true , decision: hold.skip, requestApplied: false , outcomeKnown: true };
206 return { code: HOLD_CODE , quotaHold: true , deferReason: HOLD_CODE , cmsItemQuota: true , requestApplied: false , outcomeKnown: true };
207 },
208 // Persist the refusal count (the import calls it at the end of a pass; cheap when nothing changed).
209 flush () { if (unsaved && hold) save (); },
210 };
211 }
212
213 // The journal row a caller writes for a hold refusal or a WDE0195: a typed deferral, never a
214 // failure and never an unknown outcome. After the operator's skip it is a recorded
215 // `skipped_out_of_scope` row naming the decision. Null for any other error.
216 function quotaDeferral ( error ) {
217 if (error && error.archiveSkipped === true ) return { status: 'skipped_out_of_scope' , reason: SKIP_CODE , decision: error.decision || null , requestApplied: false };
218 if ( ! (error && (error.quotaHold === true || isCmsItemQuotaError (error)))) return null ;
219 return { status: 'deferred' , reason: HOLD_CODE , deferral: { reason: HOLD_CODE , hold: HOLD_FILE }, requestApplied: false };
220 }
221
222 // The cutover task while the hold is open: read from the file, so it survives the run that opened it.
223 // After the operator's skip it becomes the Import Operator's re-run, which turns the rows deferred
224 // before the skip into recorded skips.
225 const RERUN = 'node skills/wix-replatform/scripts/cms-item-quota.js status --project-dir <project>, then run the import again with --deferred-only for the cms/data-item task' ;
226 function cutoverAction ( projectDir ) {
227 const hold = readHold (projectDir);
228 if ( ! hold || hold.status !== 'held' ) return null ;
229 const base = { code: HOLD_CODE , kind: 'manual_action' , stage: 'cutover' , blocking: false , heldAt: hold.heldAt, refusedCount: hold.refusedCount || 0 , quota: hold.quota || null };
230 if (hold.skip) {
231 return { ... base, owner: 'Import Operator' , role: 'Import Operator' , requiresImportOperator: true , skip: hold.skip,
232 summary: `The operator decided the waiting archive data (${ hold . skip . collectionIds . join ( ', ' ) }) is not needed; rows deferred before that still need one re-run to be recorded as skipped. Other CMS items still wait for quota.` ,
233 action: `Run: ${ RERUN }.` ,
234 evidence: `The deferred rows of ${ hold . skip . collectionIds . join ( ', ' ) } become skipped_out_of_scope with reason ${ SKIP_CODE }; any other collection's rows become imported once the quota has room.` };
235 }
236 return { ... base, owner: 'site owner (billing)' , role: 'Cutover Operator' ,
237 summary: 'The site has used its CMS item quota; some archived source data is waiting.' ,
238 action: 'Either: in Business Manager, upgrade the site plan to one with more CMS items (or delete unneeded CMS items); the import notices the free room and writes the waiting rows by itself. Or: decide the waiting archive data is not needed.' ,
239 // The skip is recorded on the migration machine, so it needs the Import Operator.
240 skipRequiresImportOperator: true ,
241 skipPrompt: `The Cutover Operator decided the remaining CMS archive data is not needed. Run: node skills/wix-replatform/scripts/cms-item-quota.js skip --project-dir <project> --collection <archive collection id> --decided-by "<name>" --reason "<why>"; then run the import again with --deferred-only for the cms/data-item task, so the rows deferred before the decision are recorded as skipped.` ,
242 evidence: `${ HOLD_FILE } status becomes "released" and the deferred cms-item-quota-exhausted rows become imported, or it records "skip" with the decision and those rows become skipped_out_of_scope.` };
243 }
244
245 // The operator's explicit decision that the waiting archive data of the named collections is not
246 // needed: recorded in the hold file with who, why and which collections, and every later item for
247 // those collections is journaled skipped_out_of_scope with the decision. Never a silent drop; the
248 // source extract keeps every row. Other collections keep the hold. `clearArchiveSkip` undoes it.
249 function recordArchiveSkip ( projectDir , { decidedBy , reason , collectionIds , now = Date.now } = {}) {
250 if ( typeof decidedBy !== 'string' || ! decidedBy. trim () || typeof reason !== 'string' || ! reason. trim ()) throw Object. assign ( new Error ( 'an archive skip needs decidedBy and reason' ), { code: 'cms_archive_skip_invalid' });
251 if ( ! Array. isArray (collectionIds) || ! collectionIds. length || collectionIds. some (( id ) => typeof id !== 'string' || ! id. trim ())) throw Object. assign ( new Error ( 'an archive skip names the archive collection ids it covers' ), { code: 'cms_archive_skip_invalid' });
252 const prior = readHold (projectDir) || { schemaVersion: 1 , status: 'released' , code: HOLD_CODE };
253 const hold = { ... prior, skip: { code: SKIP_CODE , collectionIds: [ ...new Set (collectionIds. map (( id ) => id. trim ()))], decidedBy: decidedBy. trim (), reason: reason. trim (), decidedAt: new Date ( now ()). toISOString () } };
254 writeHold (projectDir, hold);
255 return hold;
256 }
257 function clearArchiveSkip ( projectDir , { now = Date.now } = {}) {
258 const prior = readHold (projectDir);
259 if ( ! prior || ! prior.skip) return prior;
260 // The next write re-checks at once: room → release, else the hold waits as before.
261 const hold = { ... prior, nextCheckAt: new Date ( now ()). toISOString (), skipCleared: { ... prior.skip, clearedAt: new Date ( now ()). toISOString () } };
262 delete hold.skip;
263 writeHold (projectDir, hold);
264 return hold;
265 }
266
267 // ---------------------------------------------------------------- archive scope & packing ----
268
269 // Archive rows only for a record the import writes: its entity is in the resolved execution scope
270 // and the record itself is in scope (an out-of-scope user or library media file is not archived).
271 // `inScope(sourceEntity, record)` is the same record predicate the entity's own pass uses.
272 function archiveScope ({ resolvedEntityRefs , inScope = null }) {
273 if ( ! Array. isArray (resolvedEntityRefs)) throw Object. assign ( new Error ( 'resolvedEntityRefs must be the execution scope' ), { code: 'cms_archive_scope_invalid' });
274 const resolved = new Set (resolvedEntityRefs);
275 return ( sourceEntity , record ) => resolved. has (sourceEntity) && ( ! inScope || inScope (sourceEntity, record) === true );
276 }
277
278 // Fields with a native or extended-field home are not archived: only `cms` decisions are.
279 function archivedLocations ( row ) {
280 return (row.fieldMappings || []). filter (( d ) => d.disposition === 'cms' ). flatMap (( d ) => d.sourcePaths || []);
281 }
282
283 // One archive item per parent record. Two source records of the same thing (a WordPress user and
284 // the WooCommerce customer with the same id) take the same parentKey: the first is the item's
285 // payload, each other one an `alias` carrying only the top-level fields whose value differs
286 // (`$absent` lists the parent's keys it lacks); its `sourceKey` joins `childKeys` so it is still
287 // found by its own key. Same-parent sub-records (an order and its notes) ride in
288 // `children`, and `childKeys` lists their keys so a lookup by a child's key still finds the item
289 // (`hasSome` on an array field). An item that would pass the size limit is split into
290 // `<parentKey>#2`, `#3`… with the same parentKey; every part is found by `parentKey`.
291 function packArchiveItems ( entries , { itemBytesLimit = ITEM_BYTES_LIMIT } = {}) {
292 const parents = new Map ();
293 for ( const entry of entries) {
294 if ( ! entry || typeof entry.parentKey !== 'string' || ! entry.parentKey) throw Object. assign ( new Error ( 'archive entry needs a parentKey' ), { code: 'cms_archive_entry_invalid' });
295 if ( ! parents. has (entry.parentKey)) parents. set (entry.parentKey, { parent: null , aliases: [], children: [] });
296 const slot = parents. get (entry.parentKey);
297 if (entry.childKey) slot.children. push ({ key: entry.childKey, sourceEntity: entry.sourceEntity, payload: entry.payload });
298 else if ( ! slot.parent) slot.parent = { sourceKey: entry.sourceKey || null , sourceEntity: entry.sourceEntity, payload: entry.payload };
299 else slot.aliases. push ({ key: entry.sourceKey || null , sourceEntity: entry.sourceEntity, payload: changedFields (slot.parent.payload, entry.payload) });
300 }
301 const items = [];
302 const size = ( value ) => Buffer. byteLength ( JSON . stringify (value));
303 for ( const [ parentKey , { parent , aliases , children }] of parents) {
304 const base = { parentKey, sourceEntity: parent ? parent.sourceEntity : children[ 0 ].sourceEntity, payload: parent ? parent.payload : null };
305 let item = { ... base, ... (parent?.sourceKey ? { sourceKey: parent.sourceKey } : {}), ... (aliases. length ? { aliases } : {}), children: [], childKeys: aliases. map (( a ) => a.key). filter (Boolean) };
306 let part = 1 ;
307 for ( const child of children) {
308 if ((item.children. length || item.aliases) && size (item) + size (child) > itemBytesLimit) {
309 items. push (item);
310 part += 1 ;
311 item = { parentKey, itemKey: `${ parentKey }#${ part }` , sourceEntity: base.sourceEntity, payload: null , children: [], childKeys: [] };
312 }
313 item.children. push (child); item.childKeys. push (child.key);
314 }
315 items. push (item);
316 }
317 return items. map (( item ) => ({ itemKey: item.itemKey || item.parentKey, ... item }));
318 }
319
320 const ABSENT = '$absent' ;
321 function changedFields ( base , other ) {
322 if ( ! other || typeof other !== 'object' || Array. isArray (other) || ! base || typeof base !== 'object' || Array. isArray (base)) return other;
323 const changed = Object. fromEntries (Object. entries (other). filter (([ key , value ]) => JSON . stringify (value) !== JSON . stringify (base[key])));
324 // Keys the parent has and this record lacks, so the record is rebuilt exactly.
325 const absent = Object. keys (base). filter (( key ) => ! Object. hasOwn (other, key));
326 return absent. length ? { ... changed, [ ABSENT ]: absent } : changed;
327 }
328
329 // The expected custom-collection rows, for the plan line: packed archive items plus any other
330 // rows (source pages, a crosswalk mirror) the caller adds.
331 function expectedArchiveRows ( entries , { otherRows = 0 , itemBytesLimit } = {}) {
332 return packArchiveItems (entries, { itemBytesLimit }). length + otherRows;
333 }
334
335 module . exports = {
336 QUOTA_ERROR_CODE, HOLD_CODE, SKIP_CODE, HOLD_FILE, RECHECK_MS, ITEM_BYTES_LIMIT,
337 consumesItemQuota, isCmsItemQuotaError,
338 buildGetSiteDataUsageRequest, parseSiteDataUsage, readCmsItemQuota, quotaPlanLine,
339 holdPath, readHold, quotaGate, quotaDeferral, cutoverAction, recordArchiveSkip, clearArchiveSkip,
340 archiveScope, archivedLocations, packArchiveItems, expectedArchiveRows,
341 // Tests only: forget in-process gates so a test can model a fresh run.
342 _resetGates : () => gates. clear (),
343 };