swarm-deploy 0.1.0 → 0.3.0

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +780 -50
  3. package/RELEASING.md +2 -2
  4. package/SECURITY.md +2 -3
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +182 -25
  7. package/dist/cli.js.map +1 -1
  8. package/dist/client.d.ts +32 -27
  9. package/dist/client.js +103 -54
  10. package/dist/client.js.map +1 -1
  11. package/dist/errors.d.ts +6 -0
  12. package/dist/errors.js +6 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/files.d.ts +13 -28
  15. package/dist/files.js +13 -78
  16. package/dist/files.js.map +1 -1
  17. package/dist/hooks-module.d.ts +9 -0
  18. package/dist/hooks-module.js +118 -0
  19. package/dist/hooks-module.js.map +1 -0
  20. package/dist/hooks.d.ts +69 -0
  21. package/dist/hooks.js +88 -0
  22. package/dist/hooks.js.map +1 -0
  23. package/dist/index.d.ts +8 -4
  24. package/dist/index.js +3 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/release.d.ts +26 -0
  27. package/dist/release.js +419 -0
  28. package/dist/release.js.map +1 -0
  29. package/dist/server.d.ts +97 -0
  30. package/dist/server.js +527 -53
  31. package/dist/server.js.map +1 -1
  32. package/dist/storage/commit-journal.d.ts +39 -2
  33. package/dist/storage/commit-journal.js +140 -2
  34. package/dist/storage/commit-journal.js.map +1 -1
  35. package/dist/storage/commit-store.d.ts +69 -8
  36. package/dist/storage/commit-store.js +374 -22
  37. package/dist/storage/commit-store.js.map +1 -1
  38. package/dist/storage/layout.js +7 -1
  39. package/dist/storage/layout.js.map +1 -1
  40. package/dist/storage/link-store.d.ts +88 -0
  41. package/dist/storage/link-store.js +448 -0
  42. package/dist/storage/link-store.js.map +1 -0
  43. package/dist/storage/recovery.d.ts +2 -1
  44. package/dist/storage/recovery.js +8 -1
  45. package/dist/storage/recovery.js.map +1 -1
  46. package/dist/storage/retention.d.ts +41 -21
  47. package/dist/storage/retention.js +162 -8
  48. package/dist/storage/retention.js.map +1 -1
  49. package/dist/storage/tar-session-store.d.ts +36 -5
  50. package/dist/storage/tar-session-store.js +283 -55
  51. package/dist/storage/tar-session-store.js.map +1 -1
  52. package/dist/storage/tree-fs.d.ts +44 -0
  53. package/dist/storage/tree-fs.js +580 -0
  54. package/dist/storage/tree-fs.js.map +1 -0
  55. package/dist/storage/tree-staging.d.ts +9 -0
  56. package/dist/storage/tree-staging.js +64 -0
  57. package/dist/storage/tree-staging.js.map +1 -0
  58. package/dist/storage/types.d.ts +15 -0
  59. package/dist/symlinks.d.ts +44 -0
  60. package/dist/symlinks.js +153 -0
  61. package/dist/symlinks.js.map +1 -0
  62. package/dist/tar-protocol/controls.d.ts +52 -0
  63. package/dist/tar-protocol/controls.js +160 -2
  64. package/dist/tar-protocol/controls.js.map +1 -1
  65. package/dist/tar-protocol/direct-wire.d.ts +8 -3
  66. package/dist/tar-protocol/direct-wire.js +14 -3
  67. package/dist/tar-protocol/direct-wire.js.map +1 -1
  68. package/dist/tar-protocol/manifest.d.ts +13 -1
  69. package/dist/tar-protocol/manifest.js +26 -1
  70. package/dist/tar-protocol/manifest.js.map +1 -1
  71. package/dist/tar-protocol/tree-extract.d.ts +37 -0
  72. package/dist/tar-protocol/tree-extract.js +240 -0
  73. package/dist/tar-protocol/tree-extract.js.map +1 -0
  74. package/dist/tar-protocol/tree-manifest.d.ts +45 -0
  75. package/dist/tar-protocol/tree-manifest.js +277 -0
  76. package/dist/tar-protocol/tree-manifest.js.map +1 -0
  77. package/dist/tar-protocol/tree-path.d.ts +15 -0
  78. package/dist/tar-protocol/tree-path.js +68 -0
  79. package/dist/tar-protocol/tree-path.js.map +1 -0
  80. package/dist/tar-protocol/tree.d.ts +68 -0
  81. package/dist/tar-protocol/tree.js +269 -0
  82. package/dist/tar-protocol/tree.js.map +1 -0
  83. package/dist/tar-protocol/ustar.d.ts +14 -0
  84. package/dist/tar-protocol/ustar.js +81 -1
  85. package/dist/tar-protocol/ustar.js.map +1 -1
  86. package/dist/types.d.ts +3 -0
  87. package/package.json +3 -1
