@narumitw/pi-sync 0.50.3 → 0.51.1

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 (218) hide show
  1. package/README.md +4 -1
  2. package/dist/chunks/{chunk-6LP3MCR2.ts → chunk-2JZFHNLM.ts} +3 -5
  3. package/dist/chunks/chunk-2JZFHNLM.ts.map +7 -0
  4. package/dist/chunks/{chunk-LCRI3ABW.ts → chunk-2RZSQWBH.ts} +8 -19
  5. package/dist/chunks/chunk-2RZSQWBH.ts.map +7 -0
  6. package/dist/chunks/{chunk-IJ6FJFA5.ts → chunk-4AIHXRKE.ts} +11 -17
  7. package/dist/chunks/chunk-4AIHXRKE.ts.map +7 -0
  8. package/dist/chunks/{chunk-C7XY5NF5.ts → chunk-4MPIH46N.ts} +29 -51
  9. package/dist/chunks/chunk-4MPIH46N.ts.map +7 -0
  10. package/dist/chunks/{chunk-5B3FXBLE.ts → chunk-6546TS54.ts} +16 -20
  11. package/dist/chunks/chunk-6546TS54.ts.map +7 -0
  12. package/dist/chunks/{chunk-Q2TUM5X6.ts → chunk-6NT3YFGY.ts} +2 -2
  13. package/dist/chunks/chunk-6NT3YFGY.ts.map +7 -0
  14. package/dist/chunks/{chunk-2B2ELUF5.ts → chunk-7D5SF7P3.ts} +1 -1
  15. package/dist/chunks/chunk-7D5SF7P3.ts.map +7 -0
  16. package/dist/chunks/{chunk-YDWUZVPN.ts → chunk-7KHKWL3Q.ts} +28 -47
  17. package/dist/chunks/chunk-7KHKWL3Q.ts.map +7 -0
  18. package/dist/chunks/{chunk-ZI5MXMEX.ts → chunk-BVVP33U5.ts} +2 -4
  19. package/dist/chunks/chunk-BVVP33U5.ts.map +7 -0
  20. package/dist/chunks/{chunk-SZWBGPID.ts → chunk-DM4X4FIA.ts} +8 -13
  21. package/dist/chunks/chunk-DM4X4FIA.ts.map +7 -0
  22. package/dist/chunks/{chunk-SL64RLXR.ts → chunk-DZUSTGQV.ts} +8 -16
  23. package/dist/chunks/chunk-DZUSTGQV.ts.map +7 -0
  24. package/dist/chunks/{chunk-YHEMENKV.ts → chunk-E42GXD6D.ts} +13 -19
  25. package/dist/chunks/chunk-E42GXD6D.ts.map +7 -0
  26. package/dist/chunks/{chunk-Z4DQF6L3.ts → chunk-ECYCNZOE.ts} +1 -1
  27. package/dist/chunks/chunk-ECYCNZOE.ts.map +7 -0
  28. package/dist/chunks/{chunk-OEX4ABY6.ts → chunk-EEJPYTWE.ts} +11 -21
  29. package/dist/chunks/chunk-EEJPYTWE.ts.map +7 -0
  30. package/dist/chunks/{chunk-CPEHGXJV.ts → chunk-G4RUXRWZ.ts} +3 -5
  31. package/dist/chunks/chunk-G4RUXRWZ.ts.map +7 -0
  32. package/dist/chunks/{chunk-T2B5IU74.ts → chunk-HGJH56KR.ts} +4 -4
  33. package/dist/chunks/chunk-HGJH56KR.ts.map +7 -0
  34. package/dist/chunks/{chunk-JAAKLL6M.ts → chunk-IVXEDPAD.ts} +10 -34
  35. package/dist/chunks/chunk-IVXEDPAD.ts.map +7 -0
  36. package/dist/chunks/chunk-K3RIEL6J.ts +20 -0
  37. package/dist/chunks/chunk-K3RIEL6J.ts.map +7 -0
  38. package/dist/chunks/{chunk-LTBWGSBV.ts → chunk-MSSW6F3R.ts} +1 -1
  39. package/dist/chunks/chunk-MSSW6F3R.ts.map +7 -0
  40. package/dist/chunks/{chunk-T3HH3UEJ.ts → chunk-OVDTF5CH.ts} +4 -10
  41. package/dist/chunks/chunk-OVDTF5CH.ts.map +7 -0
  42. package/dist/chunks/{chunk-NJFUIVYL.ts → chunk-PWMWOJP4.ts} +1 -1
  43. package/dist/chunks/chunk-PWMWOJP4.ts.map +7 -0
  44. package/dist/chunks/{chunk-HUDQATCL.ts → chunk-Q32CQWPM.ts} +6 -8
  45. package/dist/chunks/chunk-Q32CQWPM.ts.map +7 -0
  46. package/dist/chunks/{chunk-ETTRJDNP.ts → chunk-QLSN7AGX.ts} +6 -13
  47. package/dist/chunks/chunk-QLSN7AGX.ts.map +7 -0
  48. package/dist/chunks/{chunk-THMF4BVL.ts → chunk-SWUXA3MH.ts} +1 -1
  49. package/dist/chunks/chunk-SWUXA3MH.ts.map +7 -0
  50. package/dist/chunks/{chunk-TEN2ZBJV.ts → chunk-T2SJD3N7.ts} +2 -4
  51. package/dist/chunks/chunk-T2SJD3N7.ts.map +7 -0
  52. package/dist/chunks/{chunk-AUSDLD6F.ts → chunk-T56RNRZJ.ts} +10 -26
  53. package/dist/chunks/chunk-T56RNRZJ.ts.map +7 -0
  54. package/dist/chunks/{chunk-DHVFS4QK.ts → chunk-UA7RXOIT.ts} +37 -90
  55. package/dist/chunks/chunk-UA7RXOIT.ts.map +7 -0
  56. package/dist/chunks/{chunk-D5X27LCA.ts → chunk-UETBKTDI.ts} +1 -1
  57. package/dist/chunks/chunk-UETBKTDI.ts.map +7 -0
  58. package/dist/chunks/{chunk-CHH26D4F.ts → chunk-YFADF5ZQ.ts} +7 -6
  59. package/dist/chunks/chunk-YFADF5ZQ.ts.map +7 -0
  60. package/dist/chunks/{chunk-CCSN3F47.ts → chunk-YPXGU5OR.ts} +4 -4
  61. package/dist/chunks/chunk-YPXGU5OR.ts.map +7 -0
  62. package/dist/chunks/{file-selection-FTTKAOYX.ts → file-selection-RAVHRYFG.ts} +14 -26
  63. package/dist/chunks/file-selection-RAVHRYFG.ts.map +7 -0
  64. package/dist/chunks/{git-backend-GMVS4PD4.ts → git-backend-IAANDL3K.ts} +41 -104
  65. package/dist/chunks/git-backend-IAANDL3K.ts.map +7 -0
  66. package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +23 -0
  67. package/dist/chunks/{manager-ui-IPAOAWZA.ts → manager-ui-763NEKJY.ts} +124 -251
  68. package/dist/chunks/manager-ui-763NEKJY.ts.map +7 -0
  69. package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +22 -0
  70. package/dist/chunks/{s3-backend-66O4FGBH.ts → s3-backend-XGXUOPUN.ts} +23 -54
  71. package/dist/chunks/s3-backend-XGXUOPUN.ts.map +7 -0
  72. package/dist/chunks/{setup-switch-IS2PEZ3J.ts → setup-switch-5G2VNJHL.ts} +9 -9
  73. package/dist/chunks/{setup-wizard-JUJZYNNM.ts → setup-wizard-YIEWGX32.ts} +12 -12
  74. package/dist/chunks/setup-wizard-YIEWGX32.ts.map +7 -0
  75. package/dist/chunks/{snapshot-HKEWCVK6.ts → snapshot-JL5DFSXI.ts} +5 -5
  76. package/dist/chunks/sync-inspection-P3ENJLNF.ts +20 -0
  77. package/dist/chunks/{sync-operations-LBX3GKCM.ts → sync-operations-AR76EB6C.ts} +85 -201
  78. package/dist/chunks/sync-operations-AR76EB6C.ts.map +7 -0
  79. package/dist/chunks/{sync-state-Q2FTXTW6.ts → sync-state-VHJUZ4YG.ts} +6 -6
  80. package/dist/chunks/{webdav-backend-ECIKD7SO.ts → webdav-backend-3EGUONAT.ts} +20 -51
  81. package/dist/chunks/webdav-backend-3EGUONAT.ts.map +7 -0
  82. package/dist/index.ts +49 -74
  83. package/dist/index.ts.map +3 -3
  84. package/docs/settings.md +9 -0
  85. package/package.json +61 -62
  86. package/src/backends/backend-factory.ts +15 -15
  87. package/src/backends/backend-identity.ts +31 -31
  88. package/src/backends/backend-types.ts +42 -42
  89. package/src/backends/git/git-backend.ts +714 -788
  90. package/src/backends/git/git-config.ts +104 -111
  91. package/src/backends/git/git-runner.ts +230 -247
  92. package/src/backends/git/git-storage.ts +228 -245
  93. package/src/backends/s3/s3-backend.ts +408 -441
  94. package/src/backends/s3/s3-client.ts +288 -333
  95. package/src/backends/sync-backend.ts +65 -65
  96. package/src/backends/webdav/webdav-backend.ts +542 -589
  97. package/src/backends/webdav/webdav-client.ts +292 -316
  98. package/src/backends/webdav/webdav-config.ts +57 -68
  99. package/src/commands/command-execution.ts +270 -289
  100. package/src/commands/command-handler.ts +160 -163
  101. package/src/commands/command-types.ts +13 -13
  102. package/src/commands/command.ts +190 -196
  103. package/src/commands/setup-completions.ts +1 -1
  104. package/src/paths.ts +47 -47
  105. package/src/settings/config-errors.ts +4 -5
  106. package/src/settings/config-file.ts +473 -506
  107. package/src/settings/config.ts +171 -174
  108. package/src/settings/settings-management.ts +191 -216
  109. package/src/settings/settings-store.ts +63 -77
  110. package/src/settings/settings-types.ts +78 -75
  111. package/src/settings/settings-validation.ts +328 -364
  112. package/src/snapshot/session-paths.ts +41 -55
  113. package/src/snapshot/snapshot-apply.ts +197 -229
  114. package/src/snapshot/snapshot-codec.ts +29 -31
  115. package/src/snapshot/snapshot-paths.ts +4 -6
  116. package/src/snapshot/snapshot-transaction.ts +161 -170
  117. package/src/snapshot/snapshot-types.ts +24 -24
  118. package/src/snapshot/snapshot.ts +310 -343
  119. package/src/state/json-file.ts +11 -11
  120. package/src/state/lock.ts +226 -242
  121. package/src/state/lockfile-fs.ts +20 -20
  122. package/src/state/operation-availability.ts +36 -41
  123. package/src/state/state-directory.ts +171 -173
  124. package/src/state/state-types.ts +15 -15
  125. package/src/state/sync-state-store.ts +23 -23
  126. package/src/sync/automatic-sync.ts +75 -80
  127. package/src/sync/remote-snapshot.ts +52 -60
  128. package/src/sync/setup-switch.ts +99 -114
  129. package/src/sync/signals.ts +3 -5
  130. package/src/sync/startup-check.ts +97 -111
  131. package/src/sync/sync-decision.ts +76 -80
  132. package/src/sync/sync-error-guidance.ts +41 -48
  133. package/src/sync/sync-errors.ts +10 -10
  134. package/src/sync/sync-inspection.ts +29 -33
  135. package/src/sync/sync-loaders.ts +28 -36
  136. package/src/sync/sync-mutations.ts +510 -622
  137. package/src/sync/sync-operations.ts +7 -7
  138. package/src/sync/sync-policy.ts +296 -326
  139. package/src/sync/sync-queries.ts +175 -194
  140. package/src/sync/sync-state.ts +74 -100
  141. package/src/sync-extension.ts +89 -101
  142. package/src/sync.ts +26 -26
  143. package/src/types.ts +37 -37
  144. package/src/ui/automatic-sync-summary.ts +1 -3
  145. package/src/ui/cancellable-operation.ts +89 -101
  146. package/src/ui/file-selection.ts +250 -274
  147. package/src/ui/manager-attention.ts +51 -63
  148. package/src/ui/manager-recovery.ts +48 -57
  149. package/src/ui/manager-result-dispatcher.ts +62 -76
  150. package/src/ui/manager-state.ts +200 -232
  151. package/src/ui/manager-ui.ts +264 -362
  152. package/src/ui/remote-selection-ui.ts +547 -588
  153. package/src/ui/secret-input.ts +197 -205
  154. package/src/ui/settings-ui.ts +175 -172
  155. package/src/ui/setup/git-ui.ts +232 -261
  156. package/src/ui/setup/s3-credentials-ui.ts +58 -65
  157. package/src/ui/setup/s3-ui.ts +171 -195
  158. package/src/ui/setup/setup-actions.ts +91 -101
  159. package/src/ui/setup/setup-location-ui.ts +121 -137
  160. package/src/ui/setup/setup-name-ui.ts +27 -27
  161. package/src/ui/setup/setup-prompts.ts +65 -65
  162. package/src/ui/setup/setup-review.ts +58 -59
  163. package/src/ui/setup/setup-switcher.ts +156 -160
  164. package/src/ui/setup/setup-wizard.ts +28 -28
  165. package/src/ui/setup/text-input.ts +54 -58
  166. package/src/ui/setup/webdav-ui.ts +246 -293
  167. package/src/ui/storage-connections-ui.ts +317 -369
  168. package/src/ui/sync-attention.ts +148 -164
  169. package/src/ui/sync-format.ts +110 -121
  170. package/src/ui/sync-resolution-ui.ts +147 -153
  171. package/src/ui/sync-setups-ui.ts +218 -238
  172. package/src/ui/sync-status.ts +17 -0
  173. package/src/ui/terminal-text.ts +2 -2
  174. package/dist/chunks/chunk-2B2ELUF5.ts.map +0 -7
  175. package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
  176. package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
  177. package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
  178. package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
  179. package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
  180. package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
  181. package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
  182. package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
  183. package/dist/chunks/chunk-DHVFS4QK.ts.map +0 -7
  184. package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
  185. package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
  186. package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
  187. package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
  188. package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
  189. package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
  190. package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
  191. package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
  192. package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
  193. package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
  194. package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
  195. package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
  196. package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
  197. package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
  198. package/dist/chunks/chunk-THMF4BVL.ts.map +0 -7
  199. package/dist/chunks/chunk-YDWUZVPN.ts.map +0 -7
  200. package/dist/chunks/chunk-YHEMENKV.ts.map +0 -7
  201. package/dist/chunks/chunk-Z4DQF6L3.ts.map +0 -7
  202. package/dist/chunks/chunk-ZI5MXMEX.ts.map +0 -7
  203. package/dist/chunks/file-selection-FTTKAOYX.ts.map +0 -7
  204. package/dist/chunks/git-backend-GMVS4PD4.ts.map +0 -7
  205. package/dist/chunks/manager-result-dispatcher-ASKGOSW5.ts +0 -22
  206. package/dist/chunks/manager-ui-IPAOAWZA.ts.map +0 -7
  207. package/dist/chunks/remote-selection-ui-QDNR7WEI.ts +0 -21
  208. package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
  209. package/dist/chunks/setup-wizard-JUJZYNNM.ts.map +0 -7
  210. package/dist/chunks/sync-inspection-CST2KL65.ts +0 -20
  211. package/dist/chunks/sync-operations-LBX3GKCM.ts.map +0 -7
  212. package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +0 -7
  213. /package/dist/chunks/{manager-result-dispatcher-ASKGOSW5.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
  214. /package/dist/chunks/{remote-selection-ui-QDNR7WEI.ts.map → remote-selection-ui-TZTJF32Y.ts.map} +0 -0
  215. /package/dist/chunks/{setup-switch-IS2PEZ3J.ts.map → setup-switch-5G2VNJHL.ts.map} +0 -0
  216. /package/dist/chunks/{snapshot-HKEWCVK6.ts.map → snapshot-JL5DFSXI.ts.map} +0 -0
  217. /package/dist/chunks/{sync-inspection-CST2KL65.ts.map → sync-inspection-P3ENJLNF.ts.map} +0 -0
  218. /package/dist/chunks/{sync-state-Q2FTXTW6.ts.map → sync-state-VHJUZ4YG.ts.map} +0 -0
@@ -5,15 +5,15 @@ import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
5
5
  import { portableSnapshotSelection } from "../../sync/sync-policy.js";
6
6
  import type { LatestPointer, RemoteObject, ResolvedWebDavBackend } from "../backend-types.js";
7
7
  import {
8
- type BackendDiagnostic,
9
- type ExpectedRemoteHead,
10
- type PublishSnapshotOptions,
11
- type PublishSnapshotResult,
12
- type RemoteHead,
13
- type RemoteHistoryEntry,
14
- type SyncBackend,
15
- SyncBackendConflictError,
16
- SyncBackendPublicationOutcomeUnknownError,
8
+ type BackendDiagnostic,
9
+ type ExpectedRemoteHead,
10
+ type PublishSnapshotOptions,
11
+ type PublishSnapshotResult,
12
+ type RemoteHead,
13
+ type RemoteHistoryEntry,
14
+ type SyncBackend,
15
+ SyncBackendConflictError,
16
+ SyncBackendPublicationOutcomeUnknownError,
17
17
  } from "../sync-backend.js";
18
18
  import { WebDavClient, WebDavHttpError, WebDavPreconditionError } from "./webdav-client.js";
19
19
 
@@ -21,674 +21,627 @@ const VERSION = 1;
21
21
  const POST_COMMIT_TIMEOUT_MS = 30_000;
22
22
 
23
23
  export class WebDavSyncBackend implements SyncBackend {
24
- readonly identity: string;
25
- readonly destination: string;
26
- private conditionalVerified = false;
27
- private readonly checksums = new Map<string, string>();
28
- private capabilityCheck?: Promise<void>;
29
-
30
- constructor(
31
- private readonly config: ResolvedWebDavBackend,
32
- private readonly postCommitTimeoutMs = POST_COMMIT_TIMEOUT_MS,
33
- ) {
34
- assertSafeDestination(config);
35
- this.identity = webDavBackendIdentity(config);
36
- this.destination = webDavStorageLocation(config);
37
- }
38
-
39
- get capability() {
40
- return this.conditionalVerified
41
- ? ("atomic-conditional" as const)
42
- : ("conditional-required" as const);
43
- }
44
-
45
- sameRevision(left: string, right: string) {
46
- return left === right;
47
- }
48
-
49
- async readHead(signal?: AbortSignal): Promise<RemoteHead | undefined> {
50
- const object = await new WebDavClient(this.config, signal).getJson<LatestPointer>(
51
- latestPath(this.config),
52
- );
53
- throwIfAborted(signal);
54
- if (object.missing) return undefined;
55
- const pointer = requirePointer(object.value, this.config.destination.namespace);
56
- this.registerChecksum(pointer.snapshot, pointer.sha256);
57
- return remoteHead(pointer, this.identity, object.etag);
58
- }
59
-
60
- async readSnapshot(reference: string, signal?: AbortSignal): Promise<Snapshot> {
61
- requireSnapshotReference(reference);
62
- const expectedChecksum =
63
- this.checksums.get(reference) ?? (await this.resolveChecksum(reference, signal));
64
- const object = await new WebDavClient(this.config, signal).getBuffer(
65
- snapshotPath(this.config, reference),
66
- );
67
- throwIfAborted(signal);
68
- if (!object.value) throw new Error(`Snapshot not found: ${reference}`);
69
- if (!expectedChecksum || sha256(object.value) !== expectedChecksum) {
70
- throw new Error("Remote snapshot checksum mismatch.");
71
- }
72
- const snapshot = await decodeSnapshot(object.value, { signal });
73
- if (snapshot.id !== reference || snapshot.profile !== this.config.destination.namespace) {
74
- throw new Error("Remote snapshot identity mismatch.");
75
- }
76
- validateSnapshotBundle(snapshot);
77
- return snapshot;
78
- }
79
-
80
- async publishSnapshot(
81
- snapshot: Snapshot,
82
- expected: ExpectedRemoteHead,
83
- options: PublishSnapshotOptions = {},
84
- ): Promise<PublishSnapshotResult> {
85
- throwIfAborted(options.signal);
86
- assertSnapshotIdentity(snapshot, this.config.destination.namespace);
87
- await this.ensureAtomicConditions(options.signal);
88
- throwIfAborted(options.signal);
89
- const client = new WebDavClient(this.config, options.signal);
90
- await client.ensureCollection(snapshotsPath(this.config));
91
- const encoded = await encodeSnapshot(snapshot);
92
- throwIfAborted(options.signal);
93
- const pointer = pointerFor(this.config, snapshot, sha256(encoded));
94
- const stagedPath = snapshotPath(this.config, snapshot.id);
95
- try {
96
- await client.putBuffer(stagedPath, encoded, "application/gzip", { ifAbsent: true });
97
- } catch (error) {
98
- if (!(error instanceof WebDavPreconditionError)) throw error;
99
- const existing = await client.getBuffer(stagedPath);
100
- if (!existing.value || sha256(existing.value) !== pointer.sha256) {
101
- throw new SyncBackendConflictError(
102
- `Immutable snapshot id already exists with different content: ${snapshot.id}`,
103
- );
104
- }
105
- }
106
- const currentObject = await client.getJson<LatestPointer>(latestPath(this.config));
107
- throwIfAborted(options.signal);
108
- const current = currentObject.missing
109
- ? undefined
110
- : remoteHead(
111
- requirePointer(currentObject.value, this.config.destination.namespace),
112
- this.identity,
113
- currentObject.etag,
114
- );
115
- if (!matchesExpected(current, expected)) {
116
- throw new SyncBackendConflictError(
117
- "Remote changed while pushing. Run /sync pull first, then retry.",
118
- {
119
- currentHead: current,
120
- },
121
- );
122
- }
123
- const condition = publicationCondition(currentObject, expected, this.identity);
124
- throwIfAborted(options.signal);
125
- options.onCommit?.();
126
- const commitClient = new WebDavClient(
127
- this.config,
128
- AbortSignal.timeout(this.postCommitTimeoutMs),
129
- this.postCommitTimeoutMs,
130
- );
131
- try {
132
- await commitClient.putJson(latestPath(this.config), pointer, condition);
133
- } catch (error) {
134
- if (error instanceof WebDavPreconditionError) {
135
- const latest = await this.readHeadAfterCommit(commitClient).catch(() => undefined);
136
- throw new SyncBackendConflictError("Remote changed while publishing the WebDAV pointer.", {
137
- currentHead: latest,
138
- });
139
- }
140
- throw new SyncBackendPublicationOutcomeUnknownError(
141
- `Remote publication outcome is unknown: ${errorMessage(error)}`,
142
- { cause: error },
143
- );
144
- }
145
- let verifiedObject: RemoteObject<LatestPointer>;
146
- try {
147
- verifiedObject = await commitClient.getJson<LatestPointer>(latestPath(this.config));
148
- } catch (error) {
149
- throw new SyncBackendPublicationOutcomeUnknownError(
150
- `Remote snapshot may be active, but publication could not be verified: ${errorMessage(error)}`,
151
- { cause: error },
152
- );
153
- }
154
- let verified: LatestPointer;
155
- try {
156
- verified = requirePointer(verifiedObject.value, this.config.destination.namespace);
157
- } catch (error) {
158
- throw new SyncBackendPublicationOutcomeUnknownError(
159
- `Remote snapshot may be active, but publication verification was malformed: ${errorMessage(error)}`,
160
- { cause: error },
161
- );
162
- }
163
- if (!samePointer(pointer, verified)) {
164
- throw new SyncBackendConflictError(
165
- "Remote latest changed immediately after push. Run /sync status before continuing.",
166
- {
167
- phase: "after-commit",
168
- currentHead: remoteHead(verified, this.identity, verifiedObject.etag),
169
- candidateMayHaveBeenActive: true,
170
- },
171
- );
172
- }
173
- if (!strongEtag(verifiedObject.etag)) {
174
- throw new SyncBackendPublicationOutcomeUnknownError(
175
- "Remote snapshot is active, but the WebDAV server returned no strong ETag.",
176
- );
177
- }
178
- this.registerChecksum(verified.snapshot, verified.sha256);
179
- const head = remoteHead(verified, this.identity, verifiedObject.etag);
180
- const warning = await this.updateHistorySafely(commitClient, pointer);
181
- return { head, warnings: warning ? [warning] : [] };
182
- }
183
-
184
- async listHistory(signal?: AbortSignal): Promise<RemoteHistoryEntry[]> {
185
- const object = await new WebDavClient(this.config, signal).getJson<{
186
- version: number;
187
- snapshots: LatestPointer[];
188
- }>(historyPath(this.config));
189
- throwIfAborted(signal);
190
- if (object.missing) return [];
191
- const pointers = requireHistory(object.value, this.config.destination.namespace);
192
- for (const pointer of pointers) this.registerChecksum(pointer.snapshot, pointer.sha256);
193
- return pointers.map(remoteHistoryEntry);
194
- }
195
-
196
- async diagnose(signal?: AbortSignal): Promise<BackendDiagnostic[]> {
197
- const diagnostics: BackendDiagnostic[] = [
198
- {
199
- key: "webdav-url",
200
- level: "info",
201
- message: `webdav URL/TLS/auth: configured (${webDavStorageLocation(this.config)})`,
202
- },
203
- {
204
- key: "webdav-scope",
205
- level: "info",
206
- message:
207
- "webdav check scope: temporary write/delete probe; repair the active snapshot's history entry if missing. No local content is applied.",
208
- },
209
- ];
210
- try {
211
- await this.runCapabilityProbe(signal);
212
- diagnostics.push(
213
- { key: "webdav-collection", level: "info", message: "webdav collection read/write: ok" },
214
- {
215
- key: "webdav-conditional",
216
- level: "info",
217
- message: "webdav conditional publication: atomic-conditional (verified)",
218
- },
219
- { key: "webdav-cleanup", level: "info", message: "webdav probe cleanup: ok" },
220
- );
221
- } catch (error) {
222
- this.capabilityCheck = undefined;
223
- this.conditionalVerified = false;
224
- if (error instanceof Error && error.name === "AbortError") throw error;
225
- diagnostics.push({
226
- key: "webdav-probe",
227
- level: "error",
228
- message: `webdav publication is read-only until diagnostics pass: ${syncErrorGuidance(error)}`,
229
- });
230
- return diagnostics;
231
- }
232
- try {
233
- diagnostics.push({
234
- key: "webdav-history",
235
- level: "info",
236
- message: await this.reconcileActiveHistory(signal),
237
- });
238
- } catch (error) {
239
- if (error instanceof Error && error.name === "AbortError") throw error;
240
- diagnostics.push({
241
- key: "webdav-history",
242
- level: "error",
243
- message: `webdav history repair failed: ${syncErrorGuidance(error)}`,
244
- });
245
- }
246
- return diagnostics;
247
- }
248
-
249
- private ensureAtomicConditions(signal?: AbortSignal) {
250
- if (this.capabilityCheck) return this.capabilityCheck;
251
- this.conditionalVerified = false;
252
- const check = this.runCapabilityProbe(signal).finally(() => {
253
- if (this.capabilityCheck === check) this.capabilityCheck = undefined;
254
- });
255
- this.capabilityCheck = check;
256
- return check;
257
- }
258
-
259
- private async runCapabilityProbe(signal?: AbortSignal) {
260
- throwIfAborted(signal);
261
- const probeCollection = joinRemote(rootPath(this.config), ".pi-sync-probes", randomUUID());
262
- const probe = `${probeCollection}/conditional.txt`;
263
- const client = new WebDavClient(this.config, signal);
264
- let created = false;
265
- let operationError: unknown;
266
- try {
267
- created = true;
268
- await client.ensureCollection(probeCollection);
269
- await client.listCollection(probeCollection);
270
- await client.putBuffer(probe, Buffer.from("first"), "text/plain", { ifAbsent: true });
271
- const read = await client.getBuffer(probe);
272
- const etag = strongEtag(read.etag);
273
- if (!read.value || !etag) {
274
- throw new Error("WebDAV server did not return a strong ETag for the capability probe.");
275
- }
276
- await expectPrecondition(
277
- client.putBuffer(probe, Buffer.from("replace"), "text/plain", { ifAbsent: true }),
278
- "If-None-Match",
279
- );
280
- await expectPrecondition(
281
- client.putBuffer(probe, Buffer.from("replace"), "text/plain", {
282
- ifMatch: '"pi-sync-deliberately-stale"',
283
- }),
284
- "If-Match",
285
- );
286
- const unchanged = await client.getBuffer(probe);
287
- if (!unchanged.value?.equals(Buffer.from("first"))) {
288
- throw new Error("WebDAV server changed a probe despite a failed precondition.");
289
- }
290
- await client.putBuffer(probe, Buffer.from("second"), "text/plain", { ifMatch: etag });
291
- const changed = await client.getBuffer(probe);
292
- const changedEtag = strongEtag(changed.etag);
293
- if (!changed.value?.equals(Buffer.from("second")) || !changedEtag || changedEtag === etag) {
294
- throw new Error(
295
- "WebDAV server did not rotate its strong ETag after changing the capability probe.",
296
- );
297
- }
298
- } catch (error) {
299
- operationError = error;
300
- }
301
- if (created) {
302
- try {
303
- await new WebDavClient(this.config, undefined).delete(probeCollection);
304
- } catch (cleanupError) {
305
- const operationDetail = operationError
306
- ? `WebDAV probe failed: ${errorMessage(operationError)}; `
307
- : "";
308
- throw new Error(
309
- `${operationDetail}probe cleanup also failed; remove ${probeCollection}: ${errorMessage(cleanupError)}`,
310
- { cause: operationError ?? cleanupError },
311
- );
312
- }
313
- }
314
- if (operationError) throw operationError;
315
- this.conditionalVerified = true;
316
- }
317
-
318
- private async resolveChecksum(reference: string, signal?: AbortSignal) {
319
- await this.readHead(signal);
320
- if (this.checksums.has(reference)) return this.checksums.get(reference);
321
- await this.listHistory(signal);
322
- return this.checksums.get(reference);
323
- }
324
-
325
- private registerChecksum(reference: string, checksum: string) {
326
- const known = this.checksums.get(reference);
327
- if (known && known !== checksum) {
328
- throw new Error("Remote snapshot reference was rebound to a different checksum.");
329
- }
330
- this.checksums.set(reference, checksum);
331
- }
332
-
333
- private async readHeadAfterCommit(client: WebDavClient) {
334
- const object = await client.getJson<LatestPointer>(latestPath(this.config));
335
- if (object.missing) return undefined;
336
- return remoteHead(
337
- requirePointer(object.value, this.config.destination.namespace),
338
- this.identity,
339
- object.etag,
340
- );
341
- }
342
-
343
- private async reconcileActiveHistory(signal?: AbortSignal) {
344
- const client = new WebDavClient(this.config, signal);
345
- const object = await client.getJson<LatestPointer>(latestPath(this.config));
346
- throwIfAborted(signal);
347
- if (object.missing) return "webdav history: no active snapshot";
348
- const pointer = requirePointer(object.value, this.config.destination.namespace);
349
- this.registerChecksum(pointer.snapshot, pointer.sha256);
350
- const repaired = await this.ensureHistoryPointer(client, pointer);
351
- return repaired
352
- ? "webdav history: repaired active snapshot entry"
353
- : "webdav history: active snapshot entry present";
354
- }
355
-
356
- private async ensureHistoryPointer(client: WebDavClient, pointer: LatestPointer) {
357
- const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(
358
- historyPath(this.config),
359
- );
360
- const snapshots = object.missing
361
- ? []
362
- : requireHistory(object.value, this.config.destination.namespace);
363
- const existing = snapshots.find((entry) => entry.snapshot === pointer.snapshot);
364
- if (existing) {
365
- if (!samePointer(existing, pointer)) {
366
- throw new Error("Remote history rebound an immutable snapshot reference.");
367
- }
368
- return false;
369
- }
370
- const next = [...snapshots, pointer].slice(-100);
371
- const condition = object.missing
372
- ? { ifAbsent: true }
373
- : { ifMatch: requireStrongEtag(object.etag, "history") };
374
- await client.putJson(
375
- historyPath(this.config),
376
- { version: VERSION, snapshots: next },
377
- condition,
378
- );
379
- return true;
380
- }
381
-
382
- private async updateHistorySafely(client: WebDavClient, pointer: LatestPointer) {
383
- try {
384
- await this.ensureHistoryPointer(client, pointer);
385
- return undefined;
386
- } catch (error) {
387
- return `Remote snapshot is active, but history could not be updated: ${errorMessage(error)}. Run /sync doctor before relying on history.`;
388
- }
389
- }
24
+ readonly identity: string;
25
+ readonly destination: string;
26
+ private conditionalVerified = false;
27
+ private readonly checksums = new Map<string, string>();
28
+ private capabilityCheck?: Promise<void>;
29
+
30
+ constructor(
31
+ private readonly config: ResolvedWebDavBackend,
32
+ private readonly postCommitTimeoutMs = POST_COMMIT_TIMEOUT_MS,
33
+ ) {
34
+ assertSafeDestination(config);
35
+ this.identity = webDavBackendIdentity(config);
36
+ this.destination = webDavStorageLocation(config);
37
+ }
38
+
39
+ get capability() {
40
+ return this.conditionalVerified ? ("atomic-conditional" as const) : ("conditional-required" as const);
41
+ }
42
+
43
+ sameRevision(left: string, right: string) {
44
+ return left === right;
45
+ }
46
+
47
+ async readHead(signal?: AbortSignal): Promise<RemoteHead | undefined> {
48
+ const object = await new WebDavClient(this.config, signal).getJson<LatestPointer>(latestPath(this.config));
49
+ throwIfAborted(signal);
50
+ if (object.missing) return undefined;
51
+ const pointer = requirePointer(object.value, this.config.destination.namespace);
52
+ this.registerChecksum(pointer.snapshot, pointer.sha256);
53
+ return remoteHead(pointer, this.identity, object.etag);
54
+ }
55
+
56
+ async readSnapshot(reference: string, signal?: AbortSignal): Promise<Snapshot> {
57
+ requireSnapshotReference(reference);
58
+ const expectedChecksum = this.checksums.get(reference) ?? (await this.resolveChecksum(reference, signal));
59
+ const object = await new WebDavClient(this.config, signal).getBuffer(snapshotPath(this.config, reference));
60
+ throwIfAborted(signal);
61
+ if (!object.value) throw new Error(`Snapshot not found: ${reference}`);
62
+ if (!expectedChecksum || sha256(object.value) !== expectedChecksum) {
63
+ throw new Error("Remote snapshot checksum mismatch.");
64
+ }
65
+ const snapshot = await decodeSnapshot(object.value, { signal });
66
+ if (snapshot.id !== reference || snapshot.profile !== this.config.destination.namespace) {
67
+ throw new Error("Remote snapshot identity mismatch.");
68
+ }
69
+ validateSnapshotBundle(snapshot);
70
+ return snapshot;
71
+ }
72
+
73
+ async publishSnapshot(
74
+ snapshot: Snapshot,
75
+ expected: ExpectedRemoteHead,
76
+ options: PublishSnapshotOptions = {},
77
+ ): Promise<PublishSnapshotResult> {
78
+ throwIfAborted(options.signal);
79
+ assertSnapshotIdentity(snapshot, this.config.destination.namespace);
80
+ await this.ensureAtomicConditions(options.signal);
81
+ throwIfAborted(options.signal);
82
+ const client = new WebDavClient(this.config, options.signal);
83
+ await client.ensureCollection(snapshotsPath(this.config));
84
+ const encoded = await encodeSnapshot(snapshot);
85
+ throwIfAborted(options.signal);
86
+ const pointer = pointerFor(this.config, snapshot, sha256(encoded));
87
+ const stagedPath = snapshotPath(this.config, snapshot.id);
88
+ try {
89
+ await client.putBuffer(stagedPath, encoded, "application/gzip", { ifAbsent: true });
90
+ } catch (error) {
91
+ if (!(error instanceof WebDavPreconditionError)) throw error;
92
+ const existing = await client.getBuffer(stagedPath);
93
+ if (!existing.value || sha256(existing.value) !== pointer.sha256) {
94
+ throw new SyncBackendConflictError(
95
+ `Immutable snapshot id already exists with different content: ${snapshot.id}`,
96
+ );
97
+ }
98
+ }
99
+ const currentObject = await client.getJson<LatestPointer>(latestPath(this.config));
100
+ throwIfAborted(options.signal);
101
+ const current = currentObject.missing
102
+ ? undefined
103
+ : remoteHead(
104
+ requirePointer(currentObject.value, this.config.destination.namespace),
105
+ this.identity,
106
+ currentObject.etag,
107
+ );
108
+ if (!matchesExpected(current, expected)) {
109
+ throw new SyncBackendConflictError("Remote changed while pushing. Run /sync pull first, then retry.", {
110
+ currentHead: current,
111
+ });
112
+ }
113
+ const condition = publicationCondition(currentObject, expected, this.identity);
114
+ throwIfAborted(options.signal);
115
+ options.onCommit?.();
116
+ const commitClient = new WebDavClient(
117
+ this.config,
118
+ AbortSignal.timeout(this.postCommitTimeoutMs),
119
+ this.postCommitTimeoutMs,
120
+ );
121
+ try {
122
+ await commitClient.putJson(latestPath(this.config), pointer, condition);
123
+ } catch (error) {
124
+ if (error instanceof WebDavPreconditionError) {
125
+ const latest = await this.readHeadAfterCommit(commitClient).catch(() => undefined);
126
+ throw new SyncBackendConflictError("Remote changed while publishing the WebDAV pointer.", {
127
+ currentHead: latest,
128
+ });
129
+ }
130
+ throw new SyncBackendPublicationOutcomeUnknownError(
131
+ `Remote publication outcome is unknown: ${errorMessage(error)}`,
132
+ { cause: error },
133
+ );
134
+ }
135
+ let verifiedObject: RemoteObject<LatestPointer>;
136
+ try {
137
+ verifiedObject = await commitClient.getJson<LatestPointer>(latestPath(this.config));
138
+ } catch (error) {
139
+ throw new SyncBackendPublicationOutcomeUnknownError(
140
+ `Remote snapshot may be active, but publication could not be verified: ${errorMessage(error)}`,
141
+ { cause: error },
142
+ );
143
+ }
144
+ let verified: LatestPointer;
145
+ try {
146
+ verified = requirePointer(verifiedObject.value, this.config.destination.namespace);
147
+ } catch (error) {
148
+ throw new SyncBackendPublicationOutcomeUnknownError(
149
+ `Remote snapshot may be active, but publication verification was malformed: ${errorMessage(error)}`,
150
+ { cause: error },
151
+ );
152
+ }
153
+ if (!samePointer(pointer, verified)) {
154
+ throw new SyncBackendConflictError(
155
+ "Remote latest changed immediately after push. Run /sync status before continuing.",
156
+ {
157
+ phase: "after-commit",
158
+ currentHead: remoteHead(verified, this.identity, verifiedObject.etag),
159
+ candidateMayHaveBeenActive: true,
160
+ },
161
+ );
162
+ }
163
+ if (!strongEtag(verifiedObject.etag)) {
164
+ throw new SyncBackendPublicationOutcomeUnknownError(
165
+ "Remote snapshot is active, but the WebDAV server returned no strong ETag.",
166
+ );
167
+ }
168
+ this.registerChecksum(verified.snapshot, verified.sha256);
169
+ const head = remoteHead(verified, this.identity, verifiedObject.etag);
170
+ const warning = await this.updateHistorySafely(commitClient, pointer);
171
+ return { head, warnings: warning ? [warning] : [] };
172
+ }
173
+
174
+ async listHistory(signal?: AbortSignal): Promise<RemoteHistoryEntry[]> {
175
+ const object = await new WebDavClient(this.config, signal).getJson<{
176
+ version: number;
177
+ snapshots: LatestPointer[];
178
+ }>(historyPath(this.config));
179
+ throwIfAborted(signal);
180
+ if (object.missing) return [];
181
+ const pointers = requireHistory(object.value, this.config.destination.namespace);
182
+ for (const pointer of pointers) this.registerChecksum(pointer.snapshot, pointer.sha256);
183
+ return pointers.map(remoteHistoryEntry);
184
+ }
185
+
186
+ async diagnose(signal?: AbortSignal): Promise<BackendDiagnostic[]> {
187
+ const diagnostics: BackendDiagnostic[] = [
188
+ {
189
+ key: "webdav-url",
190
+ level: "info",
191
+ message: `webdav URL/TLS/auth: configured (${webDavStorageLocation(this.config)})`,
192
+ },
193
+ {
194
+ key: "webdav-scope",
195
+ level: "info",
196
+ message:
197
+ "webdav check scope: temporary write/delete probe; repair the active snapshot's history entry if missing. No local content is applied.",
198
+ },
199
+ ];
200
+ try {
201
+ await this.runCapabilityProbe(signal);
202
+ diagnostics.push(
203
+ { key: "webdav-collection", level: "info", message: "webdav collection read/write: ok" },
204
+ {
205
+ key: "webdav-conditional",
206
+ level: "info",
207
+ message: "webdav conditional publication: atomic-conditional (verified)",
208
+ },
209
+ { key: "webdav-cleanup", level: "info", message: "webdav probe cleanup: ok" },
210
+ );
211
+ } catch (error) {
212
+ this.capabilityCheck = undefined;
213
+ this.conditionalVerified = false;
214
+ if (error instanceof Error && error.name === "AbortError") throw error;
215
+ diagnostics.push({
216
+ key: "webdav-probe",
217
+ level: "error",
218
+ message: `webdav publication is read-only until diagnostics pass: ${syncErrorGuidance(error)}`,
219
+ });
220
+ return diagnostics;
221
+ }
222
+ try {
223
+ diagnostics.push({
224
+ key: "webdav-history",
225
+ level: "info",
226
+ message: await this.reconcileActiveHistory(signal),
227
+ });
228
+ } catch (error) {
229
+ if (error instanceof Error && error.name === "AbortError") throw error;
230
+ diagnostics.push({
231
+ key: "webdav-history",
232
+ level: "error",
233
+ message: `webdav history repair failed: ${syncErrorGuidance(error)}`,
234
+ });
235
+ }
236
+ return diagnostics;
237
+ }
238
+
239
+ private ensureAtomicConditions(signal?: AbortSignal) {
240
+ if (this.capabilityCheck) return this.capabilityCheck;
241
+ this.conditionalVerified = false;
242
+ const check = this.runCapabilityProbe(signal).finally(() => {
243
+ if (this.capabilityCheck === check) this.capabilityCheck = undefined;
244
+ });
245
+ this.capabilityCheck = check;
246
+ return check;
247
+ }
248
+
249
+ private async runCapabilityProbe(signal?: AbortSignal) {
250
+ throwIfAborted(signal);
251
+ const probeCollection = joinRemote(rootPath(this.config), ".pi-sync-probes", randomUUID());
252
+ const probe = `${probeCollection}/conditional.txt`;
253
+ const client = new WebDavClient(this.config, signal);
254
+ let created = false;
255
+ let operationError: unknown;
256
+ try {
257
+ created = true;
258
+ await client.ensureCollection(probeCollection);
259
+ await client.listCollection(probeCollection);
260
+ await client.putBuffer(probe, Buffer.from("first"), "text/plain", { ifAbsent: true });
261
+ const read = await client.getBuffer(probe);
262
+ const etag = strongEtag(read.etag);
263
+ if (!read.value || !etag) {
264
+ throw new Error("WebDAV server did not return a strong ETag for the capability probe.");
265
+ }
266
+ await expectPrecondition(
267
+ client.putBuffer(probe, Buffer.from("replace"), "text/plain", { ifAbsent: true }),
268
+ "If-None-Match",
269
+ );
270
+ await expectPrecondition(
271
+ client.putBuffer(probe, Buffer.from("replace"), "text/plain", {
272
+ ifMatch: '"pi-sync-deliberately-stale"',
273
+ }),
274
+ "If-Match",
275
+ );
276
+ const unchanged = await client.getBuffer(probe);
277
+ if (!unchanged.value?.equals(Buffer.from("first"))) {
278
+ throw new Error("WebDAV server changed a probe despite a failed precondition.");
279
+ }
280
+ await client.putBuffer(probe, Buffer.from("second"), "text/plain", { ifMatch: etag });
281
+ const changed = await client.getBuffer(probe);
282
+ const changedEtag = strongEtag(changed.etag);
283
+ if (!changed.value?.equals(Buffer.from("second")) || !changedEtag || changedEtag === etag) {
284
+ throw new Error("WebDAV server did not rotate its strong ETag after changing the capability probe.");
285
+ }
286
+ } catch (error) {
287
+ operationError = error;
288
+ }
289
+ if (created) {
290
+ try {
291
+ await new WebDavClient(this.config, undefined).delete(probeCollection);
292
+ } catch (cleanupError) {
293
+ const operationDetail = operationError ? `WebDAV probe failed: ${errorMessage(operationError)}; ` : "";
294
+ throw new Error(
295
+ `${operationDetail}probe cleanup also failed; remove ${probeCollection}: ${errorMessage(cleanupError)}`,
296
+ { cause: operationError ?? cleanupError },
297
+ );
298
+ }
299
+ }
300
+ if (operationError) throw operationError;
301
+ this.conditionalVerified = true;
302
+ }
303
+
304
+ private async resolveChecksum(reference: string, signal?: AbortSignal) {
305
+ await this.readHead(signal);
306
+ if (this.checksums.has(reference)) return this.checksums.get(reference);
307
+ await this.listHistory(signal);
308
+ return this.checksums.get(reference);
309
+ }
310
+
311
+ private registerChecksum(reference: string, checksum: string) {
312
+ const known = this.checksums.get(reference);
313
+ if (known && known !== checksum) {
314
+ throw new Error("Remote snapshot reference was rebound to a different checksum.");
315
+ }
316
+ this.checksums.set(reference, checksum);
317
+ }
318
+
319
+ private async readHeadAfterCommit(client: WebDavClient) {
320
+ const object = await client.getJson<LatestPointer>(latestPath(this.config));
321
+ if (object.missing) return undefined;
322
+ return remoteHead(requirePointer(object.value, this.config.destination.namespace), this.identity, object.etag);
323
+ }
324
+
325
+ private async reconcileActiveHistory(signal?: AbortSignal) {
326
+ const client = new WebDavClient(this.config, signal);
327
+ const object = await client.getJson<LatestPointer>(latestPath(this.config));
328
+ throwIfAborted(signal);
329
+ if (object.missing) return "webdav history: no active snapshot";
330
+ const pointer = requirePointer(object.value, this.config.destination.namespace);
331
+ this.registerChecksum(pointer.snapshot, pointer.sha256);
332
+ const repaired = await this.ensureHistoryPointer(client, pointer);
333
+ return repaired
334
+ ? "webdav history: repaired active snapshot entry"
335
+ : "webdav history: active snapshot entry present";
336
+ }
337
+
338
+ private async ensureHistoryPointer(client: WebDavClient, pointer: LatestPointer) {
339
+ const object = await client.getJson<{ version: number; snapshots: LatestPointer[] }>(historyPath(this.config));
340
+ const snapshots = object.missing ? [] : requireHistory(object.value, this.config.destination.namespace);
341
+ const existing = snapshots.find((entry) => entry.snapshot === pointer.snapshot);
342
+ if (existing) {
343
+ if (!samePointer(existing, pointer)) {
344
+ throw new Error("Remote history rebound an immutable snapshot reference.");
345
+ }
346
+ return false;
347
+ }
348
+ const next = [...snapshots, pointer].slice(-100);
349
+ const condition = object.missing ? { ifAbsent: true } : { ifMatch: requireStrongEtag(object.etag, "history") };
350
+ await client.putJson(historyPath(this.config), { version: VERSION, snapshots: next }, condition);
351
+ return true;
352
+ }
353
+
354
+ private async updateHistorySafely(client: WebDavClient, pointer: LatestPointer) {
355
+ try {
356
+ await this.ensureHistoryPointer(client, pointer);
357
+ return undefined;
358
+ } catch (error) {
359
+ return `Remote snapshot is active, but history could not be updated: ${errorMessage(error)}. Run /sync doctor before relying on history.`;
360
+ }
361
+ }
390
362
  }
391
363
 
392
364
  export function rootPath(config: ResolvedWebDavBackend) {
393
- return config.destination.path === "./" ? "" : config.destination.path;
365
+ return config.destination.path === "./" ? "" : config.destination.path;
394
366
  }
395
367
 
396
368
  export function latestPath(config: ResolvedWebDavBackend) {
397
- return joinRemote(rootPath(config), "latest.json");
369
+ return joinRemote(rootPath(config), "latest.json");
398
370
  }
399
371
 
400
372
  export function historyPath(config: ResolvedWebDavBackend) {
401
- return joinRemote(rootPath(config), "history.json");
373
+ return joinRemote(rootPath(config), "history.json");
402
374
  }
403
375
 
404
376
  export function snapshotsPath(config: ResolvedWebDavBackend) {
405
- return joinRemote(rootPath(config), "snapshots");
377
+ return joinRemote(rootPath(config), "snapshots");
406
378
  }
407
379
 
408
380
  export function snapshotPath(config: ResolvedWebDavBackend, reference: string) {
409
- requireSnapshotReference(reference);
410
- return joinRemote(snapshotsPath(config), `${reference}.json.gz`);
381
+ requireSnapshotReference(reference);
382
+ return joinRemote(snapshotsPath(config), `${reference}.json.gz`);
411
383
  }
412
384
 
413
385
  export function webDavBackendIdentity(config: ResolvedWebDavBackend) {
414
- return `webdav:${sha256(
415
- Buffer.from(JSON.stringify([secretFreeUrl(config.profile.url), config.destination.path])),
416
- )}`;
386
+ return `webdav:${sha256(Buffer.from(JSON.stringify([secretFreeUrl(config.profile.url), config.destination.path])))}`;
417
387
  }
418
388
 
419
389
  function webDavStorageLocation(config: ResolvedWebDavBackend) {
420
- const url = new URL(secretFreeUrl(config.profile.url));
421
- return `${url.host} · ${config.destination.path}`;
422
- }
423
-
424
- function pointerFor(
425
- config: ResolvedWebDavBackend,
426
- snapshot: Snapshot,
427
- checksum: string,
428
- ): LatestPointer {
429
- return {
430
- version: VERSION,
431
- profile: config.destination.namespace,
432
- snapshot: snapshot.id,
433
- sha256: checksum,
434
- createdAt: snapshot.createdAt,
435
- machine: snapshot.machine,
436
- syncSessions:
437
- snapshot.syncSessions === true ||
438
- snapshot.files.some((file) => file.path.startsWith("sessions/")),
439
- ...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
440
- };
390
+ const url = new URL(secretFreeUrl(config.profile.url));
391
+ return `${url.host} · ${config.destination.path}`;
392
+ }
393
+
394
+ function pointerFor(config: ResolvedWebDavBackend, snapshot: Snapshot, checksum: string): LatestPointer {
395
+ return {
396
+ version: VERSION,
397
+ profile: config.destination.namespace,
398
+ snapshot: snapshot.id,
399
+ sha256: checksum,
400
+ createdAt: snapshot.createdAt,
401
+ machine: snapshot.machine,
402
+ syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
403
+ ...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
404
+ };
441
405
  }
442
406
 
443
407
  function remoteHead(pointer: LatestPointer, identity: string, etag?: string): RemoteHead {
444
- return {
445
- snapshotRef: pointer.snapshot,
446
- snapshotId: pointer.snapshot,
447
- revision: encodeRevision(identity, etag, pointer),
448
- createdAt: pointer.createdAt,
449
- machine: pointer.machine,
450
- syncSessions: pointer.syncSessions === true,
451
- ...(pointer.selection === undefined ? {} : { selection: pointer.selection }),
452
- };
408
+ return {
409
+ snapshotRef: pointer.snapshot,
410
+ snapshotId: pointer.snapshot,
411
+ revision: encodeRevision(identity, etag, pointer),
412
+ createdAt: pointer.createdAt,
413
+ machine: pointer.machine,
414
+ syncSessions: pointer.syncSessions === true,
415
+ ...(pointer.selection === undefined ? {} : { selection: pointer.selection }),
416
+ };
453
417
  }
454
418
 
455
419
  function encodeRevision(identity: string, etag: string | undefined, pointer: LatestPointer) {
456
- return `webdav-v1:${Buffer.from(JSON.stringify({ identity, etag: etag ?? null, pointer: sha256(Buffer.from(canonicalJson(pointer))) })).toString("base64url")}`;
420
+ return `webdav-v1:${Buffer.from(JSON.stringify({ identity, etag: etag ?? null, pointer: sha256(Buffer.from(canonicalJson(pointer))) })).toString("base64url")}`;
457
421
  }
458
422
 
459
423
  function decodeRevision(value: string, identity: string) {
460
- try {
461
- if (!value.startsWith("webdav-v1:")) return undefined;
462
- const parsed = JSON.parse(Buffer.from(value.slice(10), "base64url").toString("utf8")) as {
463
- identity?: unknown;
464
- etag?: unknown;
465
- };
466
- if (parsed.identity !== identity || typeof parsed.etag !== "string") return undefined;
467
- return strongEtag(parsed.etag);
468
- } catch {
469
- return undefined;
470
- }
471
- }
472
-
473
- function publicationCondition(
474
- current: RemoteObject<LatestPointer>,
475
- expected: ExpectedRemoteHead,
476
- identity: string,
477
- ) {
478
- if (expected.kind === "missing") return { ifAbsent: true };
479
- const etag = decodeRevision(expected.revision, identity) ?? strongEtag(current.etag);
480
- if (!etag) throw new SyncBackendConflictError("WebDAV head has no usable strong ETag.");
481
- return { ifMatch: etag };
424
+ try {
425
+ if (!value.startsWith("webdav-v1:")) return undefined;
426
+ const parsed = JSON.parse(Buffer.from(value.slice(10), "base64url").toString("utf8")) as {
427
+ identity?: unknown;
428
+ etag?: unknown;
429
+ };
430
+ if (parsed.identity !== identity || typeof parsed.etag !== "string") return undefined;
431
+ return strongEtag(parsed.etag);
432
+ } catch {
433
+ return undefined;
434
+ }
435
+ }
436
+
437
+ function publicationCondition(current: RemoteObject<LatestPointer>, expected: ExpectedRemoteHead, identity: string) {
438
+ if (expected.kind === "missing") return { ifAbsent: true };
439
+ const etag = decodeRevision(expected.revision, identity) ?? strongEtag(current.etag);
440
+ if (!etag) throw new SyncBackendConflictError("WebDAV head has no usable strong ETag.");
441
+ return { ifMatch: etag };
482
442
  }
483
443
 
484
444
  async function expectPrecondition(operation: Promise<unknown>, header: string) {
485
- try {
486
- await operation;
487
- } catch (error) {
488
- if (error instanceof WebDavPreconditionError) return;
489
- throw error;
490
- }
491
- throw new Error(`WebDAV server ignored ${header}; publication is read-only for safety.`);
445
+ try {
446
+ await operation;
447
+ } catch (error) {
448
+ if (error instanceof WebDavPreconditionError) return;
449
+ throw error;
450
+ }
451
+ throw new Error(`WebDAV server ignored ${header}; publication is read-only for safety.`);
492
452
  }
493
453
 
494
454
  function requireStrongEtag(value: string | undefined, resource: string) {
495
- const etag = strongEtag(value);
496
- if (!etag) throw new Error(`WebDAV ${resource} has no strong ETag.`);
497
- return etag;
455
+ const etag = strongEtag(value);
456
+ if (!etag) throw new Error(`WebDAV ${resource} has no strong ETag.`);
457
+ return etag;
498
458
  }
499
459
 
500
460
  function strongEtag(value: string | undefined) {
501
- return value && !value.startsWith("W/") && /^"[^"\r\n]+"$/u.test(value) ? value : undefined;
461
+ return value && !value.startsWith("W/") && /^"[^"\r\n]+"$/u.test(value) ? value : undefined;
502
462
  }
