@riebeckite/node 0.0.34 → 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/dist/index.js CHANGED
@@ -117,8 +117,8 @@ var FileSystemContentSource = class {
117
117
  return entries;
118
118
  }
119
119
  resolveFilePath(logicalPath) {
120
- const normalizedPath = normalizeLogicalPath(logicalPath);
121
- const filePath = path.resolve(this.contentDirectory, normalizedPath);
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 normalizedPath = normalizeContentPath(logicalPath);
175
- if (normalizedPath.length === 0 || normalizedPath.startsWith("/") || path.isAbsolute(normalizedPath) || path.win32.isAbsolute(normalizedPath) || normalizedPath.split("/").some((segment) => segment === "..")) {
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 normalizedPath;
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
- matchContentExcludePattern
2915
+ isPathInsideDirectory,
2916
+ matchContentExcludePattern,
2917
+ toLogicalPath
210
2918
  };