@@ -13,6 +13,7 @@ const errors_js_1 = require("../errors.js");
13
13
  const files_js_1 = require("../files.js");
14
14
  const hash_js_1 = require("../tar-protocol/hash.js");
15
15
  const manifest_js_1 = require("../tar-protocol/manifest.js");
16
+ const tree_manifest_js_1 = require("../tar-protocol/tree-manifest.js");
16
17
  const validation_js_1 = require("../validation.js");
17
18
  const layout_js_1 = require("./layout.js");
18
19
  const atomic_file_js_1 = require("./atomic-file.js");
@@ -21,6 +22,7 @@ Object.defineProperty(exports, "CorruptJournalError", { enumerable: true, get: f
21
22
  Object.defineProperty(exports, "COMMIT_VERSION", { enumerable: true, get: function () { return commit_journal_js_1.COMMIT_VERSION; } });
22
23
  Object.defineProperty(exports, "MAX_COMMIT_METADATA_BYTES", { enumerable: true, get: function () { return commit_journal_js_1.MAX_COMMIT_METADATA_BYTES; } });
23
24
  const root_coordinator_js_1 = require("./root-coordinator.js");
25
+ const tree_fs_js_1 = require("./tree-fs.js");
24
26
  function storageError(message, cause = null) {
25
27
  return new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.PROTOCOL_INVALID, message, cause);
26
28
  }
@@ -60,19 +62,37 @@ function replacementsEqual(left, right) {
60
62
  sameHex32(left.transferId, right.transferId) &&
61
63
  left.historyName === right.historyName);
62
64
  }
65
+ function releasesEqual(left, right) {
66
+ if (left === undefined || right === undefined)
67
+ return left === right;
68
+ return left.series === right.series && left.version === right.version;
69
+ }
63
70
  function recordsEqual(left, right) {
64
71
  return (left.version === right.version &&
72
+ left.kind === right.kind &&
65
73
  left.name === right.name &&
66
74
  left.size === right.size &&
75
+ left.entryCount === right.entryCount &&
67
76
  sameHex32(left.sha256, right.sha256) &&
68
77
  left.committedAt === right.committedAt &&
69
78
  left.uploaderFingerprint === right.uploaderFingerprint &&
70
79
  sameHex32(left.transferId, right.transferId) &&
80
+ releasesEqual(left.release, right.release) &&
71
81
  replacementsEqual(left.replaces, right.replaces));
72
82
  }
73
83
  function sameContent(left, right) {
74
84
  return left.size === right.size && sameHex32(left.sha256, right.sha256);
75
85
  }
86
+ function snapshotRelease(release) {
87
+ if (release === null || release === undefined)
88
+ return undefined;
89
+ const snapshot = {
90
+ series: release.series,
91
+ ...(release.version === undefined ? {} : { version: release.version })
92
+ };
93
+ (0, commit_journal_js_1.assertCommitRelease)(snapshot);
94
+ return snapshot;
95
+ }
76
96
  /** The record the old sidecar becomes once its inode is only reachable as history. */