503
463
 
504
464
  function requirePointer(value: LatestPointer | undefined, expectedProfile: string) {
505
- if (
506
- !value ||
507
- value.version !== VERSION ||
508
- value.profile !== expectedProfile ||
509
- !isSafeReference(value.snapshot) ||
510
- !/^[0-9a-f]{64}$/u.test(value.sha256) ||
511
- !isSafeMetadata(value.createdAt, 64) ||
512
- !isSafeMetadata(value.machine, 256) ||
513
- (value.syncSessions !== undefined && typeof value.syncSessions !== "boolean")
514
- ) {
515
- throw new Error("Remote latest pointer is malformed.");
516
- }
517
- if (value.selection !== undefined) portableSnapshotSelection(value.selection);
518
- return value;
519
- }
520
-
521
- function requireHistory(
522
- value: { version: number; snapshots: LatestPointer[] } | undefined,
523
- expectedProfile: string,
524
- ) {
525
- if (!value || value.version !== VERSION || !Array.isArray(value.snapshots)) {
526
- throw new Error("Remote history is malformed.");
527
- }
528
- const pointers = value.snapshots.map((item) => requirePointer(item, expectedProfile));
529
- if (new Set(pointers.map((item) => item.snapshot)).size !== pointers.length) {
530
- throw new Error("Remote history contains duplicate snapshot references.");
531
- }
532
- return pointers;
465
+ if (
466
+ !value ||
467
+ value.version !== VERSION ||
468
+ value.profile !== expectedProfile ||
469
+ !isSafeReference(value.snapshot) ||
470
+ !/^[0-9a-f]{64}$/u.test(value.sha256) ||
471
+ !isSafeMetadata(value.createdAt, 64) ||
472
+ !isSafeMetadata(value.machine, 256) ||
473
+ (value.syncSessions !== undefined && typeof value.syncSessions !== "boolean")
474
+ ) {
475
+ throw new Error("Remote latest pointer is malformed.");
476
+ }
477
+ if (value.selection !== undefined) portableSnapshotSelection(value.selection);
478
+ return value;
479
+ }
480
+
481
+ function requireHistory(value: { version: number; snapshots: LatestPointer[] } | undefined, expectedProfile: string) {
482
+ if (!value || value.version !== VERSION || !Array.isArray(value.snapshots)) {
483
+ throw new Error("Remote history is malformed.");
484
+ }
485
+ const pointers = value.snapshots.map((item) => requirePointer(item, expectedProfile));
486
+ if (new Set(pointers.map((item) => item.snapshot)).size !== pointers.length) {
487
+ throw new Error("Remote history contains duplicate snapshot references.");
488
+ }
489
+ return pointers;
533
490
  }
