@riebeckite/node 0.0.35 → 0.0.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/README_ja.md +25 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2714 -6
- package/dist/src/content_source_factory.d.ts +7 -0
- package/dist/src/file_system_content_migration.d.ts +48 -0
- package/dist/src/file_system_content_migration_helpers.d.ts +53 -0
- package/dist/src/file_system_content_migration_types.d.ts +99 -0
- package/dist/src/file_system_content_mutation.d.ts +25 -0
- package/dist/src/migration_state_store.d.ts +9 -0
- package/dist/src/mutation_journal.d.ts +33 -0
- package/dist/src/path_helpers.d.ts +2 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -117,8 +117,8 @@ var FileSystemContentSource = class {
|
|
|
117
117
|
return entries;
|
|
118
118
|
}
|
|
119
119
|
resolveFilePath(logicalPath) {
|
|
120
|
-
const
|
|
121
|
-
const filePath = path.resolve(this.contentDirectory,
|
|
120
|
+
const normalizedPath2 = normalizeLogicalPath(logicalPath);
|
|
121
|
+
const filePath = path.resolve(this.contentDirectory, normalizedPath2);
|
|
122
122
|
const relativePath = path.relative(this.contentDirectory, filePath);
|
|
123
123
|
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
124
124
|
throw new Error(`Invalid content path: ${logicalPath}`);
|
|
@@ -171,11 +171,11 @@ function normalizeTemplaterTemplateFolder(value) {
|
|
|
171
171
|
return normalized;
|
|
172
172
|
}
|
|
173
173
|
function normalizeLogicalPath(logicalPath) {
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
174
|
+
const normalizedPath2 = normalizeContentPath(logicalPath);
|
|
175
|
+
if (normalizedPath2.length === 0 || normalizedPath2.startsWith("/") || path.isAbsolute(normalizedPath2) || path.win32.isAbsolute(normalizedPath2) || normalizedPath2.split("/").some((segment) => segment === "..")) {
|
|
176
176
|
throw new Error(`Invalid content path: ${logicalPath}`);
|
|
177
177
|
}
|
|
178
|
-
return
|
|
178
|
+
return normalizedPath2;
|
|
179
179
|
}
|
|
180
180
|
function isMissingDirectoryError(error, directory) {
|
|
181
181
|
return error instanceof Error && "code" in error && error.code === "ENOENT" && "path" in error && error.path === directory;
|
|
@@ -202,9 +202,2717 @@ function missingContentDirectoryError(directory) {
|
|
|
202
202
|
error.hint = "Check `content.directory` in your Riebeckite configuration.";
|
|
203
203
|
return error;
|
|
204
204
|
}
|
|
205
|
+
|
|
206
|
+
// src/content_source_factory.ts
|
|
207
|
+
function createContentSource(config, directory = config.directory) {
|
|
208
|
+
return config.source ?? new FileSystemContentSource(directory, config.exclude ?? []);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/file_system_content_migration.ts
|
|
212
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
213
|
+
import fs5 from "node:fs/promises";
|
|
214
|
+
import path5 from "node:path";
|
|
215
|
+
import {
|
|
216
|
+
appendContentMigrationHistory,
|
|
217
|
+
ContentManager,
|
|
218
|
+
findContentMigrationRecord,
|
|
219
|
+
listContentMigrationEntries,
|
|
220
|
+
planContentMigrationBatch,
|
|
221
|
+
publicationEntriesFromManifest,
|
|
222
|
+
publicationFingerprint,
|
|
223
|
+
publicationOutputsFromManifest,
|
|
224
|
+
publicationRedirectsFromManifest,
|
|
225
|
+
sanitizeContentMigrationPath,
|
|
226
|
+
upsertContentMigrationRecord
|
|
227
|
+
} from "@riebeckite/core";
|
|
228
|
+
|
|
229
|
+
// src/file_system_content_migration_helpers.ts
|
|
230
|
+
import { createHash } from "node:crypto";
|
|
231
|
+
import fs2 from "node:fs/promises";
|
|
232
|
+
import path2 from "node:path";
|
|
233
|
+
function redirectFieldFromConfig(config) {
|
|
234
|
+
const options = config?.plugins.find((plugin) => plugin.name === "permalink")?.options;
|
|
235
|
+
const field = options?.redirects?.frontmatter;
|
|
236
|
+
if (field === void 0) return "redirect_from";
|
|
237
|
+
if (typeof field !== "string" || field.trim() === "")
|
|
238
|
+
throw new Error(
|
|
239
|
+
"The permalink redirects.frontmatter option must be a non-empty string."
|
|
240
|
+
);
|
|
241
|
+
return field;
|
|
242
|
+
}
|
|
243
|
+
function fingerprintFiles(files) {
|
|
244
|
+
const hash = createHash("sha256");
|
|
245
|
+
for (const file of [...files].sort(
|
|
246
|
+
(left, right) => left.path.localeCompare(right.path)
|
|
247
|
+
)) {
|
|
248
|
+
hash.update(file.path);
|
|
249
|
+
hash.update("\0");
|
|
250
|
+
hash.update(createHash("sha256").update(file.content).digest("hex"));
|
|
251
|
+
hash.update("\n");
|
|
252
|
+
}
|
|
253
|
+
return hash.digest("hex");
|
|
254
|
+
}
|
|
255
|
+
function fingerprintMutationPlan(operations, changes) {
|
|
256
|
+
return createHash("sha256").update(
|
|
257
|
+
canonicalJson({
|
|
258
|
+
operations,
|
|
259
|
+
changes: changes.map((change) => ({
|
|
260
|
+
path: change.path,
|
|
261
|
+
beforeFingerprint: change.beforeFingerprint,
|
|
262
|
+
afterFingerprint: change.afterFingerprint
|
|
263
|
+
}))
|
|
264
|
+
})
|
|
265
|
+
).digest("hex");
|
|
266
|
+
}
|
|
267
|
+
function fingerprintPlan(input) {
|
|
268
|
+
return createHash("sha256").update(
|
|
269
|
+
canonicalJson({
|
|
270
|
+
migrations: input.migrations.map((migration) => ({
|
|
271
|
+
id: migration.id,
|
|
272
|
+
version: migration.version,
|
|
273
|
+
fingerprint: migration.fingerprint
|
|
274
|
+
})),
|
|
275
|
+
baseContentFingerprint: input.baseContentFingerprint,
|
|
276
|
+
mutationPlanFingerprint: input.mutationPlanFingerprint,
|
|
277
|
+
operations: input.operations,
|
|
278
|
+
diagnostics: input.diagnostics.map((diagnostic) => ({
|
|
279
|
+
severity: diagnostic.severity,
|
|
280
|
+
code: diagnostic.code,
|
|
281
|
+
path: diagnostic.path
|
|
282
|
+
})),
|
|
283
|
+
conflicts: input.conflicts.map((conflict) => ({
|
|
284
|
+
code: conflict.code,
|
|
285
|
+
path: conflict.path
|
|
286
|
+
}))
|
|
287
|
+
})
|
|
288
|
+
).digest("hex");
|
|
289
|
+
}
|
|
290
|
+
function canonicalJson(value) {
|
|
291
|
+
return JSON.stringify(sortValue(value));
|
|
292
|
+
}
|
|
293
|
+
function sortValue(value) {
|
|
294
|
+
if (Array.isArray(value)) return value.map(sortValue);
|
|
295
|
+
if (isRecord(value)) {
|
|
296
|
+
const sorted = {};
|
|
297
|
+
for (const key of Object.keys(value).sort())
|
|
298
|
+
sorted[key] = sortValue(value[key]);
|
|
299
|
+
return sorted;
|
|
300
|
+
}
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
function findEntry(manifest, contentPath) {
|
|
304
|
+
const base = contentPath.replace(/\.md$/i, "");
|
|
305
|
+
return manifest.bySlug.get(base) ?? manifest.bySlug.get(`${base}/index`) ?? manifest.bySlug.get(base.replace(/\/index$/i, ""));
|
|
306
|
+
}
|
|
307
|
+
function changedFrontmatterKeys(file) {
|
|
308
|
+
const keys = /* @__PURE__ */ new Set();
|
|
309
|
+
for (const key of /* @__PURE__ */ new Set([
|
|
310
|
+
...Object.keys(file.frontmatter),
|
|
311
|
+
...Object.keys(file.originalFrontmatter)
|
|
312
|
+
])) {
|
|
313
|
+
if (canonicalJson(file.frontmatter[key]) !== canonicalJson(file.originalFrontmatter[key]))
|
|
314
|
+
keys.add(key);
|
|
315
|
+
}
|
|
316
|
+
return keys;
|
|
317
|
+
}
|
|
318
|
+
function diagnosticKey(diagnostic) {
|
|
319
|
+
return `${diagnostic.code}\0${diagnostic.filePath ?? ""}\0${diagnostic.target ?? ""}`;
|
|
320
|
+
}
|
|
321
|
+
function mutationIssue(issue, severity) {
|
|
322
|
+
return {
|
|
323
|
+
severity,
|
|
324
|
+
code: issue.code,
|
|
325
|
+
message: issue.message,
|
|
326
|
+
path: issue.path
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function isRecord(value) {
|
|
330
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
331
|
+
}
|
|
332
|
+
function sameMigrationPlan(left, right) {
|
|
333
|
+
if (left.baseContentFingerprint !== right.baseContentFingerprint || left.mutationPlanFingerprint !== right.mutationPlanFingerprint || left.planFingerprint !== right.planFingerprint || canonicalJson(left.operations) !== canonicalJson(right.operations) || canonicalJson(left.changes) !== canonicalJson(right.changes) || left.migrations.length !== right.migrations.length)
|
|
334
|
+
return false;
|
|
335
|
+
return left.migrations.every(
|
|
336
|
+
(migration, index) => migration.id === right.migrations[index]?.id && migration.version === right.migrations[index]?.version && migration.fingerprint === right.migrations[index]?.fingerprint
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
function isAppliedRecord(record, fingerprint2) {
|
|
340
|
+
return record?.status === "applied" && record.definitionFingerprint === fingerprint2;
|
|
341
|
+
}
|
|
342
|
+
async function readJournalSnapshot(journalPath) {
|
|
343
|
+
try {
|
|
344
|
+
const value = JSON.parse(await fs2.readFile(journalPath, "utf8"));
|
|
345
|
+
if (!isRecord(value) || typeof value.state !== "string" || !Array.isArray(value.changes))
|
|
346
|
+
return void 0;
|
|
347
|
+
const changes = [];
|
|
348
|
+
for (const change of value.changes) {
|
|
349
|
+
if (!isRecord(change) || typeof change.path !== "string" || !(change.afterFingerprint === null || typeof change.afterFingerprint === "string"))
|
|
350
|
+
return void 0;
|
|
351
|
+
changes.push({
|
|
352
|
+
path: change.path,
|
|
353
|
+
afterFingerprint: change.afterFingerprint
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
return { state: value.state, changes };
|
|
357
|
+
} catch {
|
|
358
|
+
return void 0;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function journalMatchesApplied(contentDirectory, journal) {
|
|
362
|
+
for (const change of journal.changes) {
|
|
363
|
+
const target = path2.resolve(contentDirectory, change.path);
|
|
364
|
+
if (!isWithin(contentDirectory, target)) return false;
|
|
365
|
+
let actual = null;
|
|
366
|
+
try {
|
|
367
|
+
actual = createHash("sha256").update(await fs2.readFile(target)).digest("hex");
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (error.code !== "ENOENT") return false;
|
|
370
|
+
}
|
|
371
|
+
if (actual !== change.afterFingerprint) return false;
|
|
372
|
+
}
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
function isWithin(root, target) {
|
|
376
|
+
const relative = path2.relative(path2.resolve(root), path2.resolve(target));
|
|
377
|
+
return relative === "" || !relative.startsWith("..") && !path2.isAbsolute(relative);
|
|
378
|
+
}
|
|
379
|
+
async function exists(file) {
|
|
380
|
+
try {
|
|
381
|
+
await fs2.access(file);
|
|
382
|
+
return true;
|
|
383
|
+
} catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async function staleLockContent(lockPath) {
|
|
388
|
+
let content;
|
|
389
|
+
try {
|
|
390
|
+
content = await fs2.readFile(lockPath, "utf8");
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
let value;
|
|
395
|
+
try {
|
|
396
|
+
value = JSON.parse(content);
|
|
397
|
+
} catch {
|
|
398
|
+
return await lockFileIsOld(lockPath) ? content : null;
|
|
399
|
+
}
|
|
400
|
+
const pid = isRecord(value) ? value.pid : void 0;
|
|
401
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
|
|
402
|
+
return await lockFileIsOld(lockPath) ? content : null;
|
|
403
|
+
try {
|
|
404
|
+
process.kill(pid, 0);
|
|
405
|
+
return null;
|
|
406
|
+
} catch (error) {
|
|
407
|
+
return error.code === "ESRCH" ? content : null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async function lockFileIsOld(lockPath) {
|
|
411
|
+
try {
|
|
412
|
+
return Date.now() - (await fs2.stat(lockPath)).mtimeMs > 5e3;
|
|
413
|
+
} catch {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function errorMessage(error) {
|
|
418
|
+
return error instanceof Error ? error.message : String(error);
|
|
419
|
+
}
|
|
420
|
+
function sanitizeErrorMessage(error, contentDirectory) {
|
|
421
|
+
return errorMessage(error).split(contentDirectory).join("<content>").replace(/[A-Za-z]:[\\/][^\s'"]+/gu, "<path>");
|
|
422
|
+
}
|
|
423
|
+
function errorPath(error) {
|
|
424
|
+
if (!isRecord(error) || typeof error.path !== "string") return void 0;
|
|
425
|
+
const value = error.path.replace(/\\/gu, "/");
|
|
426
|
+
if (value.startsWith("/") || /^[A-Za-z]:\//u.test(value)) return void 0;
|
|
427
|
+
return value;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// src/file_system_content_mutation.ts
|
|
431
|
+
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
432
|
+
import fs3 from "node:fs/promises";
|
|
433
|
+
import path3 from "node:path";
|
|
434
|
+
import {
|
|
435
|
+
ContentIndexBuilder,
|
|
436
|
+
extractFrontmatterAliases,
|
|
437
|
+
flattenContentMutationOperations,
|
|
438
|
+
planContentLinkUpdates
|
|
439
|
+
} from "@riebeckite/core";
|
|
440
|
+
import { parseDocument } from "yaml";
|
|
441
|
+
|
|
442
|
+
// src/mutation_journal.ts
|
|
443
|
+
function markApplied(journal, filePath) {
|
|
444
|
+
const change = journal.changes.find(
|
|
445
|
+
(candidate) => candidate.path === filePath
|
|
446
|
+
);
|
|
447
|
+
if (!change) throw new Error(`Journal change is missing: ${filePath}`);
|
|
448
|
+
change.status = "applied";
|
|
449
|
+
}
|
|
450
|
+
function parseJournal(value) {
|
|
451
|
+
const journal = JSON.parse(value);
|
|
452
|
+
if (journal.version !== 1 || typeof journal.id !== "string" || !Array.isArray(journal.changes) || ![
|
|
453
|
+
"prepared",
|
|
454
|
+
"applying",
|
|
455
|
+
"failed",
|
|
456
|
+
"complete",
|
|
457
|
+
"recovered",
|
|
458
|
+
"rolled-back"
|
|
459
|
+
].includes(journal.state ?? ""))
|
|
460
|
+
throw new Error("Journal is missing or corrupt.");
|
|
461
|
+
return journal;
|
|
462
|
+
}
|
|
463
|
+
function isRecord2(value) {
|
|
464
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
465
|
+
}
|
|
466
|
+
function serializePlan(plan) {
|
|
467
|
+
return {
|
|
468
|
+
...plan,
|
|
469
|
+
operations: serializeOperations(plan.operations),
|
|
470
|
+
changes: plan.changes.map((change) => ({
|
|
471
|
+
...change,
|
|
472
|
+
content: change.content === null ? null : Buffer.from(change.content).toString("base64")
|
|
473
|
+
}))
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function parsePlan(value) {
|
|
477
|
+
let parsed;
|
|
478
|
+
try {
|
|
479
|
+
parsed = JSON.parse(value);
|
|
480
|
+
} catch {
|
|
481
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
482
|
+
}
|
|
483
|
+
if (!isRecord2(parsed) || parsed.schemaVersion !== 1 || typeof parsed.id !== "string")
|
|
484
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
485
|
+
if (!Array.isArray(parsed.operations) || !Array.isArray(parsed.changes) || !Array.isArray(parsed.preconditions) || !Array.isArray(parsed.warnings) || !Array.isArray(parsed.conflicts))
|
|
486
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
487
|
+
const changes = parsed.changes.map((change) => {
|
|
488
|
+
const serializedContent = isRecord2(change) ? change.content : void 0;
|
|
489
|
+
if (!isRecord2(change) || typeof change.path !== "string" || !isFingerprint(change.beforeFingerprint) || !isFingerprint(change.afterFingerprint))
|
|
490
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
491
|
+
if (serializedContent !== null && typeof serializedContent !== "string")
|
|
492
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
493
|
+
const content = serializedContent === null ? null : new Uint8Array(Buffer.from(serializedContent, "base64"));
|
|
494
|
+
if (content === null !== (change.afterFingerprint === null))
|
|
495
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
496
|
+
return {
|
|
497
|
+
path: change.path,
|
|
498
|
+
beforeFingerprint: change.beforeFingerprint,
|
|
499
|
+
afterFingerprint: change.afterFingerprint,
|
|
500
|
+
content
|
|
501
|
+
};
|
|
502
|
+
});
|
|
503
|
+
return {
|
|
504
|
+
schemaVersion: 1,
|
|
505
|
+
id: parsed.id,
|
|
506
|
+
operations: parseOperations(parsed.operations),
|
|
507
|
+
changes,
|
|
508
|
+
preconditions: parsed.preconditions,
|
|
509
|
+
warnings: parsed.warnings,
|
|
510
|
+
conflicts: parsed.conflicts
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function samePlan(expected, actual) {
|
|
514
|
+
return canonicalPlan(expected) === canonicalPlan(actual) && expected.conflicts.length === actual.conflicts.length;
|
|
515
|
+
}
|
|
516
|
+
function canonicalPlan(plan) {
|
|
517
|
+
return JSON.stringify({
|
|
518
|
+
operations: serializeOperations(plan.operations),
|
|
519
|
+
changes: serializePlan(plan).changes,
|
|
520
|
+
preconditions: plan.preconditions,
|
|
521
|
+
conflicts: plan.conflicts
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
function serializeOperations(operations) {
|
|
525
|
+
return operations.map((operation) => {
|
|
526
|
+
if (operation.kind === "batch")
|
|
527
|
+
return {
|
|
528
|
+
...operation,
|
|
529
|
+
operations: serializeOperations(operation.operations)
|
|
530
|
+
};
|
|
531
|
+
if ("content" in operation)
|
|
532
|
+
return {
|
|
533
|
+
...operation,
|
|
534
|
+
content: typeof operation.content === "string" ? { encoding: "utf8", value: operation.content } : {
|
|
535
|
+
encoding: "base64",
|
|
536
|
+
value: Buffer.from(operation.content).toString("base64")
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
return operation;
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
function parseOperations(value) {
|
|
543
|
+
if (!Array.isArray(value))
|
|
544
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
545
|
+
return value.map((operation) => {
|
|
546
|
+
if (!isRecord2(operation) || typeof operation.kind !== "string")
|
|
547
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
548
|
+
if (operation.kind === "batch")
|
|
549
|
+
return {
|
|
550
|
+
kind: "batch",
|
|
551
|
+
operations: parseOperations(operation.operations)
|
|
552
|
+
};
|
|
553
|
+
if (operation.kind === "create" || operation.kind === "asset-create" || operation.kind === "update" || operation.kind === "asset-update") {
|
|
554
|
+
if (typeof operation.path !== "string" || !isRecord2(operation.content) || typeof operation.content.value !== "string")
|
|
555
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
556
|
+
if (operation.content.encoding === "utf8")
|
|
557
|
+
return {
|
|
558
|
+
kind: operation.kind,
|
|
559
|
+
path: operation.path,
|
|
560
|
+
content: operation.content.value
|
|
561
|
+
};
|
|
562
|
+
if (operation.content.encoding === "base64")
|
|
563
|
+
return {
|
|
564
|
+
kind: operation.kind,
|
|
565
|
+
path: operation.path,
|
|
566
|
+
content: new Uint8Array(
|
|
567
|
+
Buffer.from(operation.content.value, "base64")
|
|
568
|
+
)
|
|
569
|
+
};
|
|
570
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
571
|
+
}
|
|
572
|
+
if (operation.kind === "delete" || operation.kind === "asset-delete") {
|
|
573
|
+
if (typeof operation.path !== "string")
|
|
574
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
575
|
+
return { kind: operation.kind, path: operation.path };
|
|
576
|
+
}
|
|
577
|
+
if (operation.kind === "move" || operation.kind === "rename" || operation.kind === "asset-move") {
|
|
578
|
+
if (typeof operation.from !== "string" || typeof operation.to !== "string")
|
|
579
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
580
|
+
return { kind: operation.kind, from: operation.from, to: operation.to };
|
|
581
|
+
}
|
|
582
|
+
if (operation.kind === "frontmatter") {
|
|
583
|
+
if (typeof operation.path !== "string")
|
|
584
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
585
|
+
return {
|
|
586
|
+
kind: "frontmatter",
|
|
587
|
+
path: operation.path,
|
|
588
|
+
...isRecord2(operation.set) ? { set: operation.set } : {},
|
|
589
|
+
...Array.isArray(operation.remove) && operation.remove.every((item) => typeof item === "string") ? { remove: operation.remove } : {}
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
throw new Error("Mutation plan is missing or corrupt.");
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
function isFingerprint(value) {
|
|
596
|
+
return value === null || typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// src/file_system_content_mutation.ts
|
|
600
|
+
var FileSystemContentMutation = class {
|
|
601
|
+
constructor(contentDirectory, options = {}) {
|
|
602
|
+
this.contentDirectory = contentDirectory;
|
|
603
|
+
this.options = options;
|
|
604
|
+
}
|
|
605
|
+
contentDirectory;
|
|
606
|
+
options;
|
|
607
|
+
async plan(operations) {
|
|
608
|
+
const root = await this.root();
|
|
609
|
+
const conflicts = [];
|
|
610
|
+
const warnings = [];
|
|
611
|
+
const files = /* @__PURE__ */ new Map();
|
|
612
|
+
await this.loadFiles(root, "", files);
|
|
613
|
+
const initial = new Map(files);
|
|
614
|
+
const touched = /* @__PURE__ */ new Set();
|
|
615
|
+
const retargets = [];
|
|
616
|
+
for (const operation of flattenContentMutationOperations(operations)) {
|
|
617
|
+
try {
|
|
618
|
+
if (operation.kind === "create" || operation.kind === "asset-create") {
|
|
619
|
+
const target2 = validateLogicalPath(operation.path);
|
|
620
|
+
touched.add(target2);
|
|
621
|
+
if (files.has(target2))
|
|
622
|
+
throw new Error("A file already exists at this path.");
|
|
623
|
+
files.set(target2, stateOf(operation.content));
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (operation.kind === "update" || operation.kind === "asset-update") {
|
|
627
|
+
const target2 = validateLogicalPath(operation.path);
|
|
628
|
+
touched.add(target2);
|
|
629
|
+
if (!files.has(target2)) throw new Error("The file does not exist.");
|
|
630
|
+
files.set(target2, stateOf(operation.content));
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (operation.kind === "delete" || operation.kind === "asset-delete") {
|
|
634
|
+
const target2 = validateLogicalPath(operation.path);
|
|
635
|
+
touched.add(target2);
|
|
636
|
+
if (!files.delete(target2))
|
|
637
|
+
throw new Error("The file does not exist.");
|
|
638
|
+
retargets.push({ from: target2 });
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (operation.kind === "move" || operation.kind === "rename" || operation.kind === "asset-move") {
|
|
642
|
+
const from = validateLogicalPath(operation.from);
|
|
643
|
+
const to = validateLogicalPath(operation.to);
|
|
644
|
+
touched.add(from);
|
|
645
|
+
touched.add(to);
|
|
646
|
+
const source2 = files.get(from);
|
|
647
|
+
if (!source2) throw new Error("The source file does not exist.");
|
|
648
|
+
if (files.has(to))
|
|
649
|
+
throw new Error("The destination file already exists.");
|
|
650
|
+
files.delete(from);
|
|
651
|
+
files.set(to, source2);
|
|
652
|
+
retargets.push({ from, to });
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (operation.kind !== "frontmatter")
|
|
656
|
+
throw new Error("Unsupported content mutation operation.");
|
|
657
|
+
const target = validateLogicalPath(operation.path);
|
|
658
|
+
touched.add(target);
|
|
659
|
+
const source = files.get(target);
|
|
660
|
+
if (!source) throw new Error("The file does not exist.");
|
|
661
|
+
if (!target.toLowerCase().endsWith(".md"))
|
|
662
|
+
throw new Error("Frontmatter can only be updated in Markdown files.");
|
|
663
|
+
files.set(
|
|
664
|
+
target,
|
|
665
|
+
updateFrontmatter(source, operation.set, operation.remove)
|
|
666
|
+
);
|
|
667
|
+
warnings.push({
|
|
668
|
+
severity: "warning",
|
|
669
|
+
code: "frontmatter-formatting",
|
|
670
|
+
path: target,
|
|
671
|
+
message: "The frontmatter document was serialized; comments and key order are retained where YAML permits."
|
|
672
|
+
});
|
|
673
|
+
} catch (error) {
|
|
674
|
+
conflicts.push({
|
|
675
|
+
severity: "error",
|
|
676
|
+
code: "invalid-operation",
|
|
677
|
+
path: operationPath(operation),
|
|
678
|
+
message: error instanceof Error ? error.message : String(error)
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
for (const filePath of touched) {
|
|
683
|
+
try {
|
|
684
|
+
await assertSafeExistingParents(root, filePath);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
conflicts.push({
|
|
687
|
+
severity: "error",
|
|
688
|
+
code: "unsafe-path",
|
|
689
|
+
path: filePath,
|
|
690
|
+
message: error instanceof Error ? error.message : String(error)
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
addCaseConflicts(files, conflicts);
|
|
695
|
+
const beforeBuild = buildContentIndex(initial);
|
|
696
|
+
const afterBuild = buildContentIndex(files);
|
|
697
|
+
const beforeIndex = beforeBuild.index;
|
|
698
|
+
const afterIndex = afterBuild.index;
|
|
699
|
+
if (retargets.length > 0 || !sameContentIndex(beforeIndex, afterIndex)) {
|
|
700
|
+
const markdownFiles = /* @__PURE__ */ new Map();
|
|
701
|
+
for (const [filePath, state] of files) {
|
|
702
|
+
if (!filePath.toLowerCase().endsWith(".md")) continue;
|
|
703
|
+
try {
|
|
704
|
+
markdownFiles.set(
|
|
705
|
+
filePath,
|
|
706
|
+
new TextDecoder("utf-8", {
|
|
707
|
+
fatal: true,
|
|
708
|
+
ignoreBOM: true
|
|
709
|
+
}).decode(state.content)
|
|
710
|
+
);
|
|
711
|
+
} catch {
|
|
712
|
+
conflicts.push({
|
|
713
|
+
severity: "error",
|
|
714
|
+
code: "invalid-markdown-encoding",
|
|
715
|
+
path: filePath,
|
|
716
|
+
message: "The Markdown file is not valid UTF-8."
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const linkPlan = planContentLinkUpdates({
|
|
721
|
+
files: markdownFiles,
|
|
722
|
+
retargets: foldRetargets(retargets, files),
|
|
723
|
+
beforeIndex,
|
|
724
|
+
afterIndex,
|
|
725
|
+
beforeAmbiguities: beforeBuild.ambiguities,
|
|
726
|
+
afterAmbiguities: afterBuild.ambiguities
|
|
727
|
+
});
|
|
728
|
+
for (const [filePath, content] of linkPlan.updates) {
|
|
729
|
+
const existing = files.get(filePath);
|
|
730
|
+
if (!existing) continue;
|
|
731
|
+
files.set(filePath, stateOf(content));
|
|
732
|
+
}
|
|
733
|
+
warnings.push(...linkPlan.warnings);
|
|
734
|
+
conflicts.push(...linkPlan.conflicts);
|
|
735
|
+
}
|
|
736
|
+
const paths = /* @__PURE__ */ new Set([...initial.keys(), ...files.keys()]);
|
|
737
|
+
const changes = [...paths].filter(
|
|
738
|
+
(filePath) => initial.get(filePath)?.fingerprint !== files.get(filePath)?.fingerprint
|
|
739
|
+
).map((filePath) => ({
|
|
740
|
+
path: filePath,
|
|
741
|
+
beforeFingerprint: initial.get(filePath)?.fingerprint ?? null,
|
|
742
|
+
afterFingerprint: files.get(filePath)?.fingerprint ?? null,
|
|
743
|
+
content: files.get(filePath)?.content ?? null
|
|
744
|
+
})).toSorted((left, right) => left.path.localeCompare(right.path));
|
|
745
|
+
return {
|
|
746
|
+
schemaVersion: 1,
|
|
747
|
+
id: randomUUID(),
|
|
748
|
+
operations,
|
|
749
|
+
changes,
|
|
750
|
+
preconditions: changes.map((change) => ({
|
|
751
|
+
path: change.path,
|
|
752
|
+
fingerprint: change.beforeFingerprint
|
|
753
|
+
})),
|
|
754
|
+
warnings,
|
|
755
|
+
conflicts
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
async apply(plan) {
|
|
759
|
+
const root = await this.root();
|
|
760
|
+
const preconditionConflicts = await this.verifyPreconditions(
|
|
761
|
+
root,
|
|
762
|
+
plan.changes
|
|
763
|
+
);
|
|
764
|
+
if (preconditionConflicts.length > 0)
|
|
765
|
+
return {
|
|
766
|
+
planId: plan.id,
|
|
767
|
+
applied: false,
|
|
768
|
+
appliedPaths: [],
|
|
769
|
+
conflicts: preconditionConflicts
|
|
770
|
+
};
|
|
771
|
+
const revalidated = await this.revalidatePlan(plan);
|
|
772
|
+
if (revalidated.length > 0)
|
|
773
|
+
return {
|
|
774
|
+
planId: plan.id,
|
|
775
|
+
applied: false,
|
|
776
|
+
appliedPaths: [],
|
|
777
|
+
conflicts: revalidated
|
|
778
|
+
};
|
|
779
|
+
if (plan.conflicts.length > 0)
|
|
780
|
+
return {
|
|
781
|
+
planId: plan.id,
|
|
782
|
+
applied: false,
|
|
783
|
+
appliedPaths: [],
|
|
784
|
+
conflicts: plan.conflicts
|
|
785
|
+
};
|
|
786
|
+
const release = await this.acquireLock(root);
|
|
787
|
+
try {
|
|
788
|
+
const conflicts = await this.verifyPreconditions(root, plan.changes);
|
|
789
|
+
if (conflicts.length > 0)
|
|
790
|
+
return { planId: plan.id, applied: false, appliedPaths: [], conflicts };
|
|
791
|
+
const journalDirectory = path3.join(
|
|
792
|
+
root,
|
|
793
|
+
".riebeckite",
|
|
794
|
+
"mutations",
|
|
795
|
+
plan.id
|
|
796
|
+
);
|
|
797
|
+
await fs3.mkdir(path3.join(journalDirectory, "backups"), {
|
|
798
|
+
recursive: true
|
|
799
|
+
});
|
|
800
|
+
const journalPath = path3.join(journalDirectory, "journal.json");
|
|
801
|
+
const journal = {
|
|
802
|
+
version: 1,
|
|
803
|
+
id: plan.id,
|
|
804
|
+
state: "prepared",
|
|
805
|
+
changes: await Promise.all(
|
|
806
|
+
plan.changes.map(
|
|
807
|
+
(change) => this.backup(root, journalDirectory, change)
|
|
808
|
+
)
|
|
809
|
+
)
|
|
810
|
+
};
|
|
811
|
+
await writeJsonAtomically(journalPath, journal);
|
|
812
|
+
journal.state = "applying";
|
|
813
|
+
await writeJsonAtomically(journalPath, journal);
|
|
814
|
+
const appliedPaths = [];
|
|
815
|
+
try {
|
|
816
|
+
for (const change of plan.changes.filter(
|
|
817
|
+
(change2) => change2.content !== null
|
|
818
|
+
)) {
|
|
819
|
+
await this.options.beforeWrite?.(change.path);
|
|
820
|
+
if (change.content === null)
|
|
821
|
+
throw new Error("Mutation content is missing.");
|
|
822
|
+
await writeFileAtomically(
|
|
823
|
+
await safeFilePath(root, change.path, true),
|
|
824
|
+
change.content,
|
|
825
|
+
change.beforeFingerprint
|
|
826
|
+
);
|
|
827
|
+
markApplied(journal, change.path);
|
|
828
|
+
await writeJsonAtomically(journalPath, journal);
|
|
829
|
+
appliedPaths.push(change.path);
|
|
830
|
+
}
|
|
831
|
+
for (const change of plan.changes.filter(
|
|
832
|
+
(change2) => change2.content === null
|
|
833
|
+
)) {
|
|
834
|
+
await this.options.beforeWrite?.(change.path);
|
|
835
|
+
const target = await safeFilePath(root, change.path, false);
|
|
836
|
+
if (await fingerprintAt(target) !== change.beforeFingerprint)
|
|
837
|
+
throw new Error(`Content changed during apply: ${change.path}`);
|
|
838
|
+
await fs3.rm(target);
|
|
839
|
+
markApplied(journal, change.path);
|
|
840
|
+
await writeJsonAtomically(journalPath, journal);
|
|
841
|
+
appliedPaths.push(change.path);
|
|
842
|
+
}
|
|
843
|
+
} catch (error) {
|
|
844
|
+
journal.state = "failed";
|
|
845
|
+
await writeJsonAtomically(journalPath, journal);
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
journal.state = "complete";
|
|
849
|
+
await writeJsonAtomically(journalPath, journal);
|
|
850
|
+
return {
|
|
851
|
+
planId: plan.id,
|
|
852
|
+
applied: true,
|
|
853
|
+
journalPath,
|
|
854
|
+
appliedPaths,
|
|
855
|
+
conflicts: []
|
|
856
|
+
};
|
|
857
|
+
} finally {
|
|
858
|
+
await release();
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
async recover(journalPath) {
|
|
862
|
+
const root = await this.root();
|
|
863
|
+
const absoluteJournal = path3.resolve(journalPath);
|
|
864
|
+
const mutationRoot = path3.join(root, ".riebeckite", "mutations");
|
|
865
|
+
if (!isWithin2(mutationRoot, absoluteJournal))
|
|
866
|
+
throw new Error("Journal path is outside the mutation directory.");
|
|
867
|
+
const journalDirectory = path3.dirname(absoluteJournal);
|
|
868
|
+
const release = await this.acquireLock(root);
|
|
869
|
+
try {
|
|
870
|
+
const journal = parseJournal(await fs3.readFile(absoluteJournal, "utf8"));
|
|
871
|
+
if (path3.basename(absoluteJournal) !== "journal.json" || path3.basename(journalDirectory) !== journal.id || path3.basename(journalDirectory) === "plans")
|
|
872
|
+
throw new Error("Journal path does not belong to its mutation.");
|
|
873
|
+
if (journal.state === "complete" || journal.state === "recovered")
|
|
874
|
+
return {
|
|
875
|
+
journalPath: absoluteJournal,
|
|
876
|
+
recovered: false,
|
|
877
|
+
restoredPaths: [],
|
|
878
|
+
conflicts: []
|
|
879
|
+
};
|
|
880
|
+
const conflicts = [];
|
|
881
|
+
const rollback = [];
|
|
882
|
+
for (const change of journal.changes) {
|
|
883
|
+
let current;
|
|
884
|
+
try {
|
|
885
|
+
current = (await readState(root, change.path))?.fingerprint ?? null;
|
|
886
|
+
} catch (error) {
|
|
887
|
+
conflicts.push({
|
|
888
|
+
severity: "error",
|
|
889
|
+
code: "recovery-conflict",
|
|
890
|
+
path: change.path,
|
|
891
|
+
message: error instanceof Error ? error.message : String(error)
|
|
892
|
+
});
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (current === change.beforeFingerprint) continue;
|
|
896
|
+
if (current !== change.afterFingerprint) {
|
|
897
|
+
conflicts.push({
|
|
898
|
+
severity: "error",
|
|
899
|
+
code: "recovery-conflict",
|
|
900
|
+
path: change.path,
|
|
901
|
+
message: "The file changed after the interrupted mutation."
|
|
902
|
+
});
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
rollback.push(change);
|
|
906
|
+
}
|
|
907
|
+
if (conflicts.length > 0)
|
|
908
|
+
return {
|
|
909
|
+
journalPath: absoluteJournal,
|
|
910
|
+
recovered: false,
|
|
911
|
+
restoredPaths: [],
|
|
912
|
+
conflicts
|
|
913
|
+
};
|
|
914
|
+
const backups = /* @__PURE__ */ new Map();
|
|
915
|
+
for (const change of rollback) {
|
|
916
|
+
if (change.beforeFingerprint === null) continue;
|
|
917
|
+
if (!change.backupPath) {
|
|
918
|
+
conflicts.push({
|
|
919
|
+
severity: "error",
|
|
920
|
+
code: "recovery-backup-invalid",
|
|
921
|
+
path: change.path,
|
|
922
|
+
message: "The journal does not reference a backup."
|
|
923
|
+
});
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
const backup = path3.resolve(journalDirectory, change.backupPath);
|
|
927
|
+
if (!isWithin2(journalDirectory, backup)) {
|
|
928
|
+
conflicts.push({
|
|
929
|
+
severity: "error",
|
|
930
|
+
code: "recovery-backup-invalid",
|
|
931
|
+
path: change.path,
|
|
932
|
+
message: "The backup path is outside the mutation journal."
|
|
933
|
+
});
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
try {
|
|
937
|
+
const content = await fs3.readFile(backup);
|
|
938
|
+
if (fingerprint(content) !== change.beforeFingerprint)
|
|
939
|
+
throw new Error("The backup fingerprint does not match.");
|
|
940
|
+
backups.set(change.path, content);
|
|
941
|
+
} catch (error) {
|
|
942
|
+
conflicts.push({
|
|
943
|
+
severity: "error",
|
|
944
|
+
code: "recovery-backup-invalid",
|
|
945
|
+
path: change.path,
|
|
946
|
+
message: error instanceof Error ? error.message : String(error)
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (conflicts.length > 0)
|
|
951
|
+
return {
|
|
952
|
+
journalPath: absoluteJournal,
|
|
953
|
+
recovered: false,
|
|
954
|
+
restoredPaths: [],
|
|
955
|
+
conflicts
|
|
956
|
+
};
|
|
957
|
+
const restoredPaths = [];
|
|
958
|
+
for (const change of rollback) {
|
|
959
|
+
const conflict = await this.restoreChange(
|
|
960
|
+
root,
|
|
961
|
+
journalDirectory,
|
|
962
|
+
change,
|
|
963
|
+
backups.get(change.path)
|
|
964
|
+
);
|
|
965
|
+
if (conflict) {
|
|
966
|
+
conflicts.push(conflict);
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
restoredPaths.push(change.path);
|
|
970
|
+
}
|
|
971
|
+
if (conflicts.length > 0)
|
|
972
|
+
return {
|
|
973
|
+
journalPath: absoluteJournal,
|
|
974
|
+
recovered: false,
|
|
975
|
+
restoredPaths,
|
|
976
|
+
conflicts
|
|
977
|
+
};
|
|
978
|
+
journal.state = "recovered";
|
|
979
|
+
await writeJsonAtomically(absoluteJournal, journal);
|
|
980
|
+
return {
|
|
981
|
+
journalPath: absoluteJournal,
|
|
982
|
+
recovered: true,
|
|
983
|
+
restoredPaths,
|
|
984
|
+
conflicts: []
|
|
985
|
+
};
|
|
986
|
+
} finally {
|
|
987
|
+
await release();
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
async rollback(journalPath) {
|
|
991
|
+
const root = await this.root();
|
|
992
|
+
const absoluteJournal = path3.resolve(journalPath);
|
|
993
|
+
const mutationRoot = path3.join(root, ".riebeckite", "mutations");
|
|
994
|
+
const journalDirectory = path3.dirname(absoluteJournal);
|
|
995
|
+
const release = await this.acquireLock(root);
|
|
996
|
+
try {
|
|
997
|
+
if (!isWithin2(mutationRoot, absoluteJournal))
|
|
998
|
+
throw new Error("Journal path is outside the mutation directory.");
|
|
999
|
+
const journal = parseJournal(await fs3.readFile(absoluteJournal, "utf8"));
|
|
1000
|
+
if (path3.basename(absoluteJournal) !== "journal.json" || path3.basename(journalDirectory) !== journal.id || path3.basename(journalDirectory) === "plans")
|
|
1001
|
+
throw new Error("Journal path does not belong to its mutation.");
|
|
1002
|
+
if (journal.state === "rolled-back")
|
|
1003
|
+
return {
|
|
1004
|
+
journalPath: absoluteJournal,
|
|
1005
|
+
rolledBack: false,
|
|
1006
|
+
skipped: true,
|
|
1007
|
+
restoredPaths: [],
|
|
1008
|
+
conflicts: []
|
|
1009
|
+
};
|
|
1010
|
+
if (journal.state !== "complete")
|
|
1011
|
+
return {
|
|
1012
|
+
journalPath: absoluteJournal,
|
|
1013
|
+
rolledBack: false,
|
|
1014
|
+
skipped: false,
|
|
1015
|
+
restoredPaths: [],
|
|
1016
|
+
conflicts: [
|
|
1017
|
+
{
|
|
1018
|
+
severity: "error",
|
|
1019
|
+
code: "rollback-journal-incomplete",
|
|
1020
|
+
message: "Only a completed mutation can be rolled back."
|
|
1021
|
+
}
|
|
1022
|
+
]
|
|
1023
|
+
};
|
|
1024
|
+
const conflicts = [];
|
|
1025
|
+
const backups = /* @__PURE__ */ new Map();
|
|
1026
|
+
for (const change of journal.changes) {
|
|
1027
|
+
let current = null;
|
|
1028
|
+
try {
|
|
1029
|
+
current = (await readState(root, change.path))?.fingerprint ?? null;
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
conflicts.push({
|
|
1032
|
+
severity: "error",
|
|
1033
|
+
code: "rollback-conflict",
|
|
1034
|
+
path: change.path,
|
|
1035
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1036
|
+
});
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (current !== change.afterFingerprint) {
|
|
1040
|
+
conflicts.push({
|
|
1041
|
+
severity: "error",
|
|
1042
|
+
code: "rollback-conflict",
|
|
1043
|
+
path: change.path,
|
|
1044
|
+
message: "The file changed after the mutation was applied."
|
|
1045
|
+
});
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (change.beforeFingerprint === null) continue;
|
|
1049
|
+
if (!change.backupPath) {
|
|
1050
|
+
conflicts.push({
|
|
1051
|
+
severity: "error",
|
|
1052
|
+
code: "rollback-backup-invalid",
|
|
1053
|
+
path: change.path,
|
|
1054
|
+
message: "The journal does not reference a backup."
|
|
1055
|
+
});
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const backup = path3.resolve(journalDirectory, change.backupPath);
|
|
1059
|
+
if (!isWithin2(journalDirectory, backup)) {
|
|
1060
|
+
conflicts.push({
|
|
1061
|
+
severity: "error",
|
|
1062
|
+
code: "rollback-backup-invalid",
|
|
1063
|
+
path: change.path,
|
|
1064
|
+
message: "The backup path is outside the mutation journal."
|
|
1065
|
+
});
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
try {
|
|
1069
|
+
const content = await fs3.readFile(backup);
|
|
1070
|
+
if (fingerprint(content) !== change.beforeFingerprint)
|
|
1071
|
+
throw new Error("The backup fingerprint does not match.");
|
|
1072
|
+
backups.set(change.path, content);
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
conflicts.push({
|
|
1075
|
+
severity: "error",
|
|
1076
|
+
code: "rollback-backup-invalid",
|
|
1077
|
+
path: change.path,
|
|
1078
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (conflicts.length > 0)
|
|
1083
|
+
return {
|
|
1084
|
+
journalPath: absoluteJournal,
|
|
1085
|
+
rolledBack: false,
|
|
1086
|
+
skipped: false,
|
|
1087
|
+
restoredPaths: [],
|
|
1088
|
+
conflicts
|
|
1089
|
+
};
|
|
1090
|
+
const restoredPaths = [];
|
|
1091
|
+
for (const change of journal.changes) {
|
|
1092
|
+
const conflict = await this.restoreChange(
|
|
1093
|
+
root,
|
|
1094
|
+
journalDirectory,
|
|
1095
|
+
change,
|
|
1096
|
+
backups.get(change.path)
|
|
1097
|
+
);
|
|
1098
|
+
if (conflict) {
|
|
1099
|
+
conflicts.push(conflict);
|
|
1100
|
+
break;
|
|
1101
|
+
}
|
|
1102
|
+
restoredPaths.push(change.path);
|
|
1103
|
+
}
|
|
1104
|
+
if (conflicts.length > 0)
|
|
1105
|
+
return {
|
|
1106
|
+
journalPath: absoluteJournal,
|
|
1107
|
+
rolledBack: false,
|
|
1108
|
+
skipped: false,
|
|
1109
|
+
restoredPaths,
|
|
1110
|
+
conflicts
|
|
1111
|
+
};
|
|
1112
|
+
journal.state = "rolled-back";
|
|
1113
|
+
await writeJsonAtomically(absoluteJournal, journal);
|
|
1114
|
+
return {
|
|
1115
|
+
journalPath: absoluteJournal,
|
|
1116
|
+
rolledBack: true,
|
|
1117
|
+
skipped: false,
|
|
1118
|
+
restoredPaths,
|
|
1119
|
+
conflicts: []
|
|
1120
|
+
};
|
|
1121
|
+
} finally {
|
|
1122
|
+
await release();
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
async restoreChange(root, journalDirectory, change, backup) {
|
|
1126
|
+
if (change.afterFingerprint === null) {
|
|
1127
|
+
if (change.beforeFingerprint === null || !backup)
|
|
1128
|
+
return recoveryConflict(
|
|
1129
|
+
change.path,
|
|
1130
|
+
"The deletion cannot be rolled back safely."
|
|
1131
|
+
);
|
|
1132
|
+
const target2 = await safeFilePath(root, change.path, true);
|
|
1133
|
+
try {
|
|
1134
|
+
await writeFileExclusive(target2, backup);
|
|
1135
|
+
return null;
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
if (error.code === "EEXIST")
|
|
1138
|
+
return recoveryConflict(
|
|
1139
|
+
change.path,
|
|
1140
|
+
"A file appeared during recovery; it was not overwritten."
|
|
1141
|
+
);
|
|
1142
|
+
throw error;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
await this.options.beforeRecoveryRestore?.(change.path);
|
|
1146
|
+
const target = await safeFilePath(root, change.path, false);
|
|
1147
|
+
const captureDirectory = path3.join(journalDirectory, "quarantine");
|
|
1148
|
+
await fs3.mkdir(captureDirectory, { recursive: true });
|
|
1149
|
+
const captured = path3.join(captureDirectory, `${randomUUID()}.capture`);
|
|
1150
|
+
try {
|
|
1151
|
+
await fs3.rename(target, captured);
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
if (error.code === "ENOENT")
|
|
1154
|
+
return recoveryConflict(
|
|
1155
|
+
change.path,
|
|
1156
|
+
"The file disappeared before it could be verified."
|
|
1157
|
+
);
|
|
1158
|
+
throw error;
|
|
1159
|
+
}
|
|
1160
|
+
const content = await fs3.readFile(captured);
|
|
1161
|
+
if (fingerprint(content) !== change.afterFingerprint) {
|
|
1162
|
+
await restoreCapturedFile(target, captured);
|
|
1163
|
+
return recoveryConflict(
|
|
1164
|
+
change.path,
|
|
1165
|
+
"The file changed after verification; its content was preserved and not overwritten."
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
await this.options.afterRecoveryCapture?.(change.path);
|
|
1169
|
+
if (change.beforeFingerprint === null) {
|
|
1170
|
+
try {
|
|
1171
|
+
await fs3.lstat(target);
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
if (error.code === "ENOENT") return null;
|
|
1174
|
+
throw error;
|
|
1175
|
+
}
|
|
1176
|
+
return recoveryConflict(
|
|
1177
|
+
change.path,
|
|
1178
|
+
"A file appeared during recovery; it was not deleted."
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
try {
|
|
1182
|
+
await writeFileExclusive(target, backup);
|
|
1183
|
+
return null;
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
if (error.code === "EEXIST")
|
|
1186
|
+
return recoveryConflict(
|
|
1187
|
+
change.path,
|
|
1188
|
+
"A file appeared during recovery; it was not overwritten."
|
|
1189
|
+
);
|
|
1190
|
+
throw error;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
async savePlan(plan, planPath) {
|
|
1194
|
+
const root = await this.root();
|
|
1195
|
+
const target = await this.planPath(root, plan.id, planPath);
|
|
1196
|
+
await writeJsonAtomically(target, serializePlan(plan));
|
|
1197
|
+
return target;
|
|
1198
|
+
}
|
|
1199
|
+
async loadPlan(planPath) {
|
|
1200
|
+
const root = await this.root();
|
|
1201
|
+
const absolute = await this.planPath(root, void 0, planPath);
|
|
1202
|
+
return parsePlan(await fs3.readFile(absolute, "utf8"));
|
|
1203
|
+
}
|
|
1204
|
+
async revalidatePlan(plan) {
|
|
1205
|
+
if (plan.schemaVersion !== 1)
|
|
1206
|
+
return [
|
|
1207
|
+
{
|
|
1208
|
+
severity: "error",
|
|
1209
|
+
code: "unsupported-plan",
|
|
1210
|
+
message: "The mutation plan has an unsupported schema version."
|
|
1211
|
+
}
|
|
1212
|
+
];
|
|
1213
|
+
const refreshed = await this.plan(plan.operations);
|
|
1214
|
+
if (!samePlan(plan, refreshed))
|
|
1215
|
+
return [
|
|
1216
|
+
{
|
|
1217
|
+
severity: "error",
|
|
1218
|
+
code: "stale-plan",
|
|
1219
|
+
message: "The saved plan no longer matches a fresh validation of its operations."
|
|
1220
|
+
}
|
|
1221
|
+
];
|
|
1222
|
+
return [];
|
|
1223
|
+
}
|
|
1224
|
+
async planPath(root, id, requested) {
|
|
1225
|
+
const plansRoot = path3.join(root, ".riebeckite", "mutations", "plans");
|
|
1226
|
+
const target = requested ? path3.resolve(plansRoot, requested) : path3.join(plansRoot, `${id ?? ""}.json`);
|
|
1227
|
+
if (!isWithin2(plansRoot, target))
|
|
1228
|
+
throw new Error("Plan path is outside the mutation plan directory.");
|
|
1229
|
+
if (requested) {
|
|
1230
|
+
const extension = path3.extname(target).toLowerCase();
|
|
1231
|
+
if (extension !== ".json")
|
|
1232
|
+
throw new Error("Mutation plan must be a JSON file.");
|
|
1233
|
+
}
|
|
1234
|
+
await fs3.mkdir(path3.dirname(target), { recursive: true });
|
|
1235
|
+
return target;
|
|
1236
|
+
}
|
|
1237
|
+
async root() {
|
|
1238
|
+
const root = await fs3.realpath(this.contentDirectory);
|
|
1239
|
+
if (!(await fs3.stat(root)).isDirectory())
|
|
1240
|
+
throw new Error("Content directory is not a directory.");
|
|
1241
|
+
return root;
|
|
1242
|
+
}
|
|
1243
|
+
async loadFiles(root, relative, files) {
|
|
1244
|
+
for (const entry of await fs3.readdir(path3.join(root, relative), {
|
|
1245
|
+
withFileTypes: true
|
|
1246
|
+
})) {
|
|
1247
|
+
if (entry.name === ".riebeckite" && relative === "") continue;
|
|
1248
|
+
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
|
1249
|
+
if (entry.isSymbolicLink()) continue;
|
|
1250
|
+
if (entry.isDirectory()) await this.loadFiles(root, child, files);
|
|
1251
|
+
if (entry.isFile())
|
|
1252
|
+
files.set(child, stateOf(await fs3.readFile(path3.join(root, child))));
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
async verifyPreconditions(root, changes) {
|
|
1256
|
+
const conflicts = [];
|
|
1257
|
+
for (const change of changes) {
|
|
1258
|
+
try {
|
|
1259
|
+
const current = await readState(root, change.path);
|
|
1260
|
+
if ((current?.fingerprint ?? null) !== change.beforeFingerprint)
|
|
1261
|
+
conflicts.push({
|
|
1262
|
+
severity: "error",
|
|
1263
|
+
code: "concurrent-modification",
|
|
1264
|
+
path: change.path,
|
|
1265
|
+
message: "The file no longer matches the planned fingerprint."
|
|
1266
|
+
});
|
|
1267
|
+
} catch (error) {
|
|
1268
|
+
conflicts.push({
|
|
1269
|
+
severity: "error",
|
|
1270
|
+
code: "unsafe-path",
|
|
1271
|
+
path: change.path,
|
|
1272
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return conflicts;
|
|
1277
|
+
}
|
|
1278
|
+
async backup(root, journalDirectory, change) {
|
|
1279
|
+
if (change.beforeFingerprint === null)
|
|
1280
|
+
return {
|
|
1281
|
+
path: change.path,
|
|
1282
|
+
beforeFingerprint: null,
|
|
1283
|
+
afterFingerprint: change.afterFingerprint,
|
|
1284
|
+
status: "pending"
|
|
1285
|
+
};
|
|
1286
|
+
const state = await readState(root, change.path);
|
|
1287
|
+
if (!state || state.fingerprint !== change.beforeFingerprint)
|
|
1288
|
+
throw new Error(`Could not back up ${change.path}.`);
|
|
1289
|
+
const backupPath = `backups/${Buffer.from(change.path).toString("base64url")}`;
|
|
1290
|
+
await writeFileAtomically(
|
|
1291
|
+
path3.join(journalDirectory, backupPath),
|
|
1292
|
+
state.content
|
|
1293
|
+
);
|
|
1294
|
+
return {
|
|
1295
|
+
path: change.path,
|
|
1296
|
+
beforeFingerprint: change.beforeFingerprint,
|
|
1297
|
+
afterFingerprint: change.afterFingerprint,
|
|
1298
|
+
backupPath,
|
|
1299
|
+
status: "pending"
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
async acquireLock(root) {
|
|
1303
|
+
const lockPath = path3.join(root, ".riebeckite", "mutations", "lock");
|
|
1304
|
+
await fs3.mkdir(path3.dirname(lockPath), { recursive: true });
|
|
1305
|
+
let handle;
|
|
1306
|
+
try {
|
|
1307
|
+
handle = await fs3.open(lockPath, "wx");
|
|
1308
|
+
} catch (error) {
|
|
1309
|
+
if (error.code === "EEXIST") {
|
|
1310
|
+
const stale = await staleLockContent2(lockPath);
|
|
1311
|
+
if (stale !== null) {
|
|
1312
|
+
const quarantine = `${lockPath}.stale-${randomUUID()}`;
|
|
1313
|
+
try {
|
|
1314
|
+
await fs3.rename(lockPath, quarantine);
|
|
1315
|
+
} catch (renameError) {
|
|
1316
|
+
if (renameError.code === "ENOENT")
|
|
1317
|
+
return await this.acquireLock(root);
|
|
1318
|
+
throw renameError;
|
|
1319
|
+
}
|
|
1320
|
+
const quarantined = await fs3.readFile(quarantine, "utf8");
|
|
1321
|
+
if (quarantined !== stale) {
|
|
1322
|
+
const restored = await fs3.writeFile(lockPath, quarantined, { flag: "wx" }).then(
|
|
1323
|
+
() => true,
|
|
1324
|
+
() => false
|
|
1325
|
+
);
|
|
1326
|
+
if (restored) await fs3.rm(quarantine, { force: true });
|
|
1327
|
+
throw new Error(
|
|
1328
|
+
"Another content mutation is already applying or requires recovery."
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
await fs3.rm(quarantine, { force: true });
|
|
1332
|
+
return await this.acquireLock(root);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
throw new Error(
|
|
1336
|
+
"Another content mutation is already applying or requires recovery."
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
const token = randomUUID();
|
|
1340
|
+
await handle.writeFile(
|
|
1341
|
+
JSON.stringify({
|
|
1342
|
+
pid: process.pid,
|
|
1343
|
+
token,
|
|
1344
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1345
|
+
})
|
|
1346
|
+
);
|
|
1347
|
+
return async () => {
|
|
1348
|
+
await handle.close();
|
|
1349
|
+
try {
|
|
1350
|
+
const current = JSON.parse(await fs3.readFile(lockPath, "utf8"));
|
|
1351
|
+
if (isRecord2(current) && current.token === token)
|
|
1352
|
+
await fs3.rm(lockPath, { force: true });
|
|
1353
|
+
} catch {
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
function validateLogicalPath(value) {
|
|
1359
|
+
const normalized = value.replaceAll("\\", "/").normalize("NFC");
|
|
1360
|
+
if (!normalized || normalized.startsWith("/") || path3.isAbsolute(normalized) || path3.win32.isAbsolute(normalized) || normalized.split("/").some(
|
|
1361
|
+
(part) => !part || part === "." || part === ".." || hasForbiddenPathCharacter(part) || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(part)
|
|
1362
|
+
))
|
|
1363
|
+
throw new Error("Invalid content path.");
|
|
1364
|
+
return normalized;
|
|
1365
|
+
}
|
|
1366
|
+
function hasForbiddenPathCharacter(value) {
|
|
1367
|
+
return [...value].some((character) => {
|
|
1368
|
+
const code = character.codePointAt(0) ?? 0;
|
|
1369
|
+
return code <= 31 || '<>:"|?*'.includes(character);
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
async function safeFilePath(root, logicalPath, createParents) {
|
|
1373
|
+
const normalized = validateLogicalPath(logicalPath);
|
|
1374
|
+
const filePath = path3.resolve(root, ...normalized.split("/"));
|
|
1375
|
+
if (!isWithin2(root, filePath))
|
|
1376
|
+
throw new Error("Content path escapes the workspace.");
|
|
1377
|
+
const parent = path3.dirname(filePath);
|
|
1378
|
+
if (createParents) await fs3.mkdir(parent, { recursive: true });
|
|
1379
|
+
let current = root;
|
|
1380
|
+
for (const part of path3.relative(root, parent).split(path3.sep).filter(Boolean)) {
|
|
1381
|
+
current = path3.join(current, part);
|
|
1382
|
+
const stats = await fs3.lstat(current);
|
|
1383
|
+
if (stats.isSymbolicLink() || !stats.isDirectory())
|
|
1384
|
+
throw new Error(
|
|
1385
|
+
"Content path traverses a symbolic link or non-directory."
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
try {
|
|
1389
|
+
if ((await fs3.lstat(filePath)).isSymbolicLink())
|
|
1390
|
+
throw new Error("Content path is a symbolic link.");
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
if (error.code !== "ENOENT") throw error;
|
|
1393
|
+
}
|
|
1394
|
+
return filePath;
|
|
1395
|
+
}
|
|
1396
|
+
async function assertSafeExistingParents(root, logicalPath) {
|
|
1397
|
+
const normalized = validateLogicalPath(logicalPath);
|
|
1398
|
+
let current = root;
|
|
1399
|
+
for (const part of normalized.split("/").slice(0, -1)) {
|
|
1400
|
+
current = path3.join(current, part);
|
|
1401
|
+
try {
|
|
1402
|
+
const stats = await fs3.lstat(current);
|
|
1403
|
+
if (stats.isSymbolicLink() || !stats.isDirectory())
|
|
1404
|
+
throw new Error(
|
|
1405
|
+
"Content path traverses a symbolic link or non-directory."
|
|
1406
|
+
);
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
if (error.code === "ENOENT") return;
|
|
1409
|
+
throw error;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
async function readState(root, logicalPath) {
|
|
1414
|
+
try {
|
|
1415
|
+
const filePath = await safeFilePath(root, logicalPath, false);
|
|
1416
|
+
const stats = await fs3.lstat(filePath);
|
|
1417
|
+
if (!stats.isFile() || stats.isSymbolicLink())
|
|
1418
|
+
throw new Error("Content path is not a regular file.");
|
|
1419
|
+
return stateOf(await fs3.readFile(filePath));
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
if (error.code === "ENOENT") return null;
|
|
1422
|
+
throw error;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
function stateOf(content) {
|
|
1426
|
+
const bytes = typeof content === "string" ? Buffer.from(content) : new Uint8Array(content);
|
|
1427
|
+
return { content: bytes, fingerprint: fingerprint(bytes) };
|
|
1428
|
+
}
|
|
1429
|
+
function fingerprint(content) {
|
|
1430
|
+
return createHash2("sha256").update(content).digest("hex");
|
|
1431
|
+
}
|
|
1432
|
+
function foldRetargets(retargets, files) {
|
|
1433
|
+
const byFrom = new Map(
|
|
1434
|
+
retargets.map((retarget) => [normalizedPath(retarget.from), retarget])
|
|
1435
|
+
);
|
|
1436
|
+
const existing = new Set([...files.keys()].map(normalizedPath));
|
|
1437
|
+
return retargets.filter((retarget) => !existing.has(normalizedPath(retarget.from))).map((retarget) => {
|
|
1438
|
+
if (!retarget.to) return retarget;
|
|
1439
|
+
let to = retarget.to;
|
|
1440
|
+
const seen = /* @__PURE__ */ new Set([normalizedPath(retarget.from)]);
|
|
1441
|
+
for (; ; ) {
|
|
1442
|
+
const key = normalizedPath(to);
|
|
1443
|
+
const next = byFrom.get(key);
|
|
1444
|
+
if (!next?.to || seen.has(key)) break;
|
|
1445
|
+
seen.add(key);
|
|
1446
|
+
to = next.to;
|
|
1447
|
+
}
|
|
1448
|
+
return to === retarget.to ? retarget : { from: retarget.from, to };
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
function normalizedPath(value) {
|
|
1452
|
+
return value.replaceAll("\\", "/").toLowerCase();
|
|
1453
|
+
}
|
|
1454
|
+
function buildContentIndex(files) {
|
|
1455
|
+
const decoder = new TextDecoder();
|
|
1456
|
+
const entries = [];
|
|
1457
|
+
const aliasesByPath = /* @__PURE__ */ new Map();
|
|
1458
|
+
for (const [filePath, state] of files) {
|
|
1459
|
+
entries.push({ path: filePath });
|
|
1460
|
+
if (filePath.toLowerCase().endsWith(".md"))
|
|
1461
|
+
aliasesByPath.set(
|
|
1462
|
+
filePath,
|
|
1463
|
+
extractFrontmatterAliases(decoder.decode(state.content))
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
return ContentIndexBuilder.buildFromAliases(entries, aliasesByPath);
|
|
1467
|
+
}
|
|
1468
|
+
function sameContentIndex(left, right) {
|
|
1469
|
+
if (left.size !== right.size) return false;
|
|
1470
|
+
for (const [key, value] of left) {
|
|
1471
|
+
if (right.get(key) !== value) return false;
|
|
1472
|
+
}
|
|
1473
|
+
return true;
|
|
1474
|
+
}
|
|
1475
|
+
function isWithin2(parent, child) {
|
|
1476
|
+
const relative = path3.relative(parent, child);
|
|
1477
|
+
return relative === "" || !relative.startsWith(`..${path3.sep}`) && relative !== ".." && !path3.isAbsolute(relative);
|
|
1478
|
+
}
|
|
1479
|
+
function operationPath(operation) {
|
|
1480
|
+
return "path" in operation ? operation.path : operation.to;
|
|
1481
|
+
}
|
|
1482
|
+
function addCaseConflicts(files, conflicts) {
|
|
1483
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1484
|
+
for (const filePath of files.keys()) {
|
|
1485
|
+
const folded = filePath.toLocaleLowerCase("en-US");
|
|
1486
|
+
const previous = seen.get(folded);
|
|
1487
|
+
if (previous && previous !== filePath)
|
|
1488
|
+
conflicts.push({
|
|
1489
|
+
severity: "error",
|
|
1490
|
+
code: "case-collision",
|
|
1491
|
+
path: filePath,
|
|
1492
|
+
message: `Path conflicts with ${previous} on a case-insensitive filesystem.`
|
|
1493
|
+
});
|
|
1494
|
+
else seen.set(folded, filePath);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
function recoveryConflict(filePath, message) {
|
|
1498
|
+
return {
|
|
1499
|
+
severity: "error",
|
|
1500
|
+
code: "recovery-conflict",
|
|
1501
|
+
path: filePath,
|
|
1502
|
+
message
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
async function writeFileExclusive(filePath, content) {
|
|
1506
|
+
await fs3.mkdir(path3.dirname(filePath), { recursive: true });
|
|
1507
|
+
const handle = await fs3.open(filePath, "wx");
|
|
1508
|
+
try {
|
|
1509
|
+
await handle.writeFile(content);
|
|
1510
|
+
await handle.sync();
|
|
1511
|
+
} finally {
|
|
1512
|
+
await handle.close();
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
async function restoreCapturedFile(target, captured) {
|
|
1516
|
+
try {
|
|
1517
|
+
await writeFileExclusive(target, await fs3.readFile(captured));
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
if (error.code !== "EEXIST") throw error;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
async function writeFileAtomically(filePath, content, expectedFingerprint) {
|
|
1523
|
+
await fs3.mkdir(path3.dirname(filePath), { recursive: true });
|
|
1524
|
+
const temporary = path3.join(
|
|
1525
|
+
path3.dirname(filePath),
|
|
1526
|
+
`.${path3.basename(filePath)}.${randomUUID()}.tmp`
|
|
1527
|
+
);
|
|
1528
|
+
try {
|
|
1529
|
+
const handle = await fs3.open(temporary, "wx");
|
|
1530
|
+
try {
|
|
1531
|
+
await handle.writeFile(content);
|
|
1532
|
+
await handle.sync();
|
|
1533
|
+
} finally {
|
|
1534
|
+
await handle.close();
|
|
1535
|
+
}
|
|
1536
|
+
if (expectedFingerprint !== void 0 && await fingerprintAt(filePath) !== expectedFingerprint)
|
|
1537
|
+
throw new Error(`Content changed during apply: ${filePath}`);
|
|
1538
|
+
await fs3.rename(temporary, filePath);
|
|
1539
|
+
} finally {
|
|
1540
|
+
await fs3.rm(temporary, { force: true });
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
async function fingerprintAt(filePath) {
|
|
1544
|
+
try {
|
|
1545
|
+
const stats = await fs3.lstat(filePath);
|
|
1546
|
+
if (!stats.isFile() || stats.isSymbolicLink())
|
|
1547
|
+
throw new Error("Content path is not a regular file.");
|
|
1548
|
+
return fingerprint(await fs3.readFile(filePath));
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
if (error.code === "ENOENT") return null;
|
|
1551
|
+
throw error;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
async function writeJsonAtomically(filePath, value) {
|
|
1555
|
+
await writeFileAtomically(
|
|
1556
|
+
filePath,
|
|
1557
|
+
Buffer.from(`${JSON.stringify(value, null, 2)}
|
|
1558
|
+
`)
|
|
1559
|
+
);
|
|
1560
|
+
}
|
|
1561
|
+
async function staleLockContent2(lockPath) {
|
|
1562
|
+
let content;
|
|
1563
|
+
try {
|
|
1564
|
+
content = await fs3.readFile(lockPath, "utf8");
|
|
1565
|
+
} catch {
|
|
1566
|
+
return null;
|
|
1567
|
+
}
|
|
1568
|
+
let value;
|
|
1569
|
+
try {
|
|
1570
|
+
value = JSON.parse(content);
|
|
1571
|
+
} catch {
|
|
1572
|
+
return await lockFileIsOld2(lockPath) ? content : null;
|
|
1573
|
+
}
|
|
1574
|
+
const pid = isRecord2(value) ? value.pid : void 0;
|
|
1575
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
|
|
1576
|
+
return await lockFileIsOld2(lockPath) ? content : null;
|
|
1577
|
+
try {
|
|
1578
|
+
process.kill(pid, 0);
|
|
1579
|
+
return null;
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
return error.code === "ESRCH" ? content : null;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
async function lockFileIsOld2(lockPath) {
|
|
1585
|
+
try {
|
|
1586
|
+
return Date.now() - (await fs3.stat(lockPath)).mtimeMs > 5e3;
|
|
1587
|
+
} catch {
|
|
1588
|
+
return false;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
function updateFrontmatter(source, set, remove) {
|
|
1592
|
+
let text;
|
|
1593
|
+
try {
|
|
1594
|
+
text = new TextDecoder("utf-8", {
|
|
1595
|
+
fatal: true,
|
|
1596
|
+
ignoreBOM: true
|
|
1597
|
+
}).decode(source.content);
|
|
1598
|
+
} catch {
|
|
1599
|
+
throw new Error("The Markdown file is not valid UTF-8.");
|
|
1600
|
+
}
|
|
1601
|
+
const bom = text.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
1602
|
+
const body = bom ? text.slice(1) : text;
|
|
1603
|
+
const match = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(body);
|
|
1604
|
+
if (!match) throw new Error("The Markdown file has no YAML frontmatter.");
|
|
1605
|
+
const document = parseDocument(match[2]);
|
|
1606
|
+
if (document.errors.length > 0)
|
|
1607
|
+
throw new Error(`Invalid YAML frontmatter: ${document.errors[0]?.message}`);
|
|
1608
|
+
for (const [key, value] of Object.entries(set ?? {}))
|
|
1609
|
+
document.setIn(key.split("."), value);
|
|
1610
|
+
for (const key of remove ?? []) document.deleteIn(key.split("."));
|
|
1611
|
+
return stateOf(
|
|
1612
|
+
`${bom}${match[1]}${document.toString().trimEnd()}${match[3]}${body.slice(match[0].length)}`
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// src/migration_state_store.ts
|
|
1617
|
+
import fs4 from "node:fs/promises";
|
|
1618
|
+
import path4 from "node:path";
|
|
1619
|
+
import {
|
|
1620
|
+
emptyContentMigrationHistory,
|
|
1621
|
+
emptyContentMigrationState,
|
|
1622
|
+
parseContentMigrationHistory,
|
|
1623
|
+
parseContentMigrationState
|
|
1624
|
+
} from "@riebeckite/core";
|
|
1625
|
+
function migrationStateDirectory(contentDirectory) {
|
|
1626
|
+
return path4.join(contentDirectory, ".riebeckite", "migrations");
|
|
1627
|
+
}
|
|
1628
|
+
function migrationStatePath(contentDirectory) {
|
|
1629
|
+
return path4.join(migrationStateDirectory(contentDirectory), "state.json");
|
|
1630
|
+
}
|
|
1631
|
+
function migrationHistoryPath(contentDirectory) {
|
|
1632
|
+
return path4.join(migrationStateDirectory(contentDirectory), "history.json");
|
|
1633
|
+
}
|
|
1634
|
+
function migrationLockPath(contentDirectory) {
|
|
1635
|
+
return path4.join(migrationStateDirectory(contentDirectory), "lock");
|
|
1636
|
+
}
|
|
1637
|
+
async function readMigrationState(contentDirectory) {
|
|
1638
|
+
try {
|
|
1639
|
+
return parseContentMigrationState(
|
|
1640
|
+
JSON.parse(
|
|
1641
|
+
await fs4.readFile(migrationStatePath(contentDirectory), "utf8")
|
|
1642
|
+
)
|
|
1643
|
+
);
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
if (error.code === "ENOENT")
|
|
1646
|
+
return emptyContentMigrationState();
|
|
1647
|
+
throw error;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
async function writeMigrationState(contentDirectory, state) {
|
|
1651
|
+
const target = migrationStatePath(contentDirectory);
|
|
1652
|
+
await fs4.mkdir(path4.dirname(target), { recursive: true });
|
|
1653
|
+
await fs4.writeFile(target, `${JSON.stringify(state, null, 2)}
|
|
1654
|
+
`, "utf8");
|
|
1655
|
+
}
|
|
1656
|
+
async function readMigrationHistory(contentDirectory) {
|
|
1657
|
+
try {
|
|
1658
|
+
return parseContentMigrationHistory(
|
|
1659
|
+
JSON.parse(
|
|
1660
|
+
await fs4.readFile(migrationHistoryPath(contentDirectory), "utf8")
|
|
1661
|
+
)
|
|
1662
|
+
);
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
if (error.code === "ENOENT")
|
|
1665
|
+
return emptyContentMigrationHistory();
|
|
1666
|
+
throw error;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
async function writeMigrationHistory(contentDirectory, history) {
|
|
1670
|
+
const target = migrationHistoryPath(contentDirectory);
|
|
1671
|
+
await fs4.mkdir(path4.dirname(target), { recursive: true });
|
|
1672
|
+
await fs4.writeFile(target, `${JSON.stringify(history, null, 2)}
|
|
1673
|
+
`, "utf8");
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// src/file_system_content_migration.ts
|
|
1677
|
+
var FileSystemContentMigration = class {
|
|
1678
|
+
constructor(options) {
|
|
1679
|
+
this.options = options;
|
|
1680
|
+
this.mutation = new FileSystemContentMutation(options.contentDirectory);
|
|
1681
|
+
this.source = new FileSystemContentSource(options.contentDirectory, [
|
|
1682
|
+
...options.config?.content.exclude ?? [],
|
|
1683
|
+
...options.exclude ?? []
|
|
1684
|
+
]);
|
|
1685
|
+
}
|
|
1686
|
+
options;
|
|
1687
|
+
mutation;
|
|
1688
|
+
source;
|
|
1689
|
+
get contentDirectory() {
|
|
1690
|
+
return this.options.contentDirectory;
|
|
1691
|
+
}
|
|
1692
|
+
async plan(options = {}) {
|
|
1693
|
+
const migrations = this.options.registry.resolve(options.ids);
|
|
1694
|
+
const entries = (await this.source.scan()).filter(
|
|
1695
|
+
(entry) => entry.path.toLowerCase().endsWith(".md")
|
|
1696
|
+
);
|
|
1697
|
+
const files = await Promise.all(
|
|
1698
|
+
entries.map(async (entry) => ({
|
|
1699
|
+
path: entry.path,
|
|
1700
|
+
content: await this.source.read(entry)
|
|
1701
|
+
}))
|
|
1702
|
+
);
|
|
1703
|
+
const baseContentFingerprint = fingerprintFiles(files);
|
|
1704
|
+
const redirectField = this.options.redirectField ?? redirectFieldFromConfig(this.options.config);
|
|
1705
|
+
const batch = planContentMigrationBatch({
|
|
1706
|
+
migrations,
|
|
1707
|
+
files,
|
|
1708
|
+
preserveMovedPermalinks: false,
|
|
1709
|
+
redirectField
|
|
1710
|
+
});
|
|
1711
|
+
const beforeOutcome = await this.tryAnalyze(this.source);
|
|
1712
|
+
let mutationPlan = await this.mutation.plan(batch.operations);
|
|
1713
|
+
let afterOutcome = beforeOutcome.analysis ? await this.tryAnalyze(
|
|
1714
|
+
createFutureContentSource(this.source, mutationPlan.changes)
|
|
1715
|
+
) : { analysis: void 0, diagnostics: [] };
|
|
1716
|
+
const diagnosticList = [
|
|
1717
|
+
...beforeOutcome.diagnostics,
|
|
1718
|
+
...afterOutcome.diagnostics
|
|
1719
|
+
];
|
|
1720
|
+
if (beforeOutcome.analysis && afterOutcome.analysis) {
|
|
1721
|
+
const redirectOperations = this.redirectOperations(
|
|
1722
|
+
batch,
|
|
1723
|
+
beforeOutcome.analysis.manifest,
|
|
1724
|
+
afterOutcome.analysis.manifest,
|
|
1725
|
+
redirectField
|
|
1726
|
+
);
|
|
1727
|
+
if (redirectOperations.length > 0) {
|
|
1728
|
+
mutationPlan = await this.mutation.plan([
|
|
1729
|
+
...batch.operations,
|
|
1730
|
+
...redirectOperations
|
|
1731
|
+
]);
|
|
1732
|
+
afterOutcome = await this.tryAnalyze(
|
|
1733
|
+
createFutureContentSource(this.source, mutationPlan.changes)
|
|
1734
|
+
);
|
|
1735
|
+
diagnosticList.push(...afterOutcome.diagnostics);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
const verification = beforeOutcome.analysis && afterOutcome.analysis ? this.verify({
|
|
1739
|
+
batch,
|
|
1740
|
+
before: beforeOutcome.analysis,
|
|
1741
|
+
after: afterOutcome.analysis,
|
|
1742
|
+
mutationPlan
|
|
1743
|
+
}).diagnostics : [];
|
|
1744
|
+
const diagnostics = [
|
|
1745
|
+
...batch.diagnostics,
|
|
1746
|
+
...mutationPlan.warnings.map((issue) => mutationIssue(issue, "warning")),
|
|
1747
|
+
...diagnosticList,
|
|
1748
|
+
...verification
|
|
1749
|
+
];
|
|
1750
|
+
const conflicts = [
|
|
1751
|
+
...mutationPlan.conflicts.map((issue) => mutationIssue(issue, "error")),
|
|
1752
|
+
...diagnostics.filter((diagnostic) => diagnostic.severity === "error")
|
|
1753
|
+
];
|
|
1754
|
+
const mutationChanges = mutationPlan.changes.map((change) => ({
|
|
1755
|
+
path: change.path,
|
|
1756
|
+
beforeFingerprint: change.beforeFingerprint,
|
|
1757
|
+
afterFingerprint: change.afterFingerprint
|
|
1758
|
+
}));
|
|
1759
|
+
const mutationPlanFingerprint = fingerprintMutationPlan(
|
|
1760
|
+
mutationPlan.operations,
|
|
1761
|
+
mutationPlan.changes
|
|
1762
|
+
);
|
|
1763
|
+
const planFingerprint = fingerprintPlan({
|
|
1764
|
+
migrations,
|
|
1765
|
+
baseContentFingerprint,
|
|
1766
|
+
mutationPlanFingerprint,
|
|
1767
|
+
operations: mutationPlan.operations,
|
|
1768
|
+
diagnostics,
|
|
1769
|
+
conflicts
|
|
1770
|
+
});
|
|
1771
|
+
return {
|
|
1772
|
+
schemaVersion: 1,
|
|
1773
|
+
id: randomUUID2(),
|
|
1774
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1775
|
+
migrations: migrations.map((migration) => ({
|
|
1776
|
+
id: migration.id,
|
|
1777
|
+
version: migration.version,
|
|
1778
|
+
fingerprint: migration.fingerprint
|
|
1779
|
+
})),
|
|
1780
|
+
baseContentFingerprint,
|
|
1781
|
+
mutationPlanId: mutationPlan.id,
|
|
1782
|
+
mutationPlanFingerprint,
|
|
1783
|
+
planFingerprint,
|
|
1784
|
+
operations: mutationPlan.operations,
|
|
1785
|
+
changes: mutationChanges,
|
|
1786
|
+
diagnostics,
|
|
1787
|
+
conflicts,
|
|
1788
|
+
selections: batch.selections,
|
|
1789
|
+
skipped: batch.skipped,
|
|
1790
|
+
impact: batch.impact,
|
|
1791
|
+
verification: {
|
|
1792
|
+
beforeFiles: beforeOutcome.analysis?.manifest.entries.length ?? 0,
|
|
1793
|
+
afterFiles: afterOutcome.analysis?.manifest.entries.length ?? 0,
|
|
1794
|
+
beforePublicationFingerprint: beforeOutcome.analysis?.publicationFingerprint ?? "",
|
|
1795
|
+
afterPublicationFingerprint: afterOutcome.analysis?.publicationFingerprint ?? "",
|
|
1796
|
+
derivedViews: afterOutcome.analysis?.derivedViews ?? 0
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
async savePlan(plan, outputPath) {
|
|
1801
|
+
const target = outputPath ?? path5.join(
|
|
1802
|
+
this.options.contentDirectory,
|
|
1803
|
+
".riebeckite",
|
|
1804
|
+
"migrations",
|
|
1805
|
+
"plans",
|
|
1806
|
+
`${plan.id}.json`
|
|
1807
|
+
);
|
|
1808
|
+
await fs5.mkdir(path5.dirname(target), { recursive: true });
|
|
1809
|
+
await fs5.writeFile(target, `${JSON.stringify(plan, null, 2)}
|
|
1810
|
+
`, "utf8");
|
|
1811
|
+
return target;
|
|
1812
|
+
}
|
|
1813
|
+
async loadPlan(planPath) {
|
|
1814
|
+
const content = await fs5.readFile(planPath, "utf8");
|
|
1815
|
+
const parsed = JSON.parse(content);
|
|
1816
|
+
if (!isRecord(parsed) || parsed.schemaVersion !== 1)
|
|
1817
|
+
throw new Error("The migration plan has an unsupported schema.");
|
|
1818
|
+
return parsed;
|
|
1819
|
+
}
|
|
1820
|
+
async history(options = {}) {
|
|
1821
|
+
const history = await this.readHistory();
|
|
1822
|
+
if (options.limit === void 0) return history;
|
|
1823
|
+
if (!Number.isInteger(options.limit) || options.limit < 0)
|
|
1824
|
+
throw new Error("The history limit must be a non-negative integer.");
|
|
1825
|
+
return {
|
|
1826
|
+
...history,
|
|
1827
|
+
runs: history.runs.slice(-options.limit)
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
async list() {
|
|
1831
|
+
return listContentMigrationEntries(
|
|
1832
|
+
this.options.registry.migrations,
|
|
1833
|
+
await this.readState()
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
async apply(options = {}) {
|
|
1837
|
+
const stored = options.planPath ? await this.loadPlan(options.planPath) : void 0;
|
|
1838
|
+
const requested = stored ? [...new Set(stored.migrations.map((migration) => migration.id))] : options.ids;
|
|
1839
|
+
const fresh = await this.plan({ ids: requested });
|
|
1840
|
+
if (stored && !sameMigrationPlan(stored, fresh))
|
|
1841
|
+
return {
|
|
1842
|
+
applied: false,
|
|
1843
|
+
skipped: false,
|
|
1844
|
+
plan: fresh,
|
|
1845
|
+
changedPaths: [],
|
|
1846
|
+
conflicts: [
|
|
1847
|
+
{
|
|
1848
|
+
severity: "error",
|
|
1849
|
+
code: "stale-plan",
|
|
1850
|
+
message: "The stored migration plan no longer matches the current content or migration definitions."
|
|
1851
|
+
}
|
|
1852
|
+
]
|
|
1853
|
+
};
|
|
1854
|
+
if (fresh.conflicts.length > 0)
|
|
1855
|
+
return {
|
|
1856
|
+
applied: false,
|
|
1857
|
+
skipped: false,
|
|
1858
|
+
plan: fresh,
|
|
1859
|
+
changedPaths: [],
|
|
1860
|
+
conflicts: fresh.conflicts
|
|
1861
|
+
};
|
|
1862
|
+
let release;
|
|
1863
|
+
try {
|
|
1864
|
+
release = await this.acquireLock();
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
return {
|
|
1867
|
+
applied: false,
|
|
1868
|
+
skipped: false,
|
|
1869
|
+
plan: fresh,
|
|
1870
|
+
changedPaths: [],
|
|
1871
|
+
conflicts: [
|
|
1872
|
+
{
|
|
1873
|
+
severity: "error",
|
|
1874
|
+
code: "migration-locked",
|
|
1875
|
+
message: errorMessage(error)
|
|
1876
|
+
}
|
|
1877
|
+
]
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
try {
|
|
1881
|
+
const state = await this.readState();
|
|
1882
|
+
const conflicts = this.preflight(fresh, state);
|
|
1883
|
+
const currentContent = await this.currentContentFingerprint();
|
|
1884
|
+
if (currentContent !== fresh.baseContentFingerprint)
|
|
1885
|
+
conflicts.push({
|
|
1886
|
+
severity: "error",
|
|
1887
|
+
code: "stale-plan",
|
|
1888
|
+
message: "Content changed after the migration plan was created."
|
|
1889
|
+
});
|
|
1890
|
+
if (conflicts.length > 0)
|
|
1891
|
+
return {
|
|
1892
|
+
applied: false,
|
|
1893
|
+
skipped: false,
|
|
1894
|
+
plan: fresh,
|
|
1895
|
+
changedPaths: [],
|
|
1896
|
+
conflicts
|
|
1897
|
+
};
|
|
1898
|
+
const actionable = fresh.migrations.filter(
|
|
1899
|
+
(migration) => !isAppliedRecord(
|
|
1900
|
+
findContentMigrationRecord(state, migration.id, migration.version),
|
|
1901
|
+
migration.fingerprint
|
|
1902
|
+
)
|
|
1903
|
+
);
|
|
1904
|
+
if (actionable.length === 0)
|
|
1905
|
+
return {
|
|
1906
|
+
applied: false,
|
|
1907
|
+
skipped: true,
|
|
1908
|
+
plan: fresh,
|
|
1909
|
+
changedPaths: [],
|
|
1910
|
+
conflicts: []
|
|
1911
|
+
};
|
|
1912
|
+
const mutationPlan = await this.mutation.plan(fresh.operations);
|
|
1913
|
+
if (fingerprintMutationPlan(
|
|
1914
|
+
mutationPlan.operations,
|
|
1915
|
+
mutationPlan.changes
|
|
1916
|
+
) !== fresh.mutationPlanFingerprint)
|
|
1917
|
+
return {
|
|
1918
|
+
applied: false,
|
|
1919
|
+
skipped: false,
|
|
1920
|
+
plan: fresh,
|
|
1921
|
+
changedPaths: [],
|
|
1922
|
+
conflicts: [
|
|
1923
|
+
{
|
|
1924
|
+
severity: "error",
|
|
1925
|
+
code: "stale-plan",
|
|
1926
|
+
message: "The content mutation plan changed after the migration plan was created."
|
|
1927
|
+
}
|
|
1928
|
+
]
|
|
1929
|
+
};
|
|
1930
|
+
const now = /* @__PURE__ */ new Date();
|
|
1931
|
+
let nextState = this.stateWith(fresh, actionable, state, "applying", now);
|
|
1932
|
+
await this.writeState(nextState);
|
|
1933
|
+
try {
|
|
1934
|
+
const result = await this.mutation.apply({
|
|
1935
|
+
...mutationPlan,
|
|
1936
|
+
id: fresh.mutationPlanId
|
|
1937
|
+
});
|
|
1938
|
+
if (!result.applied) {
|
|
1939
|
+
nextState = this.stateWith(
|
|
1940
|
+
fresh,
|
|
1941
|
+
actionable,
|
|
1942
|
+
nextState,
|
|
1943
|
+
"failed",
|
|
1944
|
+
/* @__PURE__ */ new Date(),
|
|
1945
|
+
{
|
|
1946
|
+
error: result.conflicts.map((conflict) => conflict.message).join(" "),
|
|
1947
|
+
journalPath: void 0
|
|
1948
|
+
}
|
|
1949
|
+
);
|
|
1950
|
+
await this.writeState(nextState);
|
|
1951
|
+
await this.appendHistory(
|
|
1952
|
+
fresh,
|
|
1953
|
+
actionable,
|
|
1954
|
+
"failure",
|
|
1955
|
+
"required",
|
|
1956
|
+
void 0
|
|
1957
|
+
);
|
|
1958
|
+
return {
|
|
1959
|
+
applied: false,
|
|
1960
|
+
skipped: false,
|
|
1961
|
+
plan: fresh,
|
|
1962
|
+
changedPaths: [],
|
|
1963
|
+
conflicts: result.conflicts.map(
|
|
1964
|
+
(issue) => mutationIssue(issue, "error")
|
|
1965
|
+
)
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
const journalPath = result.journalPath ? this.relativeToContent(result.journalPath) : void 0;
|
|
1969
|
+
const appliedState = this.stateWith(
|
|
1970
|
+
fresh,
|
|
1971
|
+
actionable,
|
|
1972
|
+
nextState,
|
|
1973
|
+
"applied",
|
|
1974
|
+
/* @__PURE__ */ new Date(),
|
|
1975
|
+
{ journalPath, rolledBackAt: void 0 }
|
|
1976
|
+
);
|
|
1977
|
+
nextState = this.supersede(actionable, appliedState, /* @__PURE__ */ new Date());
|
|
1978
|
+
await this.writeState(nextState);
|
|
1979
|
+
await this.appendHistory(
|
|
1980
|
+
fresh,
|
|
1981
|
+
actionable,
|
|
1982
|
+
"success",
|
|
1983
|
+
"none",
|
|
1984
|
+
journalPath
|
|
1985
|
+
);
|
|
1986
|
+
return {
|
|
1987
|
+
applied: true,
|
|
1988
|
+
skipped: false,
|
|
1989
|
+
plan: fresh,
|
|
1990
|
+
journalPath: result.journalPath,
|
|
1991
|
+
changedPaths: result.appliedPaths,
|
|
1992
|
+
conflicts: []
|
|
1993
|
+
};
|
|
1994
|
+
} catch (error) {
|
|
1995
|
+
const journal = await this.failedJournal(fresh);
|
|
1996
|
+
const message = sanitizeErrorMessage(error, this.contentDirectory);
|
|
1997
|
+
nextState = this.stateWith(
|
|
1998
|
+
fresh,
|
|
1999
|
+
actionable,
|
|
2000
|
+
nextState,
|
|
2001
|
+
journal ? "recovery-required" : "failed",
|
|
2002
|
+
/* @__PURE__ */ new Date(),
|
|
2003
|
+
{ journalPath: journal, error: message }
|
|
2004
|
+
);
|
|
2005
|
+
await this.writeState(nextState);
|
|
2006
|
+
await this.appendHistory(
|
|
2007
|
+
fresh,
|
|
2008
|
+
actionable,
|
|
2009
|
+
"failure",
|
|
2010
|
+
journal ? "required" : "none",
|
|
2011
|
+
journal
|
|
2012
|
+
);
|
|
2013
|
+
return {
|
|
2014
|
+
applied: false,
|
|
2015
|
+
skipped: false,
|
|
2016
|
+
plan: fresh,
|
|
2017
|
+
changedPaths: [],
|
|
2018
|
+
conflicts: [
|
|
2019
|
+
{
|
|
2020
|
+
severity: "error",
|
|
2021
|
+
code: journal ? "recovery-required" : "apply-failed",
|
|
2022
|
+
message
|
|
2023
|
+
}
|
|
2024
|
+
]
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
} finally {
|
|
2028
|
+
await release();
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
async status() {
|
|
2032
|
+
const state = await this.readState();
|
|
2033
|
+
const history = await this.readHistory();
|
|
2034
|
+
const reconciled = /* @__PURE__ */ new Map();
|
|
2035
|
+
for (const record of state.migrations) {
|
|
2036
|
+
const resolved = await this.reconcile(record);
|
|
2037
|
+
if (resolved !== record)
|
|
2038
|
+
reconciled.set(`${record.id}@${record.version}`, resolved);
|
|
2039
|
+
}
|
|
2040
|
+
let nextState = state;
|
|
2041
|
+
if (reconciled.size > 0) {
|
|
2042
|
+
try {
|
|
2043
|
+
const release = await this.acquireLock();
|
|
2044
|
+
try {
|
|
2045
|
+
const current = await this.readState();
|
|
2046
|
+
let merged = current;
|
|
2047
|
+
for (const record of current.migrations) {
|
|
2048
|
+
const resolved = reconciled.get(`${record.id}@${record.version}`);
|
|
2049
|
+
const original = state.migrations.find(
|
|
2050
|
+
(entry) => entry.id === record.id && entry.version === record.version
|
|
2051
|
+
);
|
|
2052
|
+
if (resolved && original && record.status === original.status && record.updatedAt === original.updatedAt)
|
|
2053
|
+
merged = upsertContentMigrationRecord(merged, resolved);
|
|
2054
|
+
}
|
|
2055
|
+
if (merged !== current) await this.writeState(merged);
|
|
2056
|
+
nextState = merged;
|
|
2057
|
+
} finally {
|
|
2058
|
+
await release();
|
|
2059
|
+
}
|
|
2060
|
+
} catch {
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
const entries = nextState.migrations.map((record) => {
|
|
2064
|
+
const definition = this.options.registry.versions(record.id).find((migration) => migration.version === record.version);
|
|
2065
|
+
const latest = this.options.registry.latest(record.id);
|
|
2066
|
+
return {
|
|
2067
|
+
id: record.id,
|
|
2068
|
+
version: record.version,
|
|
2069
|
+
latestVersion: latest?.version,
|
|
2070
|
+
status: record.status,
|
|
2071
|
+
definitionMatches: definition !== void 0 && definition.fingerprint === record.definitionFingerprint,
|
|
2072
|
+
planId: record.planId,
|
|
2073
|
+
appliedAt: record.appliedAt,
|
|
2074
|
+
updatedAt: record.updatedAt,
|
|
2075
|
+
journal: record.journalPath,
|
|
2076
|
+
error: record.error
|
|
2077
|
+
};
|
|
2078
|
+
}).toSorted(
|
|
2079
|
+
(left, right) => left.id.localeCompare(right.id) || left.version - right.version
|
|
2080
|
+
);
|
|
2081
|
+
return {
|
|
2082
|
+
schemaVersion: 1,
|
|
2083
|
+
updatedAt: nextState.updatedAt,
|
|
2084
|
+
entries,
|
|
2085
|
+
migrations: listContentMigrationEntries(
|
|
2086
|
+
this.options.registry.migrations,
|
|
2087
|
+
nextState
|
|
2088
|
+
),
|
|
2089
|
+
historyCount: history.runs.length
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
async recover(options = {}) {
|
|
2093
|
+
const release = await this.acquireLock();
|
|
2094
|
+
try {
|
|
2095
|
+
const state = await this.readState();
|
|
2096
|
+
const journalPath = options.journal !== void 0 ? this.absoluteFromContent(options.journal) : void 0;
|
|
2097
|
+
if (journalPath !== void 0 && !isWithin(this.contentDirectory, journalPath))
|
|
2098
|
+
throw new Error(
|
|
2099
|
+
`The migration journal "${options.journal}" is outside the content directory.`
|
|
2100
|
+
);
|
|
2101
|
+
const targets = state.migrations.filter((record) => {
|
|
2102
|
+
if (options.journal !== void 0)
|
|
2103
|
+
return record.journalPath === options.journal || record.mutationPlanId !== void 0 && journalPath !== void 0 && path5.basename(path5.dirname(journalPath)) === record.mutationPlanId;
|
|
2104
|
+
if (options.migrationId !== void 0)
|
|
2105
|
+
return record.id === options.migrationId && ["applying", "recovery-required", "failed"].includes(record.status);
|
|
2106
|
+
return ["applying", "recovery-required", "failed"].includes(
|
|
2107
|
+
record.status
|
|
2108
|
+
);
|
|
2109
|
+
});
|
|
2110
|
+
const journals = /* @__PURE__ */ new Map();
|
|
2111
|
+
for (const record of targets) {
|
|
2112
|
+
const resolved = record.journalPath !== void 0 ? this.absoluteFromContent(record.journalPath) : record.mutationPlanId !== void 0 ? path5.join(
|
|
2113
|
+
this.contentDirectory,
|
|
2114
|
+
".riebeckite",
|
|
2115
|
+
"mutations",
|
|
2116
|
+
record.mutationPlanId,
|
|
2117
|
+
"journal.json"
|
|
2118
|
+
) : void 0;
|
|
2119
|
+
if (!resolved || !await exists(resolved)) continue;
|
|
2120
|
+
const records = journals.get(resolved) ?? [];
|
|
2121
|
+
records.push(record);
|
|
2122
|
+
journals.set(resolved, records);
|
|
2123
|
+
}
|
|
2124
|
+
if (journalPath !== void 0) {
|
|
2125
|
+
if (!await exists(journalPath))
|
|
2126
|
+
return {
|
|
2127
|
+
recovered: false,
|
|
2128
|
+
applied: false,
|
|
2129
|
+
skipped: false,
|
|
2130
|
+
restoredPaths: [],
|
|
2131
|
+
conflicts: [
|
|
2132
|
+
{
|
|
2133
|
+
severity: "error",
|
|
2134
|
+
code: "journal-missing",
|
|
2135
|
+
message: "The mutation journal does not exist."
|
|
2136
|
+
}
|
|
2137
|
+
]
|
|
2138
|
+
};
|
|
2139
|
+
if (!journals.has(journalPath)) journals.set(journalPath, []);
|
|
2140
|
+
}
|
|
2141
|
+
if (journals.size === 0)
|
|
2142
|
+
return {
|
|
2143
|
+
recovered: false,
|
|
2144
|
+
applied: false,
|
|
2145
|
+
skipped: true,
|
|
2146
|
+
restoredPaths: [],
|
|
2147
|
+
conflicts: []
|
|
2148
|
+
};
|
|
2149
|
+
let nextState = state;
|
|
2150
|
+
let recovered = false;
|
|
2151
|
+
let applied = false;
|
|
2152
|
+
const restoredPaths = [];
|
|
2153
|
+
const conflicts = [];
|
|
2154
|
+
for (const [journal, records] of journals) {
|
|
2155
|
+
const relativeJournal = isWithin(this.contentDirectory, journal) ? this.relativeToContent(journal) : void 0;
|
|
2156
|
+
try {
|
|
2157
|
+
const result = await this.mutation.recover(journal);
|
|
2158
|
+
restoredPaths.push(...result.restoredPaths);
|
|
2159
|
+
if (result.conflicts.length > 0) {
|
|
2160
|
+
conflicts.push(
|
|
2161
|
+
...result.conflicts.map((issue) => mutationIssue(issue, "error"))
|
|
2162
|
+
);
|
|
2163
|
+
nextState = this.setStatuses(
|
|
2164
|
+
nextState,
|
|
2165
|
+
records,
|
|
2166
|
+
"recovery-required",
|
|
2167
|
+
/* @__PURE__ */ new Date(),
|
|
2168
|
+
relativeJournal
|
|
2169
|
+
);
|
|
2170
|
+
await this.appendHistoryForRecords(
|
|
2171
|
+
records,
|
|
2172
|
+
"failure",
|
|
2173
|
+
"required",
|
|
2174
|
+
relativeJournal
|
|
2175
|
+
);
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
if (result.recovered) {
|
|
2179
|
+
recovered = true;
|
|
2180
|
+
nextState = this.setStatuses(
|
|
2181
|
+
nextState,
|
|
2182
|
+
records,
|
|
2183
|
+
"rolled-back",
|
|
2184
|
+
/* @__PURE__ */ new Date(),
|
|
2185
|
+
relativeJournal
|
|
2186
|
+
);
|
|
2187
|
+
await this.appendHistoryForRecords(
|
|
2188
|
+
records,
|
|
2189
|
+
"rolled-back",
|
|
2190
|
+
"recovered",
|
|
2191
|
+
relativeJournal
|
|
2192
|
+
);
|
|
2193
|
+
continue;
|
|
2194
|
+
}
|
|
2195
|
+
const snapshot = await readJournalSnapshot(journal);
|
|
2196
|
+
if (snapshot?.state === "complete" && await journalMatchesApplied(this.contentDirectory, snapshot)) {
|
|
2197
|
+
applied = true;
|
|
2198
|
+
nextState = this.setStatuses(
|
|
2199
|
+
nextState,
|
|
2200
|
+
records,
|
|
2201
|
+
"applied",
|
|
2202
|
+
/* @__PURE__ */ new Date(),
|
|
2203
|
+
relativeJournal
|
|
2204
|
+
);
|
|
2205
|
+
await this.appendHistoryForRecords(
|
|
2206
|
+
records,
|
|
2207
|
+
"success",
|
|
2208
|
+
"recovered",
|
|
2209
|
+
relativeJournal
|
|
2210
|
+
);
|
|
2211
|
+
} else if (snapshot?.state === "rolled-back" || snapshot?.state === "recovered") {
|
|
2212
|
+
recovered = true;
|
|
2213
|
+
nextState = this.setStatuses(
|
|
2214
|
+
nextState,
|
|
2215
|
+
records,
|
|
2216
|
+
"rolled-back",
|
|
2217
|
+
/* @__PURE__ */ new Date(),
|
|
2218
|
+
relativeJournal
|
|
2219
|
+
);
|
|
2220
|
+
await this.appendHistoryForRecords(
|
|
2221
|
+
records,
|
|
2222
|
+
"rolled-back",
|
|
2223
|
+
"recovered",
|
|
2224
|
+
relativeJournal
|
|
2225
|
+
);
|
|
2226
|
+
} else {
|
|
2227
|
+
nextState = this.setStatuses(
|
|
2228
|
+
nextState,
|
|
2229
|
+
records,
|
|
2230
|
+
"recovery-required",
|
|
2231
|
+
/* @__PURE__ */ new Date(),
|
|
2232
|
+
relativeJournal
|
|
2233
|
+
);
|
|
2234
|
+
conflicts.push({
|
|
2235
|
+
severity: "error",
|
|
2236
|
+
code: "journal-not-recoverable",
|
|
2237
|
+
message: "The mutation journal is not in a recoverable state."
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
} catch (error) {
|
|
2241
|
+
conflicts.push({
|
|
2242
|
+
severity: "error",
|
|
2243
|
+
code: "recovery-failed",
|
|
2244
|
+
message: errorMessage(error)
|
|
2245
|
+
});
|
|
2246
|
+
nextState = this.setStatuses(
|
|
2247
|
+
nextState,
|
|
2248
|
+
records,
|
|
2249
|
+
"recovery-required",
|
|
2250
|
+
/* @__PURE__ */ new Date(),
|
|
2251
|
+
relativeJournal
|
|
2252
|
+
);
|
|
2253
|
+
await this.appendHistoryForRecords(
|
|
2254
|
+
records,
|
|
2255
|
+
"failure",
|
|
2256
|
+
"required",
|
|
2257
|
+
relativeJournal
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
await this.writeState(nextState);
|
|
2262
|
+
return {
|
|
2263
|
+
recovered,
|
|
2264
|
+
applied,
|
|
2265
|
+
skipped: false,
|
|
2266
|
+
restoredPaths,
|
|
2267
|
+
conflicts
|
|
2268
|
+
};
|
|
2269
|
+
} finally {
|
|
2270
|
+
await release();
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
async rollback(options) {
|
|
2274
|
+
const release = await this.acquireLock();
|
|
2275
|
+
try {
|
|
2276
|
+
const state = await this.readState();
|
|
2277
|
+
const candidates = state.migrations.filter(
|
|
2278
|
+
(record) => options.planId !== void 0 ? record.planId === options.planId : options.migrationId !== void 0 ? record.id === options.migrationId : false
|
|
2279
|
+
);
|
|
2280
|
+
const target = candidates.filter((record) => record.status === "applied").toSorted((left, right) => right.version - left.version)[0];
|
|
2281
|
+
if (!target) {
|
|
2282
|
+
if (candidates.some((record) => record.status === "rolled-back"))
|
|
2283
|
+
return {
|
|
2284
|
+
rolledBack: false,
|
|
2285
|
+
skipped: true,
|
|
2286
|
+
restoredPaths: [],
|
|
2287
|
+
conflicts: []
|
|
2288
|
+
};
|
|
2289
|
+
return {
|
|
2290
|
+
rolledBack: false,
|
|
2291
|
+
skipped: false,
|
|
2292
|
+
restoredPaths: [],
|
|
2293
|
+
conflicts: [
|
|
2294
|
+
{
|
|
2295
|
+
severity: "error",
|
|
2296
|
+
code: "rollback-not-applied",
|
|
2297
|
+
message: "The migration has no applied state to roll back."
|
|
2298
|
+
}
|
|
2299
|
+
]
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
if (!target.journalPath)
|
|
2303
|
+
return {
|
|
2304
|
+
rolledBack: false,
|
|
2305
|
+
skipped: false,
|
|
2306
|
+
restoredPaths: [],
|
|
2307
|
+
conflicts: [
|
|
2308
|
+
{
|
|
2309
|
+
severity: "error",
|
|
2310
|
+
code: "rollback-journal-missing",
|
|
2311
|
+
message: "The applied migration has no mutation journal."
|
|
2312
|
+
}
|
|
2313
|
+
]
|
|
2314
|
+
};
|
|
2315
|
+
const batch = state.migrations.filter(
|
|
2316
|
+
(record) => record.status === "applied" && record.planId !== void 0 && record.planId === target.planId
|
|
2317
|
+
);
|
|
2318
|
+
const journal = this.absoluteFromContent(target.journalPath);
|
|
2319
|
+
let result;
|
|
2320
|
+
try {
|
|
2321
|
+
result = await this.mutation.rollback(journal);
|
|
2322
|
+
} catch (error) {
|
|
2323
|
+
return {
|
|
2324
|
+
rolledBack: false,
|
|
2325
|
+
skipped: false,
|
|
2326
|
+
restoredPaths: [],
|
|
2327
|
+
conflicts: [
|
|
2328
|
+
{
|
|
2329
|
+
severity: "error",
|
|
2330
|
+
code: "rollback-failed",
|
|
2331
|
+
message: errorMessage(error)
|
|
2332
|
+
}
|
|
2333
|
+
]
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
if (result.conflicts.length > 0) {
|
|
2337
|
+
const nextState2 = this.setStatuses(
|
|
2338
|
+
state,
|
|
2339
|
+
batch,
|
|
2340
|
+
"recovery-required",
|
|
2341
|
+
/* @__PURE__ */ new Date(),
|
|
2342
|
+
target.journalPath,
|
|
2343
|
+
sanitizeErrorMessage(
|
|
2344
|
+
result.conflicts.map((issue) => issue.message).join(" "),
|
|
2345
|
+
this.contentDirectory
|
|
2346
|
+
)
|
|
2347
|
+
);
|
|
2348
|
+
await this.writeState(nextState2);
|
|
2349
|
+
await this.appendHistoryForRecords(
|
|
2350
|
+
batch,
|
|
2351
|
+
"failure",
|
|
2352
|
+
"required",
|
|
2353
|
+
target.journalPath
|
|
2354
|
+
);
|
|
2355
|
+
return {
|
|
2356
|
+
rolledBack: false,
|
|
2357
|
+
skipped: false,
|
|
2358
|
+
restoredPaths: result.restoredPaths,
|
|
2359
|
+
conflicts: result.conflicts.map(
|
|
2360
|
+
(issue) => mutationIssue(issue, "error")
|
|
2361
|
+
)
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
const nextState = this.setStatuses(
|
|
2365
|
+
state,
|
|
2366
|
+
batch,
|
|
2367
|
+
"rolled-back",
|
|
2368
|
+
/* @__PURE__ */ new Date(),
|
|
2369
|
+
target.journalPath,
|
|
2370
|
+
void 0,
|
|
2371
|
+
/* @__PURE__ */ new Date()
|
|
2372
|
+
);
|
|
2373
|
+
await this.writeState(nextState);
|
|
2374
|
+
await this.appendHistoryForRecords(
|
|
2375
|
+
batch,
|
|
2376
|
+
"rolled-back",
|
|
2377
|
+
"recovered",
|
|
2378
|
+
target.journalPath
|
|
2379
|
+
);
|
|
2380
|
+
return {
|
|
2381
|
+
rolledBack: result.rolledBack,
|
|
2382
|
+
skipped: result.skipped,
|
|
2383
|
+
restoredPaths: result.restoredPaths,
|
|
2384
|
+
conflicts: []
|
|
2385
|
+
};
|
|
2386
|
+
} finally {
|
|
2387
|
+
await release();
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
async reconcile(record) {
|
|
2391
|
+
if (record.status !== "applying") return record;
|
|
2392
|
+
const journal = record.journalPath ? this.absoluteFromContent(record.journalPath) : record.mutationPlanId ? path5.join(
|
|
2393
|
+
this.contentDirectory,
|
|
2394
|
+
".riebeckite",
|
|
2395
|
+
"mutations",
|
|
2396
|
+
record.mutationPlanId,
|
|
2397
|
+
"journal.json"
|
|
2398
|
+
) : void 0;
|
|
2399
|
+
const snapshot = journal ? await readJournalSnapshot(journal) : void 0;
|
|
2400
|
+
if (!snapshot)
|
|
2401
|
+
return {
|
|
2402
|
+
...record,
|
|
2403
|
+
status: "failed",
|
|
2404
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2405
|
+
...journal ? { journalPath: this.relativeToContent(journal) } : {},
|
|
2406
|
+
error: "The mutation journal is missing."
|
|
2407
|
+
};
|
|
2408
|
+
const relative = this.relativeToContent(journal);
|
|
2409
|
+
if (snapshot.state === "complete") {
|
|
2410
|
+
if (await journalMatchesApplied(this.contentDirectory, snapshot))
|
|
2411
|
+
return {
|
|
2412
|
+
...record,
|
|
2413
|
+
status: "applied",
|
|
2414
|
+
journalPath: relative,
|
|
2415
|
+
appliedAt: record.appliedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2416
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2417
|
+
};
|
|
2418
|
+
return {
|
|
2419
|
+
...record,
|
|
2420
|
+
status: "recovery-required",
|
|
2421
|
+
journalPath: relative,
|
|
2422
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2423
|
+
error: "Content changed after the mutation journal completed."
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
if (snapshot.state === "rolled-back" || snapshot.state === "recovered")
|
|
2427
|
+
return {
|
|
2428
|
+
...record,
|
|
2429
|
+
status: "rolled-back",
|
|
2430
|
+
journalPath: relative,
|
|
2431
|
+
rolledBackAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2432
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2433
|
+
};
|
|
2434
|
+
return {
|
|
2435
|
+
...record,
|
|
2436
|
+
status: "recovery-required",
|
|
2437
|
+
journalPath: relative,
|
|
2438
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
preflight(plan, state) {
|
|
2442
|
+
const conflicts = [];
|
|
2443
|
+
for (const migration of plan.migrations) {
|
|
2444
|
+
const records = state.migrations.filter(
|
|
2445
|
+
(record) => record.id === migration.id
|
|
2446
|
+
);
|
|
2447
|
+
const sameVersion = records.find(
|
|
2448
|
+
(record) => record.version === migration.version
|
|
2449
|
+
);
|
|
2450
|
+
if (sameVersion?.status === "applied" && sameVersion.definitionFingerprint !== migration.fingerprint)
|
|
2451
|
+
conflicts.push({
|
|
2452
|
+
severity: "error",
|
|
2453
|
+
code: "migration-definition-changed",
|
|
2454
|
+
message: `Migration "${migration.id}" changed without a version bump.`,
|
|
2455
|
+
path: void 0
|
|
2456
|
+
});
|
|
2457
|
+
const higher = records.find(
|
|
2458
|
+
(record) => record.version > migration.version && record.status === "applied"
|
|
2459
|
+
);
|
|
2460
|
+
if (higher)
|
|
2461
|
+
conflicts.push({
|
|
2462
|
+
severity: "error",
|
|
2463
|
+
code: "migration-version-regressed",
|
|
2464
|
+
message: `Migration "${migration.id}" version ${migration.version} is older than the applied version ${higher.version}.`
|
|
2465
|
+
});
|
|
2466
|
+
if (records.some(
|
|
2467
|
+
(record) => record.version === migration.version && record.status === "applying"
|
|
2468
|
+
))
|
|
2469
|
+
conflicts.push({
|
|
2470
|
+
severity: "error",
|
|
2471
|
+
code: "migration-in-progress",
|
|
2472
|
+
message: `Migration "${migration.id}" is still applying; recover it first.`
|
|
2473
|
+
});
|
|
2474
|
+
if (records.some(
|
|
2475
|
+
(record) => record.version === migration.version && record.status === "recovery-required"
|
|
2476
|
+
))
|
|
2477
|
+
conflicts.push({
|
|
2478
|
+
severity: "error",
|
|
2479
|
+
code: "recovery-required",
|
|
2480
|
+
message: `Migration "${migration.id}" requires recovery before it can be applied again.`
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
return conflicts;
|
|
2484
|
+
}
|
|
2485
|
+
stateWith(plan, migrations, state, status, now, extra = {}) {
|
|
2486
|
+
let next = state;
|
|
2487
|
+
for (const migration of migrations) {
|
|
2488
|
+
const prior = findContentMigrationRecord(
|
|
2489
|
+
state,
|
|
2490
|
+
migration.id,
|
|
2491
|
+
migration.version
|
|
2492
|
+
);
|
|
2493
|
+
next = upsertContentMigrationRecord(
|
|
2494
|
+
next,
|
|
2495
|
+
{
|
|
2496
|
+
id: migration.id,
|
|
2497
|
+
version: migration.version,
|
|
2498
|
+
definitionFingerprint: migration.fingerprint,
|
|
2499
|
+
status,
|
|
2500
|
+
planId: plan.id,
|
|
2501
|
+
planFingerprint: plan.planFingerprint,
|
|
2502
|
+
contentFingerprint: plan.baseContentFingerprint,
|
|
2503
|
+
mutationPlanId: plan.mutationPlanId,
|
|
2504
|
+
mutationPlanFingerprint: plan.mutationPlanFingerprint,
|
|
2505
|
+
journalPath: extra.journalPath ?? prior?.journalPath,
|
|
2506
|
+
changedPaths: plan.changes.map((change) => change.path),
|
|
2507
|
+
updatedAt: now.toISOString(),
|
|
2508
|
+
appliedAt: status === "applied" ? prior?.appliedAt ?? now.toISOString() : prior?.appliedAt,
|
|
2509
|
+
rolledBackAt: extra.rolledBackAt ?? prior?.rolledBackAt,
|
|
2510
|
+
error: extra.error
|
|
2511
|
+
},
|
|
2512
|
+
now
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
return next;
|
|
2516
|
+
}
|
|
2517
|
+
supersede(migrations, state, now) {
|
|
2518
|
+
let next = state;
|
|
2519
|
+
for (const migration of migrations) {
|
|
2520
|
+
for (const record of state.migrations) {
|
|
2521
|
+
if (record.id === migration.id && record.version < migration.version && record.status === "applied")
|
|
2522
|
+
next = upsertContentMigrationRecord(
|
|
2523
|
+
next,
|
|
2524
|
+
{
|
|
2525
|
+
...record,
|
|
2526
|
+
status: "superseded",
|
|
2527
|
+
updatedAt: now.toISOString()
|
|
2528
|
+
},
|
|
2529
|
+
now
|
|
2530
|
+
);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
return next;
|
|
2534
|
+
}
|
|
2535
|
+
setStatuses(state, records, status, now, journalPath, error, rolledBackAt) {
|
|
2536
|
+
const keys = new Set(
|
|
2537
|
+
records.map((record) => `${record.id}@${record.version}`)
|
|
2538
|
+
);
|
|
2539
|
+
let next = state;
|
|
2540
|
+
for (const record of state.migrations) {
|
|
2541
|
+
if (!keys.has(`${record.id}@${record.version}`)) continue;
|
|
2542
|
+
next = upsertContentMigrationRecord(
|
|
2543
|
+
next,
|
|
2544
|
+
{
|
|
2545
|
+
...record,
|
|
2546
|
+
status,
|
|
2547
|
+
journalPath: journalPath ?? record.journalPath,
|
|
2548
|
+
updatedAt: now.toISOString(),
|
|
2549
|
+
appliedAt: status === "applied" ? record.appliedAt ?? now.toISOString() : record.appliedAt,
|
|
2550
|
+
rolledBackAt: rolledBackAt?.toISOString() ?? record.rolledBackAt,
|
|
2551
|
+
error
|
|
2552
|
+
},
|
|
2553
|
+
now
|
|
2554
|
+
);
|
|
2555
|
+
}
|
|
2556
|
+
return next;
|
|
2557
|
+
}
|
|
2558
|
+
async appendHistory(plan, migrations, status, recoveryState, journal) {
|
|
2559
|
+
if (migrations.length === 0) return;
|
|
2560
|
+
const created = plan.changes.filter(
|
|
2561
|
+
(change) => change.beforeFingerprint === null
|
|
2562
|
+
).length;
|
|
2563
|
+
const deleted = plan.changes.filter(
|
|
2564
|
+
(change) => change.afterFingerprint === null
|
|
2565
|
+
).length;
|
|
2566
|
+
const updated = plan.changes.length - created - deleted;
|
|
2567
|
+
const history = await this.readHistory();
|
|
2568
|
+
let next = history;
|
|
2569
|
+
for (const migration of migrations) {
|
|
2570
|
+
const run = {
|
|
2571
|
+
id: migration.id,
|
|
2572
|
+
version: migration.version,
|
|
2573
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2574
|
+
planId: plan.id,
|
|
2575
|
+
mutationPlanId: plan.mutationPlanId,
|
|
2576
|
+
planFingerprint: plan.planFingerprint,
|
|
2577
|
+
contentFingerprint: plan.baseContentFingerprint,
|
|
2578
|
+
mutationPlanFingerprint: plan.mutationPlanFingerprint,
|
|
2579
|
+
status,
|
|
2580
|
+
created,
|
|
2581
|
+
updated,
|
|
2582
|
+
deleted,
|
|
2583
|
+
moved: plan.impact.moved,
|
|
2584
|
+
changedCount: plan.changes.length,
|
|
2585
|
+
recoveryState,
|
|
2586
|
+
journal
|
|
2587
|
+
};
|
|
2588
|
+
next = appendContentMigrationHistory(next, run);
|
|
2589
|
+
}
|
|
2590
|
+
await this.writeHistory(next);
|
|
2591
|
+
}
|
|
2592
|
+
async appendHistoryForRecords(records, status, recoveryState, journal) {
|
|
2593
|
+
if (records.length === 0) return;
|
|
2594
|
+
const history = await this.readHistory();
|
|
2595
|
+
let next = history;
|
|
2596
|
+
for (const record of records) {
|
|
2597
|
+
if (!record.planId) continue;
|
|
2598
|
+
const run = {
|
|
2599
|
+
id: record.id,
|
|
2600
|
+
version: record.version,
|
|
2601
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2602
|
+
planId: record.planId,
|
|
2603
|
+
mutationPlanId: record.mutationPlanId,
|
|
2604
|
+
planFingerprint: record.planFingerprint ?? "",
|
|
2605
|
+
contentFingerprint: record.contentFingerprint ?? "",
|
|
2606
|
+
mutationPlanFingerprint: record.mutationPlanFingerprint,
|
|
2607
|
+
status,
|
|
2608
|
+
created: 0,
|
|
2609
|
+
updated: 0,
|
|
2610
|
+
deleted: 0,
|
|
2611
|
+
moved: 0,
|
|
2612
|
+
changedCount: record.changedPaths.length,
|
|
2613
|
+
recoveryState,
|
|
2614
|
+
journal
|
|
2615
|
+
};
|
|
2616
|
+
next = appendContentMigrationHistory(next, run);
|
|
2617
|
+
}
|
|
2618
|
+
await this.writeHistory(next);
|
|
2619
|
+
}
|
|
2620
|
+
async failedJournal(plan) {
|
|
2621
|
+
const journal = path5.join(
|
|
2622
|
+
this.contentDirectory,
|
|
2623
|
+
".riebeckite",
|
|
2624
|
+
"mutations",
|
|
2625
|
+
plan.mutationPlanId,
|
|
2626
|
+
"journal.json"
|
|
2627
|
+
);
|
|
2628
|
+
try {
|
|
2629
|
+
await fs5.access(journal);
|
|
2630
|
+
return this.relativeToContent(journal);
|
|
2631
|
+
} catch {
|
|
2632
|
+
return void 0;
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
async currentContentFingerprint() {
|
|
2636
|
+
const entries = (await this.source.scan()).filter(
|
|
2637
|
+
(entry) => entry.path.toLowerCase().endsWith(".md")
|
|
2638
|
+
);
|
|
2639
|
+
return fingerprintContentFiles(
|
|
2640
|
+
await Promise.all(
|
|
2641
|
+
entries.map(async (entry) => ({
|
|
2642
|
+
path: entry.path,
|
|
2643
|
+
content: await this.source.read(entry)
|
|
2644
|
+
}))
|
|
2645
|
+
)
|
|
2646
|
+
);
|
|
2647
|
+
}
|
|
2648
|
+
async readState() {
|
|
2649
|
+
return readMigrationState(this.contentDirectory);
|
|
2650
|
+
}
|
|
2651
|
+
async writeState(state) {
|
|
2652
|
+
return writeMigrationState(this.contentDirectory, state);
|
|
2653
|
+
}
|
|
2654
|
+
async readHistory() {
|
|
2655
|
+
return readMigrationHistory(this.contentDirectory);
|
|
2656
|
+
}
|
|
2657
|
+
async writeHistory(history) {
|
|
2658
|
+
return writeMigrationHistory(this.contentDirectory, history);
|
|
2659
|
+
}
|
|
2660
|
+
relativeToContent(target) {
|
|
2661
|
+
return sanitizeContentMigrationPath(
|
|
2662
|
+
path5.relative(this.contentDirectory, target).split(path5.sep).join("/")
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
absoluteFromContent(target) {
|
|
2666
|
+
return path5.isAbsolute(target) ? target : path5.join(this.contentDirectory, target);
|
|
2667
|
+
}
|
|
2668
|
+
async acquireLock() {
|
|
2669
|
+
const lockPath = migrationLockPath(this.contentDirectory);
|
|
2670
|
+
await fs5.mkdir(path5.dirname(lockPath), { recursive: true });
|
|
2671
|
+
let handle;
|
|
2672
|
+
for (let attempt = 0; attempt < 3 && !handle; attempt += 1) {
|
|
2673
|
+
try {
|
|
2674
|
+
handle = await fs5.open(lockPath, "wx");
|
|
2675
|
+
} catch (error) {
|
|
2676
|
+
if (error.code !== "EEXIST") throw error;
|
|
2677
|
+
const stale = await staleLockContent(lockPath);
|
|
2678
|
+
if (stale === null)
|
|
2679
|
+
throw new Error(
|
|
2680
|
+
"Another migration is already applying or requires recovery."
|
|
2681
|
+
);
|
|
2682
|
+
const quarantine = `${lockPath}.stale-${randomUUID2()}`;
|
|
2683
|
+
try {
|
|
2684
|
+
await fs5.rename(lockPath, quarantine);
|
|
2685
|
+
} catch (renameError) {
|
|
2686
|
+
if (renameError.code === "ENOENT")
|
|
2687
|
+
continue;
|
|
2688
|
+
throw renameError;
|
|
2689
|
+
}
|
|
2690
|
+
const quarantined = await fs5.readFile(quarantine, "utf8");
|
|
2691
|
+
if (quarantined !== stale) {
|
|
2692
|
+
await fs5.writeFile(lockPath, quarantined, { flag: "wx" }).then(
|
|
2693
|
+
() => void 0,
|
|
2694
|
+
() => void 0
|
|
2695
|
+
);
|
|
2696
|
+
await fs5.rm(quarantine, { force: true });
|
|
2697
|
+
throw new Error(
|
|
2698
|
+
"Another migration is already applying or requires recovery."
|
|
2699
|
+
);
|
|
2700
|
+
}
|
|
2701
|
+
await fs5.rm(quarantine, { force: true });
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
if (!handle)
|
|
2705
|
+
throw new Error(
|
|
2706
|
+
"Another migration is already applying or requires recovery."
|
|
2707
|
+
);
|
|
2708
|
+
const token = randomUUID2();
|
|
2709
|
+
await handle.writeFile(
|
|
2710
|
+
JSON.stringify({
|
|
2711
|
+
pid: process.pid,
|
|
2712
|
+
token,
|
|
2713
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2714
|
+
})
|
|
2715
|
+
);
|
|
2716
|
+
return async () => {
|
|
2717
|
+
await handle.close();
|
|
2718
|
+
try {
|
|
2719
|
+
const current = JSON.parse(await fs5.readFile(lockPath, "utf8"));
|
|
2720
|
+
if (isRecord(current) && current.token === token)
|
|
2721
|
+
await fs5.rm(lockPath, { force: true });
|
|
2722
|
+
} catch {
|
|
2723
|
+
}
|
|
2724
|
+
};
|
|
2725
|
+
}
|
|
2726
|
+
async tryAnalyze(source) {
|
|
2727
|
+
try {
|
|
2728
|
+
return { analysis: await this.analyze(source), diagnostics: [] };
|
|
2729
|
+
} catch (error) {
|
|
2730
|
+
const path7 = errorPath(error);
|
|
2731
|
+
return {
|
|
2732
|
+
diagnostics: [
|
|
2733
|
+
{
|
|
2734
|
+
severity: "error",
|
|
2735
|
+
code: "manifest-build-failed",
|
|
2736
|
+
message: `The content manifest could not be built: ${errorMessage(error)}`,
|
|
2737
|
+
...path7 === void 0 ? {} : { path: path7 }
|
|
2738
|
+
}
|
|
2739
|
+
]
|
|
2740
|
+
};
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
async analyze(source) {
|
|
2744
|
+
const manager = new ContentManager(source, [], {
|
|
2745
|
+
config: this.options.config,
|
|
2746
|
+
plugins: this.options.plugins ? [...this.options.plugins] : void 0,
|
|
2747
|
+
commitBuildState: false,
|
|
2748
|
+
publishingBuildTime: this.options.buildTime
|
|
2749
|
+
});
|
|
2750
|
+
try {
|
|
2751
|
+
const manifest = await manager.getManifest();
|
|
2752
|
+
const locations = await manager.getContentLocations();
|
|
2753
|
+
const snapshot = manager.getCompletedBuildSnapshot();
|
|
2754
|
+
return {
|
|
2755
|
+
manifest,
|
|
2756
|
+
locations,
|
|
2757
|
+
publicationFingerprint: publicationFingerprint(
|
|
2758
|
+
publicationEntriesFromManifest(manifest),
|
|
2759
|
+
publicationRedirectsFromManifest(manifest),
|
|
2760
|
+
publicationOutputsFromManifest(manifest)
|
|
2761
|
+
),
|
|
2762
|
+
derivedViews: snapshot?.derivedViews.length ?? 0
|
|
2763
|
+
};
|
|
2764
|
+
} finally {
|
|
2765
|
+
await manager.dispose();
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
redirectOperations(batch, beforeManifest, afterManifest, field) {
|
|
2769
|
+
const operations = [];
|
|
2770
|
+
for (const file of batch.files) {
|
|
2771
|
+
if (file.originalPath === file.path) continue;
|
|
2772
|
+
const beforeEntry = findEntry(beforeManifest, file.originalPath);
|
|
2773
|
+
const afterEntry = findEntry(afterManifest, file.path);
|
|
2774
|
+
if (!beforeEntry || !afterEntry || beforeEntry.permalink === afterEntry.permalink)
|
|
2775
|
+
continue;
|
|
2776
|
+
const current = file.frontmatter[field];
|
|
2777
|
+
const redirects = typeof current === "string" ? [current] : Array.isArray(current) ? current.filter(
|
|
2778
|
+
(entry) => typeof entry === "string"
|
|
2779
|
+
) : [];
|
|
2780
|
+
if (redirects.includes(beforeEntry.permalink)) continue;
|
|
2781
|
+
operations.push({
|
|
2782
|
+
kind: "frontmatter",
|
|
2783
|
+
path: file.path,
|
|
2784
|
+
set: { [field]: [...redirects, beforeEntry.permalink] },
|
|
2785
|
+
remove: []
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
return operations;
|
|
2789
|
+
}
|
|
2790
|
+
verify(input) {
|
|
2791
|
+
const diagnostics = [];
|
|
2792
|
+
const beforeDiagnostics = new Set(
|
|
2793
|
+
input.before.manifest.diagnostics.map(diagnosticKey)
|
|
2794
|
+
);
|
|
2795
|
+
for (const diagnostic of input.after.manifest.diagnostics) {
|
|
2796
|
+
if (diagnostic.severity !== "error") continue;
|
|
2797
|
+
if (beforeDiagnostics.has(diagnosticKey(diagnostic))) continue;
|
|
2798
|
+
diagnostics.push({
|
|
2799
|
+
severity: "error",
|
|
2800
|
+
code: diagnostic.code,
|
|
2801
|
+
message: diagnostic.message,
|
|
2802
|
+
path: diagnostic.filePath
|
|
2803
|
+
});
|
|
2804
|
+
}
|
|
2805
|
+
const permalinks = /* @__PURE__ */ new Map();
|
|
2806
|
+
for (const entry of input.after.manifest.entries) {
|
|
2807
|
+
const existing = permalinks.get(entry.permalink);
|
|
2808
|
+
if (existing !== void 0) {
|
|
2809
|
+
diagnostics.push({
|
|
2810
|
+
severity: "error",
|
|
2811
|
+
code: "permalink-collision",
|
|
2812
|
+
message: `Content "${existing}" and "${entry.slug}" share the permalink "${entry.permalink}".`
|
|
2813
|
+
});
|
|
2814
|
+
} else permalinks.set(entry.permalink, entry.slug);
|
|
2815
|
+
}
|
|
2816
|
+
for (const file of input.batch.files) {
|
|
2817
|
+
const beforeEntry = findEntry(input.before.manifest, file.originalPath);
|
|
2818
|
+
const afterEntry = findEntry(input.after.manifest, file.path);
|
|
2819
|
+
if (!afterEntry) {
|
|
2820
|
+
diagnostics.push({
|
|
2821
|
+
severity: "error",
|
|
2822
|
+
code: "manifest-entry-missing",
|
|
2823
|
+
message: "The migrated content is missing from the future manifest.",
|
|
2824
|
+
path: file.path
|
|
2825
|
+
});
|
|
2826
|
+
continue;
|
|
2827
|
+
}
|
|
2828
|
+
if (!beforeEntry) continue;
|
|
2829
|
+
const changed = changedFrontmatterKeys(file);
|
|
2830
|
+
if (beforeEntry.contentId !== afterEntry.contentId && file.frontmatter.id === file.originalFrontmatter.id)
|
|
2831
|
+
diagnostics.push({
|
|
2832
|
+
severity: "error",
|
|
2833
|
+
code: "content-id-changed",
|
|
2834
|
+
message: "The migration changed the content ID without an explicit id rule.",
|
|
2835
|
+
path: file.path
|
|
2836
|
+
});
|
|
2837
|
+
if (beforeEntry.publishing.visibility !== afterEntry.publishing.visibility && !changed.has("visibility") && !changed.has("published") && !changed.has("publish") && !changed.has("private") && !changed.has("draft") && !changed.has("publishAt"))
|
|
2838
|
+
diagnostics.push({
|
|
2839
|
+
severity: "error",
|
|
2840
|
+
code: "visibility-changed",
|
|
2841
|
+
message: "The migration changed the publishing visibility without an explicit rule.",
|
|
2842
|
+
path: file.path
|
|
2843
|
+
});
|
|
2844
|
+
if (beforeEntry.permalink !== afterEntry.permalink && (beforeEntry.publishing.visibility === "public" || beforeEntry.publishing.visibility === "unlisted") && !input.after.manifest.redirects.has(beforeEntry.permalink))
|
|
2845
|
+
diagnostics.push({
|
|
2846
|
+
severity: "error",
|
|
2847
|
+
code: "permalink-redirect-missing",
|
|
2848
|
+
message: `The previous permalink "${beforeEntry.permalink}" has no redirect.`,
|
|
2849
|
+
path: file.path
|
|
2850
|
+
});
|
|
2851
|
+
}
|
|
2852
|
+
return { diagnostics };
|
|
2853
|
+
}
|
|
2854
|
+
};
|
|
2855
|
+
function createFutureContentSource(base, changes) {
|
|
2856
|
+
const map = /* @__PURE__ */ new Map();
|
|
2857
|
+
for (const change of changes) map.set(change.path, change.content);
|
|
2858
|
+
return {
|
|
2859
|
+
changes: map,
|
|
2860
|
+
async scan() {
|
|
2861
|
+
const entries = /* @__PURE__ */ new Map();
|
|
2862
|
+
for (const entry of await base.scan()) entries.set(entry.path, entry);
|
|
2863
|
+
for (const [changePath, content] of map) {
|
|
2864
|
+
if (content === null) entries.delete(changePath);
|
|
2865
|
+
else
|
|
2866
|
+
entries.set(changePath, {
|
|
2867
|
+
path: changePath,
|
|
2868
|
+
metadata: entries.get(changePath)?.metadata
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
return [...entries.values()].sort(
|
|
2872
|
+
(left, right) => left.path.localeCompare(right.path)
|
|
2873
|
+
);
|
|
2874
|
+
},
|
|
2875
|
+
async read(entry) {
|
|
2876
|
+
const content = map.get(entry.path);
|
|
2877
|
+
if (content === null)
|
|
2878
|
+
throw new Error(`The content path "${entry.path}" was deleted.`);
|
|
2879
|
+
if (content !== void 0) return content;
|
|
2880
|
+
return await base.read(entry);
|
|
2881
|
+
}
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
function fingerprintContentFiles(files) {
|
|
2885
|
+
return fingerprintFiles(files);
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
// src/path_helpers.ts
|
|
2889
|
+
import path6 from "node:path";
|
|
2890
|
+
import { normalizeContentPath as normalizeContentPath2 } from "@riebeckite/core";
|
|
2891
|
+
function isPathInsideDirectory(directory, file) {
|
|
2892
|
+
const root = normalizeForCompare(directory).replace(/\/+$/, "");
|
|
2893
|
+
if (!root) return false;
|
|
2894
|
+
const target = normalizeForCompare(file);
|
|
2895
|
+
return target === root || target.startsWith(`${root}/`);
|
|
2896
|
+
}
|
|
2897
|
+
function toLogicalPath(directory, file) {
|
|
2898
|
+
return normalizeContentPath2(
|
|
2899
|
+
path6.relative(normalizeContentPath2(directory), normalizeContentPath2(file))
|
|
2900
|
+
);
|
|
2901
|
+
}
|
|
2902
|
+
function normalizeForCompare(value) {
|
|
2903
|
+
const normalized = normalizeContentPath2(value);
|
|
2904
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
2905
|
+
}
|
|
205
2906
|
export {
|
|
2907
|
+
FileSystemContentMigration,
|
|
2908
|
+
FileSystemContentMutation,
|
|
206
2909
|
FileSystemContentSource,
|
|
207
2910
|
INTERNAL_CONTENT_IGNORE_PATTERNS,
|
|
2911
|
+
createContentSource,
|
|
2912
|
+
createFutureContentSource,
|
|
2913
|
+
fingerprintContentFiles,
|
|
208
2914
|
isIgnoredContentPath,
|
|
209
|
-
|
|
2915
|
+
isPathInsideDirectory,
|
|
2916
|
+
matchContentExcludePattern,
|
|
2917
|
+
toLogicalPath
|
|
210
2918
|
};
|