77
97
  function historyRecord(oldRecord, name) {
78
98
  return (0, commit_journal_js_1.assertCommitRecord)({ ...oldRecord, name });
@@ -97,9 +117,34 @@ function assertSession(session) {
97
117
  }
98
118
  (0, validation_js_1.assertSafeUint)(candidate.tarSize, 'session TAR size');
99
119
  (0, validation_js_1.assertFixed32)(candidate.tarDigest, 'session TAR digest');
120
+ if (candidate.kind === 'directory') {
121
+ (0, validation_js_1.assertSafeUint)(candidate.entryCount, 'session entry count');
122
+ (0, tree_manifest_js_1.assertTreeMetadataTransferId)(candidate.ownerKey, {
123
+ v: 1,
124
+ kind: 'directory',
125
+ name: candidate.name,
126
+ ...(candidate.sourceParent === undefined
127
+ ? {}
128
+ : { sourceParent: candidate.sourceParent }),
129
+ entryCount: candidate.entryCount,
130
+ payloadBytes: candidate.size,
131
+ treeSha256: toHex(candidate.digest),
132
+ tarSize: candidate.tarSize,
133
+ tarSha256: toHex(candidate.tarDigest),
134
+ transferId: candidate.id,
135
+ reset: false
136
+ });
137
+ return;
138
+ }
139
+ if (candidate.kind !== 'file' && candidate.kind !== undefined) {
140
+ throw storageError('Invalid session artifact kind');
141
+ }
100
142
  (0, manifest_js_1.assertMetadataTransferId)(candidate.ownerKey, {
101
143
  v: 1,
102
144
  name: candidate.name,
145
+ ...(candidate.sourceParent === undefined
146
+ ? {}
147
+ : { sourceParent: candidate.sourceParent }),
103
148
  fileSize: candidate.size,
104
149
  fileSha256: toHex(candidate.digest),
105
150
  tarSize: candidate.tarSize,
@@ -199,6 +244,16 @@ class CommitStore {
199
244
  _tarStagingPath(id) {
200
245
  return _path_1.default.join(this.layout.staging, `${id}.tar.part`);
201
246
  }
247
+ _treeStagingPath(id) {
248
+ return _path_1.default.join(this.layout.staging, `${id}.tree`);
249
+ }
250
+ _trashTreePath(id) {
251
+ return _path_1.default.join(this.layout.trash, `${id}.tree`);
252
+ }
253
+ /** The visible state of one top-level directory path, without following links. */
254
+ _rootTreeState(name) {
255
+ return (0, tree_fs_js_1.inspectTreePath)(this._finalPath(name), this.layout.root, this.storage);
256
+ }
202
257
  _sessionPath(id) {
203
258
  return _path_1.default.join(this.layout.sessions, `${id}.json`);
204
259
  }
@@ -384,8 +439,8 @@ class CommitStore {
384
439
  }, id);
385
440
  return id;
386
441
  }
387
- /** A replacement always starts a fresh attempt; leftovers belong to recovery. */
388
- async _writeReplacementJournal(journal) {
442
+ /** A replacement or directory commit always starts a fresh attempt; leftovers belong to recovery. */
443
+ async _writeFreshJournal(journal) {
389
444
  const id = journal.transferId;
390
445
  if (await this._safeFileOrAbsent(this._journalPath(id), this.layout.journals)) {
391
446
  let existing = null;
@@ -551,18 +606,25 @@ class CommitStore {
551
606
  const result = await digestExactFile(filePath, record.size, this.storage, syncFirst);
552
607
  return result !== null && (0, hash_js_1.digestMatches)(result.digest, b4a_1.default.from(record.sha256, 'hex'));
553
608
  }
554
- _recordFromSession(session) {
609
+ _recordFromSession(session, release) {
555
610
  assertSession(session);
556
- const record = {
557
- version: commit_journal_js_1.COMMIT_VERSION,
611
+ const base = {
558
612
  name: session.name,
559
613
  size: session.size,
560
614
  sha256: toHex(session.digest),
561
615
  committedAt: this.clock.now(),
562
616
  uploaderFingerprint: fingerprint(session.ownerKey),
563
- transferId: session.id
617
+ transferId: session.id,
618
+ ...(release === undefined ? {} : { release })
564
619
  };
565
- return (0, commit_journal_js_1.assertCommitRecord)(record);
620
+ return (0, commit_journal_js_1.assertCommitRecord)(session.kind === 'directory'
621
+ ? {
622
+ version: commit_journal_js_1.DIRECTORY_COMMIT_VERSION,
623
+ kind: 'directory',
624
+ entryCount: session.entryCount,
625
+ ...base
626
+ }
627
+ : { version: commit_journal_js_1.COMMIT_VERSION, ...base });
566
628
  }
567
629
  async inspect(name, offer, { replaceNames } = {}) {
568
630
  await this._assertLayout();
@@ -577,10 +639,18 @@ class CommitStore {
577
639
  (0, validation_js_1.assertSafeUint)(offer.size, 'commit offer size');
578
640
  (0, validation_js_1.assertFixed32)(offer.digest, 'commit offer digest');
579
641
  (0, validation_js_1.assertFixed32)(offer.transferId, 'commit offer transfer ID');
642
+ if (offer.kind !== undefined && offer.kind !== 'file' && offer.kind !== 'directory') {
643
+ throw storageError('Invalid commit offer kind');
644
+ }
645
+ if (offer.kind === 'directory')
646
+ (0, validation_js_1.assertSafeUint)(offer.entryCount, 'commit offer entry count');
647
+ const release = snapshotRelease(offer.release);
580
648
  const mutable = (0, files_js_1.validateReplaceNames)(replaceNames);
581
- return (0, root_coordinator_js_1.withNameLease)(this.layout.root, name, () => this._inspect(name, offer, mutable));
649
+ return (0, root_coordinator_js_1.withNameLease)(this.layout.root, name, () => this._inspect(name, offer, mutable, release));
582
650
  }
583
- async _inspect(name, offer, mutable) {
651
+ async _inspect(name, offer, mutable, release) {
652
+ if (offer.kind === 'directory')
653
+ return this._inspectDirectory(name, offer);
584
654
  const finalPath = this._finalPath(name);
585
655
  const final = await this._safeFileOrAbsent(finalPath, this.layout.root);
586
656
  if (!final)
@@ -592,6 +662,7 @@ class CommitStore {
592
662
  record.size === offer.size &&
593
663
  sameHex32(record.sha256, toHex(offer.digest)) &&
594
664
  sameHex32(record.transferId, id) &&
665
+ releasesEqual(record.release, release) &&
595
666
  (await this._matchesRecord(finalPath, this.layout.root, record))) {
596
667
  return { status: 'ALREADY_COMMITTED', record };
597
668
  }
@@ -601,12 +672,52 @@ class CommitStore {
601
672
  if (!current || !(0, commit_journal_js_1.identitiesEqual)(current.identity, (0, commit_journal_js_1.fileIdentity)(final))) {
602
673
  return { status: 'FILE_EXISTS' };
603
674
  }
604
- if (current.record.size === offer.size &&
675
+ const sameRelease = releasesEqual(current.record.release, release);
676
+ if (sameHex32(current.record.transferId, id) && !sameRelease) {
677
+ // One transfer ID names one authenticated upload; its release identity is immutable.
678
+ throw storageError('Release identity conflicts with the committed transfer');
679
+ }
680
+ if (sameRelease &&
681
+ current.record.size === offer.size &&
605
682
  sameHex32(current.record.sha256, toHex(offer.digest))) {
606
683
  return { status: 'ALREADY_COMMITTED', record: current.record };
607
684
  }
608
685
  return { status: 'REPLACEABLE', record: current.record };
609
686
  }
687
+ async _inspectDirectory(name, offer) {
688
+ if ((await this._rootTreeState(name)) === 'MISSING') {
689
+ const occupied = await this._safeFileOrAbsent(this._finalPath(name), this.layout.root);
690
+ return occupied ? { status: 'FILE_EXISTS' } : { status: 'AVAILABLE' };
691
+ }
692
+ const id = toHex(offer.transferId);
693
+ const record = await this._readRecordOrAbsent(this._recordPath(id));
694
+ if (!record ||
695
+ record.kind !== 'directory' ||
696
+ record.name !== name ||
697
+ record.size !== offer.size ||
698
+ record.entryCount !== offer.entryCount ||
699
+ !sameHex32(record.sha256, toHex(offer.digest)) ||
700
+ !sameHex32(record.transferId, id)) {
701
+ return { status: 'FILE_EXISTS' };
702
+ }
703
+ let digested;
704
+ try {
705
+ digested = await (0, tree_fs_js_1.digestTree)(this._finalPath(name), this.storage);
706
+ }
707
+ catch (error) {
708
+ if (error instanceof errors_js_1.SwarmDeployError &&
709
+ (error.code === errors_js_1.ERRORS.INVALID_FILENAME || error.code === errors_js_1.ERRORS.PROTOCOL_INVALID)) {
710
+ return { status: 'FILE_EXISTS' };
711
+ }
712
+ throw error;
713
+ }
714
+ if (digested.entryCount !== record.entryCount ||
715
+ digested.payloadBytes !== record.size ||
716
+ !(0, hash_js_1.digestMatches)(digested.treeSha256, b4a_1.default.from(record.sha256, 'hex'))) {
717
+ return { status: 'FILE_EXISTS' };
718
+ }
719
+ return { status: 'ALREADY_COMMITTED', record };
720
+ }
610
721
  /** The managed record whose verified content currently owns a visible name. */
611
722
  async _managedCurrent(name) {
612
723
  const finalPath = this._finalPath(name);
@@ -626,7 +737,9 @@ class CommitStore {
626
737
  if (session.name !== record.name ||
627
738
  session.size !== record.size ||
628
739
  toHex(session.digest) !== record.sha256 ||
629
- fingerprint(session.ownerKey) !== record.uploaderFingerprint) {
740
+ fingerprint(session.ownerKey) !== record.uploaderFingerprint ||
741
+ (record.kind === 'directory' &&
742
+ (session.kind !== 'directory' || session.entryCount !== record.entryCount))) {
630
743
  throw new commit_journal_js_1.CorruptJournalError(message);
631
744
  }
632
745
  }
@@ -650,9 +763,10 @@ class CommitStore {
650
763
  }
651
764
  return { sessionFile, staging };
652
765
  }
653
- commit(session, { retentionManager = null, signal = null, replaceNames } = {}) {
766
+ commit(session, { retentionManager = null, signal = null, replaceNames, release = null, deferPostCommitRetention = false } = {}) {
654
767
  try {
655
768
  assertSession(session);
769
+ const releaseSnapshot = snapshotRelease(release);
656
770
  if (retentionManager !== null &&
657
771
  (typeof retentionManager.run !== 'function' ||
658
772
  typeof retentionManager.afterCommit !== 'function' ||
@@ -664,19 +778,22 @@ class CommitStore {
664
778
  return Promise.reject(storageError('Invalid commit abort signal'));
665
779
  }
666
780
  const mutable = (0, files_js_1.validateReplaceNames)(replaceNames);
667
- return (0, root_coordinator_js_1.withNameLease)(this.layout.root, session.name, () => (0, root_coordinator_js_1.withRootLease)(this.layout.root, () => this._commit(session, retentionManager, signal, mutable)));
781
+ return (0, root_coordinator_js_1.withNameLease)(this.layout.root, session.name, () => (0, root_coordinator_js_1.withRootLease)(this.layout.root, () => this._commit(session, retentionManager, signal, mutable, releaseSnapshot, deferPostCommitRetention === true)));
668
782
  }
669
783
  catch (error) {
670
784
  return Promise.reject(error);
671
785
  }
672
786
  }
673
- async _commit(session, retentionManager, signal, mutable = new Set()) {
787
+ async _commit(session, retentionManager, signal, mutable = new Set(), release, deferPostCommitRetention = false) {
674
788
  assertNotAborted(signal);
675
789
  await this._assertLayout();
676
- const record = this._recordFromSession(session);
790
+ const record = this._recordFromSession(session, release);
677
791
  if ((0, files_js_1.isReservedHistoryName)(record.name)) {
678
792
  throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.INVALID_FILENAME, 'Reserved artifact name');
679
793
  }
794
+ if (session.kind === 'directory') {
795
+ return this._commitDirectory(record, retentionManager, signal, mutable, deferPostCommitRetention);
796
+ }
680
797
  const stagingPath = this._stagingPath(record.transferId);
681
798
  const finalPath = this._finalPath(record.name);
682
799
  const stagingDigest = await digestExactFile(stagingPath, record.size, this.storage, true);
@@ -692,7 +809,7 @@ class CommitStore {
692
809
  return plan.record;
693
810
  }
694
811
  if (plan.mode === 'replace') {
695
- return this._replace(record, stagingDigest.identity, plan, retentionManager, signal);
812
+ return this._replace(record, stagingDigest.identity, plan, retentionManager, signal, deferPostCommitRetention);
696
813
  }
697
814
  }
698
815
  if (retentionManager) {
@@ -717,8 +834,9 @@ class CommitStore {
717
834
  await this._removeFile(this._tarStagingPath(record.transferId), this.layout.staging);
718
835
  assertNotAborted(signal);
719
836
  await this._discardJournal(record.transferId, journalAttemptId);
720
- if (retentionManager)
837
+ if (retentionManager && !deferPostCommitRetention) {
721
838
  await retentionManager._afterCommitUnlocked();
839
+ }
722
840
  return record;
723
841
  }
724
842
  catch (err) {
@@ -758,12 +876,122 @@ class CommitStore {
758
876
  throw commitError('Unable to create final artifact', err);
759
877
  }
760
878
  }
879
+ /**
880
+ * Publishes one verified staging tree by rename. Directory artifacts are
881
+ * create-only: a configured mutable name, an occupied destination, and a
882
+ * kind change are all existing-name conflicts.
883
+ */
884
+ async _commitDirectory(record, retentionManager, signal, mutable, deferPostCommitRetention = false) {
885
+ if (mutable.has(record.name)) {
886
+ throw existsError('Directory artifacts cannot be replaced');
887
+ }
888
+ const treePath = this._treeStagingPath(record.transferId);
889
+ const finalPath = this._finalPath(record.name);
890
+ if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) !== 'DIRECTORY') {
891
+ throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.CHECKSUM_MISMATCH, 'Verified staging tree is missing');
892
+ }
893
+ const digested = await (0, tree_fs_js_1.digestTree)(treePath, this.storage);
894
+ if (digested.entryCount !== record.entryCount ||
895
+ digested.payloadBytes !== record.size ||
896
+ !(0, hash_js_1.digestMatches)(digested.treeSha256, b4a_1.default.from(record.sha256, 'hex'))) {
897
+ throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.CHECKSUM_MISMATCH, 'Staging tree checksum mismatch');
898
+ }
899
+ const stagingTreeIdentity = (0, commit_journal_js_1.fileIdentity)(await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => this.storage.lstat(treePath)));
900
+ for (const existing of await this._scanRecords()) {
901
+ if (existing.name === record.name)
902
+ throw existsError('Destination already exists');
903
+ }
904
+ if ((await this._rootTreeState(record.name)) !== 'MISSING') {
905
+ throw existsError('Destination already exists');
906
+ }
907
+ if (retentionManager) {
908
+ await retentionManager._runUnlocked({ incomingBytes: record.size, trigger: 'commit' });
909
+ }
910
+ assertNotAborted(signal);
911
+ const attempt = attemptId();
912
+ let journal = {
913
+ version: commit_journal_js_1.DIRECTORY_JOURNAL_VERSION,
914
+ intent: 'create-directory',
915
+ state: 'committing',
916
+ phase: 'journaled',
917
+ transferId: record.transferId,
918
+ attemptId: attempt,
919
+ name: record.name,
920
+ stagingTreeName: `${record.transferId}.tree`,
921
+ stagingTreeIdentity,
922
+ record
923
+ };
924
+ await this._writeFreshJournal(journal);
925
+ let linearized = false;
926
+ try {
927
+ assertNotAborted(signal);
928
+ if ((await this._rootTreeState(record.name)) !== 'MISSING') {
929
+ throw existsError('Destination already exists');
930
+ }
931
+ await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => this.storage.rename(treePath, finalPath)));
932
+ await this._syncRoot();
933
+ journal = await this._setDirectoryPhase(journal, 'renamed');
934
+ assertNotAborted(signal);
935
+ await this._writeRecord(record);
936
+ linearized = true;
937
+ journal = await this._setDirectoryPhase(journal, 'sidecar');
938
+ await this._removeFile(this._sessionPath(record.transferId), this.layout.sessions);
939
+ await this._removeFile(this._tarStagingPath(record.transferId), this.layout.staging);
940
+ await this._discardJournal(record.transferId, attempt);
941
+ if (retentionManager && !deferPostCommitRetention) {
942
+ await retentionManager._afterCommitUnlocked();
943
+ }
944
+ return record;
945
+ }
946
+ catch (err) {
947
+ if (linearized) {
948
+ this._reportCleanupPending(record, err);
949
+ return record;
950
+ }
951
+ try {
952
+ await this._rollbackDirectory(journal);
953
+ }
954
+ catch (cleanupError) {
955
+ throw new AggregateError([err, cleanupError], 'Unable to roll back directory commit');
956
+ }
957
+ throw err;
958
+ }
959
+ }
960
+ async _setDirectoryPhase(journal, phase) {
961
+ const next = { ...journal, phase };
962
+ await (0, atomic_file_js_1.writeAtomic)(this._journalPath(journal.transferId), b4a_1.default.from(JSON.stringify((0, commit_journal_js_1.serializeJournal)(next))), this.storage);
963
+ return next;
964
+ }
965
+ /** Returns a proven renamed tree to staging; a foreign destination is preserved. */
966
+ async _rollbackDirectory(journal) {
967
+ const finalPath = this._finalPath(journal.name);
968
+ const treePath = this._treeStagingPath(journal.transferId);
969
+ if ((await this._rootTreeState(journal.name)) !== 'DIRECTORY') {
970
+ if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) === 'DIRECTORY') {
971
+ await this._discardJournal(journal.transferId, journal.attemptId);
972
+ }
973
+ return;
974
+ }
975
+ const visible = (0, commit_journal_js_1.fileIdentity)(await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => this.storage.lstat(finalPath)));
976
+ if (!(0, commit_journal_js_1.identitiesEqual)(visible, journal.stagingTreeIdentity)) {
977
+ throw storageError('Foreign artifact at directory destination');
978
+ }
979
+ if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) !== 'MISSING') {
980
+ throw storageError('Directory staging tree already exists');
981
+ }
982
+ await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => this.storage.rename(finalPath, treePath)));
983
+ await this._syncRoot();
984
+ await this._discardJournal(journal.transferId, journal.attemptId);
985
+ }
761
986
  /**
762
987
  * Classifies a configured mutable name. Only an exact managed current record
763
988
  * whose verified content owns the visible path may be replaced; every other
764
989
  * occupied path is an existing-name conflict.
765
990
  */
766
991
  async _planReplacement(record) {
992
+ if ((await this._rootTreeState(record.name)) === 'DIRECTORY') {
993
+ throw existsError('Destination already exists');
994
+ }
767
995
  const state = await this._rootPathState(record.name);
768
996
  if (!state.present)
769
997
  return { mode: 'create' };
@@ -773,8 +1001,14 @@ class CommitStore {
773
1001
  if (!current || !(0, commit_journal_js_1.identitiesEqual)(current.identity, (0, commit_journal_js_1.fileIdentity)(state.stat))) {
774
1002
  throw existsError('Destination already exists');
775
1003
  }
776
- if (sameContent(current.record, record))
1004
+ const sameRelease = releasesEqual(current.record.release, record.release);
1005
+ if (current.record.transferId === record.transferId && !sameRelease) {
1006
+ // One transfer ID names one authenticated upload; its release identity is immutable.
1007
+ throw storageError('Release identity conflicts with the committed transfer');
1008
+ }
1009
+ if (sameContent(current.record, record) && sameRelease) {
777
1010
  return { mode: 'idempotent', record: current.record };
1011
+ }
778
1012
  return { mode: 'replace', oldRecord: current.record, finalIdentity: current.identity };
779
1013
  }
780
1014
  /**
@@ -787,7 +1021,9 @@ class CommitStore {
787
1021
  const existing = await this._readRecordOrAbsent(this._recordPath(record.transferId));
788
1022
  if (!existing)
789
1023
  return null;
790
- if (existing.name !== name || !sameContent(existing, record)) {
1024
+ if (existing.name !== name ||
1025
+ !sameContent(existing, record) ||
1026
+ !releasesEqual(existing.release, record.release)) {
791
1027
  throw storageError('Conflicting commit record');
792
1028
  }
793
1029
  const state = await this._rootPathState(name);
@@ -801,7 +1037,7 @@ class CommitStore {
801
1037
  }
802
1038
  return { name, identity: digested.identity };
803
1039
  }
804
- async _replace(record, stagingIdentity, plan, retentionManager, signal) {
1040
+ async _replace(record, stagingIdentity, plan, retentionManager, signal, deferPostCommitRetention = false) {
805
1041
  const { oldRecord, finalIdentity } = plan;
806
1042
  const history = (0, files_js_1.historyName)(oldRecord.transferId);
807
1043
  const newRecord = (0, commit_journal_js_1.assertCommitRecord)({
@@ -846,7 +1082,7 @@ class CommitStore {
846
1082
  record: newRecord
847
1083
  };
848
1084
  const publicationPath = this._publicationPath(journal.publicationName);
849
- await this._writeReplacementJournal(journal);
1085
+ await this._writeFreshJournal(journal);
850
1086
  let linearized = false;
851
1087
  try {
852
1088
  assertNotAborted(signal);
@@ -881,8 +1117,9 @@ class CommitStore {
881
1117
  await this._removeFile(stagingPath, this.layout.staging);
882
1118
  await this._removeFile(this._tarStagingPath(newRecord.transferId), this.layout.staging);
883
1119
  await this._discardJournal(newRecord.transferId, attempt);
884
- if (retentionManager)
1120
+ if (retentionManager && !deferPostCommitRetention) {
885
1121
  await retentionManager._afterCommitUnlocked();
1122
+ }
886
1123
  return newRecord;
887
1124
  }
888
1125
  catch (err) {
@@ -1068,6 +1305,59 @@ class CommitStore {
1068
1305
  await this._removeFile(this._tarStagingPath(id), this.layout.staging);
1069
1306
  }
1070
1307
  /** Proves the verified session and staging an aborted attempt must retain. */
1308
+ /**
1309
+ * Converges one directory attempt. The durable sidecar is the linearization
1310
+ * point: before it a proven renamed tree returns to staging, after it the
1311
+ * published tree is kept and only residue is removed. A destination that
1312
+ * cannot be proven is preserved and fails closed.
1313
+ */
1314
+ async _recoverDirectoryJournal(id, journal, sessionStore, isAuthorized = null) {
1315
+ const { record, name, attemptId: attempt } = journal;
1316
+ const sidecar = await this._readRecordOrAbsent(this._recordPath(id));
1317
+ if (sidecar && !recordsEqual(sidecar, record)) {
1318
+ throw storageError('Commit sidecar does not match journal');
1319
+ }
1320
+ const state = await this._rootTreeState(name);
1321
+ if (state === 'UNMANAGED')
1322
+ throw storageError('Foreign artifact at directory destination');
1323
+ if (state === 'DIRECTORY') {
1324
+ const digested = await (0, tree_fs_js_1.digestTree)(this._finalPath(name), this.storage);
1325
+ if (digested.entryCount !== record.entryCount ||
1326
+ digested.payloadBytes !== record.size ||
1327
+ !(0, hash_js_1.digestMatches)(digested.treeSha256, b4a_1.default.from(record.sha256, 'hex'))) {
1328
+ throw storageError('Published directory does not match its journal');
1329
+ }
1330
+ if (!sidecar && isAuthorized) {
1331
+ if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
1332
+ throw storageError('Session store cannot validate recovery state');
1333
+ }
1334
+ const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
1335
+ if (!isAuthorized(session.ownerKey)) {
1336
+ await this._rollbackDirectory(journal);
1337
+ return { status: 'RESUMABLE', record };
1338
+ }
1339
+ }
1340
+ if (!sidecar)
1341
+ await this._writeRecord(record);
1342
+ await this._removeFile(this._sessionPath(id), this.layout.sessions);
1343
+ await this._removeFile(this._tarStagingPath(id), this.layout.staging);
1344
+ await this._discardJournal(id, attempt);
1345
+ return { status: 'COMMITTED', record };
1346
+ }
1347
+ if (sidecar)
1348
+ await this._removeFile(this._recordPath(id), this.layout.commits);
1349
+ if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
1350
+ throw storageError('Session store cannot validate recovery state');
1351
+ }
1352
+ const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
1353
+ this._assertSessionMatchesRecord(session, record, 'Directory journal does not match verified session');
1354
+ const treePath = this._treeStagingPath(id);
1355
+ if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) !== 'DIRECTORY') {
1356
+ throw new commit_journal_js_1.CorruptJournalError('Directory journal does not own verified staging');
1357
+ }
1358
+ await this._discardJournal(id, attempt);
1359
+ return { status: 'RESUMABLE', record };
1360
+ }
1071
1361
  async _assertReplacementRetryable(id, journal, sessionStore) {
1072
1362
  if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
1073
1363
  throw storageError('Session store cannot validate commit journal');
@@ -1089,6 +1379,9 @@ class CommitStore {
1089
1379
  const journal = await this._readJournal(id);
1090
1380
  if (!journal)
1091
1381
  return { status: 'MISSING' };
1382
+ if ((0, commit_journal_js_1.isDirectoryJournal)(journal)) {
1383
+ return (0, root_coordinator_js_1.withNameLease)(this.layout.root, journal.name, () => this._recoverDirectoryJournal(id, journal, sessionStore, isAuthorized));
1384
+ }
1092
1385
  if ((0, commit_journal_js_1.isReplacementJournal)(journal)) {
1093
1386
  return (0, root_coordinator_js_1.withNameLease)(this.layout.root, journal.name, () => this._recoverReplacementJournal(id, journal, sessionStore, isAuthorized));
1094
1387
  }
@@ -1296,11 +1589,61 @@ class CommitStore {
1296
1589
  }
1297
1590
  throw storageError('Commit record changed before deletion');
1298
1591
  }
1592
+ /**
1593
+ * Removes proven private trash trees left by an interrupted deletion. Only a
1594
+ * `<64-hex>.tree` entry is ours; anything else in the directory is left alone.
1595
+ */
1596
+ async sweepTrash() {
1597
+ await this._assertLayout();
1598
+ const names = await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.trash, this.storage, () => this.storage.readdir(this.layout.trash));
1599
+ let removed = 0;
1600
+ for (const name of names.sort()) {
1601
+ if (!/^[0-9a-f]{64}\.tree$/.test(name))
1602
+ continue;
1603
+ if (await (0, tree_fs_js_1.removeTree)(_path_1.default.join(this.layout.trash, name), this.layout.trash, this.storage)) {
1604
+ removed++;
1605
+ }
1606
+ }
1607
+ return removed;
1608
+ }
1609
+ /**
1610
+ * Removes a managed directory by renaming it into the private trash first, so
1611
+ * the visible name disappears atomically, and only then removing the sidecar
1612
+ * and recursively deleting the trash tree.
1613
+ */
1614
+ async _removeManagedTree(record) {
1615
+ const finalPath = this._finalPath(record.name);
1616
+ const trashPath = this._trashTreePath(record.transferId);
1617
+ const state = await this._rootTreeState(record.name);
1618
+ if (state === 'MISSING')
1619
+ return { removed: false, preservedPath: false };
1620
+ if (state === 'UNMANAGED')
1621
+ return { removed: false, preservedPath: true };
1622
+ if ((await (0, tree_fs_js_1.inspectTreePath)(trashPath, this.layout.trash, this.storage)) !== 'MISSING') {
1623
+ await (0, tree_fs_js_1.removeTree)(trashPath, this.layout.trash, this.storage);
1624
+ }
1625
+ await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.trash, this.storage, () => this.storage.rename(finalPath, trashPath)));
1626
+ await this._syncRoot();
1627
+ await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
1628
+ await (0, tree_fs_js_1.removeTree)(trashPath, this.layout.trash, this.storage);
1629
+ return { removed: true, preservedPath: false };
1630
+ }
1299
1631
  async delete(record) {
1300
1632
  await this._assertLayout();
1301
1633
  (0, commit_journal_js_1.assertCommitRecord)(record);
1302
1634
  if (!(await this._storedRecordFor(record)))
1303
1635
  return false;
1636
+ if (record.kind === 'directory') {
1637
+ const removed = await this._removeManagedTree(record);
1638
+ if (removed.preservedPath) {
1639
+ await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
1640
+ return true;
1641
+ }
1642
+ if (!removed.removed) {
1643
+ await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
1644
+ }
1645
+ return true;
1646
+ }
1304
1647
  const finalPath = this._finalPath(record.name);
1305
1648
  const final = await this._safeFileOrAbsent(finalPath, this.layout.root);
1306
1649
  if (final)
@@ -1313,6 +1656,15 @@ class CommitStore {
1313
1656
  (0, commit_journal_js_1.assertCommitRecord)(record);
1314
1657
  if (!(await this._storedRecordFor(record)))
1315
1658
  return false;
1659
+ if (record.kind === 'directory') {
1660
+ if (preservePath) {
1661
+ await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
1662
+ return { purged: true, preservedPath: true };
1663
+ }
1664
+ const removed = await this._removeManagedTree(record);
1665
+ await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
1666
+ return { purged: true, preservedPath: removed.preservedPath };
1667
+ }
1316
1668
  let final;
1317
1669
  if (preservePath) {
1318
1670
  let preservedPath = false;