534
491
 
535
492
  function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
536
- return {
537
- snapshotRef: pointer.snapshot,
538
- snapshotId: pointer.snapshot,
539
- createdAt: pointer.createdAt,
540
- machine: pointer.machine,
541
- syncSessions: pointer.syncSessions === true,
542
- };
493
+ return {
494
+ snapshotRef: pointer.snapshot,
495
+ snapshotId: pointer.snapshot,
496
+ createdAt: pointer.createdAt,
497
+ machine: pointer.machine,
498
+ syncSessions: pointer.syncSessions === true,
499
+ };
543
500
  }
544
501
 
545
502
  function assertSnapshotIdentity(snapshot: Snapshot, namespace: string) {
546
- if (
547
- snapshot.version !== VERSION ||
548
- snapshot.profile !== namespace ||
549
- !isSafeReference(snapshot.id) ||
550
- !isSafeMetadata(snapshot.createdAt, 64) ||
551
- !isSafeMetadata(snapshot.machine, 256) ||
552
- !Array.isArray(snapshot.files)
553
- ) {
554
- throw new Error("Invalid snapshot identity for WebDAV publication.");
555
- }
503
+ if (
504
+ snapshot.version !== VERSION ||
505
+ snapshot.profile !== namespace ||
506
+ !isSafeReference(snapshot.id) ||
507
+ !isSafeMetadata(snapshot.createdAt, 64) ||
508
+ !isSafeMetadata(snapshot.machine, 256) ||
509
+ !Array.isArray(snapshot.files)
510
+ ) {
511
+ throw new Error("Invalid snapshot identity for WebDAV publication.");
512
+ }
556
513
  }
