Setting the file. One moment. URL Preservation State · Wix Replatform · wix/skills · Skills Docs104
function assertObject
— line 104
This file
- Number
- 47.31
- Position
- 31 of 34
- Type
- JavaScript
- Size
- 11 KB
- Lines
- 391
lib/url-preservation-state.js
JavaScript·391 lines·11 KB
'redirect_required'
,
11 'pending_target_route',
12 'source_url_missing',
13 'target_url_missing',
14 'not_public',
15 'manual_review',
16]);
17const UNRESOLVED_STATUS = new Set(['open', 'resolved', 'manual_review']);
18const REDIRECT_STATUS = new Set(['planned']);
19const BASE_PATH_STATUS = new Set(['planned', 'verified', 'unverified', 'manual_review']);
20
21function urlPreservationDir(projectDir) {
22 return path.join(projectDir, 'state', 'url-preservation');
23}
24
25function basePathsPath(projectDir) {
26 return path.join(urlPreservationDir(projectDir), 'base-paths.json');
27}
28
29function urlLedgerPath(projectDir) {
30 return path.join(urlPreservationDir(projectDir), 'url-ledger.ndjson');
31}
32
33function redirectsPath(projectDir) {
34 return path.join(urlPreservationDir(projectDir), 'redirects.ndjson');
35}
36
37function unresolvedPath(projectDir) {
38 return path.join(urlPreservationDir(projectDir), 'unresolved.ndjson');
39}
40
41async function mkdirp(dirPath) {
42 await fs.mkdir(dirPath, { recursive: true });
43}
44
45async function pathExists(filePath) {
46 try {
47 await fs.access(filePath);
48 return true;
49 } catch (error) {
50 if (error && error.code === 'ENOENT') {
51 return false;
52 }
53 throw error;
54 }
55}
56
57async function ensureEmptyNdjson(filePath) {
58 await mkdirp(path.dirname(filePath));
59 if (!(await pathExists(filePath))) {
60 await fs.writeFile(filePath, '', 'utf8');
61 return true;
62 }
63 return false;
64}
65
66async function writeJsonAtomic(filePath, data) {
67 await mkdirp(path.dirname(filePath));
68 const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
69 await fs.writeFile(tempPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
70 await fs.rename(tempPath, filePath);
71}
72
73async function appendNdjson(filePath, row) {
74 await mkdirp(path.dirname(filePath));
75 await fs.appendFile(filePath, `${JSON.stringify(row)}\n`, 'utf8');
76}
77
78async function readNdjson(filePath) {
79 let raw;
80 try {
81 raw = await fs.readFile(filePath, 'utf8');
82 } catch (error) {
83 if (error && error.code === 'ENOENT') {
84 return [];
85 }
86 throw error;
87 }
88 const rows = [];
89 const lines = raw.split(/\r?\n/);
90 for (let index = 0; index < lines.length; index += 1) {
91 const line = lines[index].trim();
92 if (!line) {
93 continue;
94 }
95 try {
96 rows.push(JSON.parse(line));
97 } catch (error) {
98 throw new Error(`${filePath}:${index + 1} invalid NDJSON: ${error.message}`);
99 }
100 }
101 return rows;
102}
103
104function assertObject(row, label) {
105 if (!row || typeof row !== 'object' || Array.isArray(row)) {
106 throw new Error(`${label} must be an object`);
107 }
108}
109
110function requireString(row, field, label, errors) {
111 if (!row[field] || typeof row[field] !== 'string') {
112 errors.push(`${label}.${field} must be a non-empty string`);
113 }
114}
115
116function validateTimestamp(row, field, label, errors) {
117 if (row[field] !== undefined && Number.isNaN(Date.parse(row[field]))) {
118 errors.push(`${label}.${field} must be an ISO timestamp when present`);
119 }
120}
121
122function validateUrlStatus(row, label, errors) {
123 if (row.urlStatus && !URL_STATUS.has(row.urlStatus)) {
124 errors.push(`${label}.urlStatus must be one of: ${Array.from(URL_STATUS).join(', ')}`);
125 }
126}
127
128function validateRow(row, { allowThrow = true, label = 'row', required = [], validate = null } = {}) {
129 const errors = [];
130 try {
131 assertObject(row, label);
132 } catch (error) {
133 if (allowThrow) {
134 throw error;
135 }
136 return { ok: false, errors: [error.message] };
137 }
138 if (row.schemaVersion !== SCHEMA_VERSION) {
139 errors.push(`${label}.schemaVersion must be ${SCHEMA_VERSION}`);
140 }
141 for (const field of required) {
142 requireString(row, field, label, errors);
143 }
144 validateTimestamp(row, 'createdAt', label, errors);
145 validateTimestamp(row, 'updatedAt', label, errors);
146 if (validate) {
147 validate(row, label, errors);
148 }
149 if (errors.length && allowThrow) {
150 throw new Error(errors.join('; '));
151 }
152 return { ok: errors.length === 0, errors };
153}
154
155function validateLedgerRow(row, options = {}) {
156 return validateRow(row, {
157 label: options.label || 'URL ledger row',
158 allowThrow: options.allowThrow,
159 required: [
160 'sourceSystem',
161 'sourceEntityType',
162 'sourceStableKey',
163 'targetSystem',
164 'targetEntityType',
165 'sourceRelativeUrl',
166 'urlStatus',
167 ],
168 validate: validateUrlStatus,
169 });
170}
171
172function validateRedirectRow(row, options = {}) {
173 return validateRow(row, {
174 label: options.label || 'redirect row',
175 allowThrow: options.allowThrow,
176 required: [
177 'sourceStableKey',
178 'sourceRelativeUrl',
179 'targetRelativeUrl',
180 'status',
181 ],
182 validate: (candidate, label, errors) => {
183 if (candidate.httpStatus !== 301) {
184 errors.push(`${label}.httpStatus must be 301`);
185 }
186 if (candidate.status && !REDIRECT_STATUS.has(candidate.status)) {
187 errors.push(`${label}.status must be one of: ${Array.from(REDIRECT_STATUS).join(', ')}`);
188 }
189 },
190 });
191}
192
193function validateUnresolvedRow(row, options = {}) {
194 return validateRow(row, {
195 label: options.label || 'unresolved row',
196 allowThrow: options.allowThrow,
197 required: [
198 'sourceSystem',
199 'sourceEntityType',
200 'sourceStableKey',
201 'targetSystem',
202 'targetEntityType',
203 'sourceRelativeUrl',
204 'urlStatus',
205 'status',
206 ],
207 validate: (candidate, label, errors) => {
208 validateUrlStatus(candidate, label, errors);
209 if (candidate.status && !UNRESOLVED_STATUS.has(candidate.status)) {
210 errors.push(`${label}.status must be one of: ${Array.from(UNRESOLVED_STATUS).join(', ')}`);
211 }
212 },
213 });
214}
215
216function validateBasePathEntry(row, options = {}) {
217 return validateRow(row, {
218 label: options.label || 'base path entry',
219 allowThrow: options.allowThrow,
220 required: [
221 'sourceSystem',
222 'sourceEntityType',
223 'targetSystem',
224 'targetEntityType',
225 'sourceBasePath',
226 'sourceUrlPattern',
227 'status',
228 ],
229 validate: (candidate, label, errors) => {
230 if (typeof candidate.public !== 'boolean') {
231 errors.push(`${label}.public must be a boolean`);
232 }
233 if (candidate.preserveBasePath !== undefined && typeof candidate.preserveBasePath !== 'boolean') {
234 errors.push(`${label}.preserveBasePath must be a boolean when present`);
235 }
236 if (candidate.preserveSlug !== undefined && typeof candidate.preserveSlug !== 'boolean') {
237 errors.push(`${label}.preserveSlug must be a boolean when present`);
238 }
239 if (candidate.status && !BASE_PATH_STATUS.has(candidate.status)) {
240 errors.push(`${label}.status must be one of: ${Array.from(BASE_PATH_STATUS).join(', ')}`);
241 }
242 },
243 });
244}
245
246function ledgerKey(row) {
247 return `${row.sourceStableKey}\t${row.sourceRelativeUrl}`;
248}
249
250function redirectKey(row) {
251 return `${row.sourceRelativeUrl}\t${row.targetRelativeUrl}\t${row.httpStatus}`;
252}
253
254function unresolvedKey(row) {
255 return `${row.sourceStableKey}\t${row.sourceRelativeUrl}\t${row.urlStatus}`;
256}
257
258function foldRows(rows, keyFor) {
259 const latestByKey = {};
260 for (const row of rows) {
261 latestByKey[keyFor(row)] = row;
262 }
263 return latestByKey;
264}
265
266async function appendUrlLedgerRow(projectDir, row) {
267 validateLedgerRow(row);
268 await appendNdjson(urlLedgerPath(projectDir), row);
269 return row;
270}
271
272async function appendRedirectRow(projectDir, row) {
273 validateRedirectRow(row);
274 await appendNdjson(redirectsPath(projectDir), row);
275 return row;
276}
277
278async function appendUnresolvedRow(projectDir, row) {
279 validateUnresolvedRow(row);
280 await appendNdjson(unresolvedPath(projectDir), row);
281 return row;
282}
283
284async function loadUrlLedger(projectDir) {
285 const rows = await readNdjson(urlLedgerPath(projectDir));
286 for (const row of rows) {
287 validateLedgerRow(row);
288 }
289 return { rows, byKey: foldRows(rows, ledgerKey) };
290}
291
292async function loadRedirects(projectDir) {
293 const rows = await readNdjson(redirectsPath(projectDir));
294 for (const row of rows) {
295 validateRedirectRow(row);
296 }
297 return { rows, byKey: foldRows(rows, redirectKey) };
298}
299
300async function loadUnresolved(projectDir) {
301 const rows = await readNdjson(unresolvedPath(projectDir));
302 for (const row of rows) {
303 validateUnresolvedRow(row);
304 }
305 return { rows, byKey: foldRows(rows, unresolvedKey) };
306}
307
308async function writeBasePaths(projectDir, entries) {
309 if (!Array.isArray(entries)) {
310 throw new Error('base path entries must be an array');
311 }
312 for (const entry of entries) {
313 validateBasePathEntry(entry);
314 }
315 await writeJsonAtomic(basePathsPath(projectDir), {
316 schemaVersion: SCHEMA_VERSION,
317 entries,
318 });
319 return entries;
320}
321
322async function loadBasePaths(projectDir) {
323 let raw;
324 try {
325 raw = await fs.readFile(basePathsPath(projectDir), 'utf8');
326 } catch (error) {
327 if (error && error.code === 'ENOENT') {
328 return { schemaVersion: SCHEMA_VERSION, entries: [] };
329 }
330 throw error;
331 }
332 const parsed = JSON.parse(raw);
333 const entries = Array.isArray(parsed) ? parsed : parsed.entries;
334 if (!Array.isArray(entries)) {
335 throw new Error('base-paths.json entries must be an array');
336 }
337 for (const entry of entries) {
338 validateBasePathEntry(entry);
339 }
340 return { schemaVersion: parsed.schemaVersion || SCHEMA_VERSION, entries };
341}
342
343async function initUrlPreservationState(projectDir) {
344 await mkdirp(urlPreservationDir(projectDir));
345 const actions = [];
346 if (!(await pathExists(basePathsPath(projectDir)))) {
347 await writeJsonAtomic(basePathsPath(projectDir), {
348 schemaVersion: SCHEMA_VERSION,
349 entries: [],
350 });
351 actions.push({ action: 'initialized_url_base_paths', path: 'state/url-preservation/base-paths.json' });
352 }
353 for (const [filePath, action, relativePath] of [
354 [urlLedgerPath(projectDir), 'initialized_url_ledger', 'state/url-preservation/url-ledger.ndjson'],
355 [redirectsPath(projectDir), 'initialized_url_redirects', 'state/url-preservation/redirects.ndjson'],
356 [unresolvedPath(projectDir), 'initialized_url_unresolved', 'state/url-preservation/unresolved.ndjson'],
357 ]) {
358 const created = await ensureEmptyNdjson(filePath);
359 actions.push({ action: created ? action : action.replace('initialized', 'kept'), path: relativePath });
360 }
361 await loadBasePaths(projectDir);
362 await loadUrlLedger(projectDir);
363 await loadRedirects(projectDir);
364 await loadUnresolved(projectDir);
365 return actions;
366}
367
368module.exports = {
369 SCHEMA_VERSION,
370 urlPreservationDir,
371 basePathsPath,
372 urlLedgerPath,
373 redirectsPath,
374 unresolvedPath,
375 ledgerKey,
376 redirectKey,
377 unresolvedKey,
378 validateBasePathEntry,
379 validateLedgerRow,
380 validateRedirectRow,
381 validateUnresolvedRow,
382 writeBasePaths,
383 loadBasePaths,
384 appendUrlLedgerRow,
385 appendRedirectRow,
386 appendUnresolvedRow,
387 loadUrlLedger,
388 loadRedirects,
389 loadUnresolved,
390 initUrlPreservationState,
391};