557
514
 
558
515
  function validateSnapshotBundle(snapshot: Snapshot) {
559
- const paths = new Set<string>();
560
- for (const file of snapshot.files) {
561
- if (
562
- !file ||
563
- !isSafeSnapshotPath(file.path) ||
564
- paths.has(file.path) ||
565
- typeof file.contentBase64 !== "string" ||
566
- typeof file.sha256 !== "string"
567
- ) {
568
- throw new Error("Remote snapshot bundle is malformed.");
569
- }
570
- const content = Buffer.from(file.contentBase64, "base64");
571
- if (content.toString("base64") !== file.contentBase64 || sha256(content) !== file.sha256) {
572
- throw new Error("Remote snapshot bundle is malformed.");
573
- }
574
- paths.add(file.path);
575
- }
516
+ const paths = new Set<string>();
517
+ for (const file of snapshot.files) {
518
+ if (
519
+ !file ||
520
+ !isSafeSnapshotPath(file.path) ||
521
+ paths.has(file.path) ||
522
+ typeof file.contentBase64 !== "string" ||
523
+ typeof file.sha256 !== "string"
524
+ ) {
525
+ throw new Error("Remote snapshot bundle is malformed.");
526
+ }
527
+ const content = Buffer.from(file.contentBase64, "base64");
528
+ if (content.toString("base64") !== file.contentBase64 || sha256(content) !== file.sha256) {
529
+ throw new Error("Remote snapshot bundle is malformed.");
530
+ }
531
+ paths.add(file.path);
532
+ }
576
533
  }
577
534
 
578
535
  function isSafeSnapshotPath(value: unknown): value is string {
579
- if (typeof value !== "string" || !value || value.length > 4096 || value.includes("\\")) {
580
- return false;
581
- }
582
- if (
583
- [...value].some((character) => {
584
- const code = character.codePointAt(0) ?? 0;
585
- return code < 0x20 || (code >= 0x7f && code <= 0x9f);
586
- })
587
- ) {
588
- return false;
589
- }
590
- return value.split("/").every((segment) => segment && segment !== "." && segment !== "..");
536
+ if (typeof value !== "string" || !value || value.length > 4096 || value.includes("\\")) {
537
+ return false;
538
+ }
539
+ if (
540
+ [...value].some((character) => {
541
+ const code = character.codePointAt(0) ?? 0;
542
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f);
543
+ })
544
+ ) {
545
+ return false;
546
+ }
547
+ return value.split("/").every((segment) => segment && segment !== "." && segment !== "..");
591
548
  }
592
549
 
593
550
  function assertSafeDestination(config: ResolvedWebDavBackend) {
594
- const url = new URL(config.profile.url);
595
- if (url.username || url.password || url.search || url.hash)
596
- throw new Error("Invalid WebDAV URL.");
597
- for (const [label, value] of [
598
- ["path", config.destination.path],
599
- ["namespace", config.destination.namespace],
600
- ]) {
601
- if (label === "path" && value === "./") continue;
602
- if (
603
- !value ||
604
- value.includes("\\") ||
605
- [...value].some((character) => {
606
- const code = character.codePointAt(0) ?? 0;
607
- return code < 0x20 || (code >= 0x7f && code <= 0x9f);
608
- }) ||
609
- value.split("/").some((part) => part === "." || part === "..")
610
- ) {
611
- throw new Error("Invalid WebDAV storage location.");
612
- }
613
- }
551
+ const url = new URL(config.profile.url);
552
+ if (url.username || url.password || url.search || url.hash) throw new Error("Invalid WebDAV URL.");
553
+ for (const [label, value] of [
554
+ ["path", config.destination.path],
555
+ ["namespace", config.destination.namespace],
556
+ ]) {
557
+ if (label === "path" && value === "./") continue;
558
+ if (
559
+ !value ||
560
+ value.includes("\\") ||
561
+ [...value].some((character) => {
562
+ const code = character.codePointAt(0) ?? 0;
563
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f);
564
+ }) ||
565
+ value.split("/").some((part) => part === "." || part === "..")
566
+ ) {
567
+ throw new Error("Invalid WebDAV storage location.");
568
+ }
569
+ }
614
570
  }
615
571
 
616
572
  function isSafeMetadata(value: unknown, maxLength: number): value is string {
617
- return (
618
- typeof value === "string" &&
619
- value.length > 0 &&
620
- value.length <= maxLength &&
621
- ![...value].some((character) => {
622
- const code = character.codePointAt(0) ?? 0;
623
- return code < 0x20 || (code >= 0x7f && code <= 0x9f);
624
- })
625
- );
573
+ return (
574
+ typeof value === "string" &&
575
+ value.length > 0 &&
576
+ value.length <= maxLength &&
577
+ ![...value].some((character) => {
578
+ const code = character.codePointAt(0) ?? 0;
579
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f);
580
+ })
581
+ );
626
582
  }
627
583
 
628
584
  function requireSnapshotReference(reference: string) {
629
- if (!isSafeReference(reference)) throw new Error("Invalid WebDAV snapshot reference.");
585
+ if (!isSafeReference(reference)) throw new Error("Invalid WebDAV snapshot reference.");
630
586
  }
631
587
 
632
588
  function isSafeReference(value: string) {
633
- return (
634
- !!value &&
635
- value.length <= 512 &&
636
- // biome-ignore lint/suspicious/noControlCharactersInRegex: Remote references cannot contain terminal controls.
637
- !/[\\/\u0000-\u001f\u007f-\u009f]/u.test(value) &&
638
- value !== "." &&
639
- value !== ".."
640
- );
589
+ return (
590
+ !!value &&
591
+ value.length <= 512 &&
592
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Remote references cannot contain terminal controls.
593
+ !/[\\/\u0000-\u001f\u007f-\u009f]/u.test(value) &&
594
+ value !== "." &&
595
+ value !== ".."
596
+ );
641
597
  }
642
598
 
643
599
  function matchesExpected(head: RemoteHead | undefined, expected: ExpectedRemoteHead) {
644
- return expected.kind === "missing" ? head === undefined : head?.revision === expected.revision;
600
+ return expected.kind === "missing" ? head === undefined : head?.revision === expected.revision;
645
601
  }
646
602
 
647
603
  function samePointer(left: LatestPointer, right: LatestPointer) {
648
- return canonicalJson(left) === canonicalJson(right);
604
+ return canonicalJson(left) === canonicalJson(right);
649
605
  }
650
606
 
651
607
  function canonicalJson(value: unknown): string {
652
- if (Array.isArray(value))
653
- return JSON.stringify(value.map((item) => JSON.parse(canonicalJson(item))));
654
- if (!value || typeof value !== "object") return JSON.stringify(value);
655
- return JSON.stringify(
656
- Object.fromEntries(
657
- Object.keys(value)
658
- .sort()
659
- .map((key) => [key, JSON.parse(canonicalJson((value as Record<string, unknown>)[key]))]),
660
- ),
661
- );
608
+ if (Array.isArray(value)) return JSON.stringify(value.map((item) => JSON.parse(canonicalJson(item))));
609
+ if (!value || typeof value !== "object") return JSON.stringify(value);
610
+ return JSON.stringify(
611
+ Object.fromEntries(
612
+ Object.keys(value)
613
+ .sort()
614
+ .map((key) => [key, JSON.parse(canonicalJson((value as Record<string, unknown>)[key]))]),
615
+ ),
616
+ );
662
617
  }
663
618
 
664
619
  function joinRemote(...parts: string[]) {
665
- return parts
666
- .flatMap((part) => part.split("/"))
667
- .filter(Boolean)
668
- .join("/");
620
+ return parts
621
+ .flatMap((part) => part.split("/"))
622
+ .filter(Boolean)
623
+ .join("/");
669
624
  }
670
625
 
671
626
  function secretFreeUrl(value: string) {
672
- const url = new URL(value);
673
- url.username = "";
674
- url.password = "";
675
- url.search = "";
676
- url.hash = "";
677
- return url.toString();
627
+ const url = new URL(value);
628
+ url.username = "";
629
+ url.password = "";
630
+ url.search = "";
631
+ url.hash = "";
632
+ return url.toString();
678
633
  }
679
634
 
680
635
  function sha256(value: Buffer) {
681
- return createHash("sha256").update(value).digest("hex");
636
+ return createHash("sha256").update(value).digest("hex");
682
637
  }
683
638
 
684
639
  function throwIfAborted(signal?: AbortSignal) {
685
- if (!signal?.aborted) return;
686
- throw signal.reason instanceof Error
687
- ? signal.reason
688
- : new DOMException("The operation was aborted", "AbortError");
640
+ if (!signal?.aborted) return;
641
+ throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
689
642
  }
690
643
 
691
644
  function errorMessage(error: unknown) {
692
- if (error instanceof WebDavHttpError) return error.message;
693
- return error instanceof Error ? error.message : String(error);
645
+ if (error instanceof WebDavHttpError) return error.message;
646
+ return error instanceof Error ? error.message : String(error);
694
647
  }