@narumitw/pi-sync 0.50.2 → 0.51.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 (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-4CHTZMEO.ts → chunk-7KHKWL3Q.ts} +37 -55
  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-G4RNQQZS.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-KW7YQEZN.ts → chunk-UA7RXOIT.ts} +48 -100
  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-5KLKVMRC.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-IUB6ESEU.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 +92 -112
  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 +198 -214
  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-4CHTZMEO.ts.map +0 -7
  176. package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
  177. package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
  178. package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
  179. package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
  180. package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
  181. package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
  182. package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
  183. package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
  184. package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
  185. package/dist/chunks/chunk-G4RNQQZS.ts.map +0 -7
  186. package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
  187. package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
  188. package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
  189. package/dist/chunks/chunk-KW7YQEZN.ts.map +0 -7
  190. package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
  191. package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
  192. package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
  193. package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
  194. package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
  195. package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
  196. package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
  197. package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
  198. package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
  199. package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
  200. package/dist/chunks/chunk-THMF4BVL.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-FKIBM3GO.ts +0 -22
  206. package/dist/chunks/manager-ui-5KLKVMRC.ts.map +0 -7
  207. package/dist/chunks/remote-selection-ui-D7RBRJNL.ts +0 -21
  208. package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
  209. package/dist/chunks/setup-wizard-IUB6ESEU.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-FKIBM3GO.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
  214. /package/dist/chunks/{remote-selection-ui-D7RBRJNL.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
@@ -9,388 +9,343 @@ const MAX_ERROR_RESPONSE_BYTES = 64 * 1024;
9
9
  const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
10
10
 
11
11
  function iso8601Basic(date: Date) {
12
- return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
12
+ return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
13
13
  }
14
14
 
15
15
  function isCloudflareR2Endpoint(endpoint: string | undefined) {
16
- const value = endpoint?.trim();
17
- if (!value) return false;
18
- try {
19
- const hostname = new URL(value).hostname.toLowerCase();
20
- return (
21
- hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com")
22
- );
23
- } catch {
24
- return false;
25
- }
16
+ const value = endpoint?.trim();
17
+ if (!value) return false;
18
+ try {
19
+ const hostname = new URL(value).hostname.toLowerCase();
20
+ return hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
21
+ } catch {
22
+ return false;
23
+ }
26
24
  }
27
25
 
28
26
  export class S3ObjectAlreadyExistsError extends Error {
29
- constructor(readonly key: string) {
30
- super(`S3 object already exists: ${key}`);
31
- this.name = "S3ObjectAlreadyExistsError";
32
- }
27
+ constructor(readonly key: string) {
28
+ super(`S3 object already exists: ${key}`);
29
+ this.name = "S3ObjectAlreadyExistsError";
30
+ }
33
31
  }
34
32
 
35
33
  export class S3HttpError extends Error {
36
- constructor(
37
- message: string,
38
- readonly status: number,
39
- ) {
40
- super(message);
41
- this.name = "S3HttpError";
42
- }
34
+ constructor(
35
+ message: string,
36
+ readonly status: number,
37
+ ) {
38
+ super(message);
39
+ this.name = "S3HttpError";
40
+ }
43
41
  }
44
42
 
45
43
  export class S3Client {
46
- private config: ResolvedS3Backend;
47
- private endpoint: URL;
48
- private signal?: AbortSignal;
49
- private requestTimeoutMs: number;
50
- private omitSessionTokenAfterRejection = false;
44
+ private config: ResolvedS3Backend;
45
+ private endpoint: URL;
46
+ private signal?: AbortSignal;
47
+ private requestTimeoutMs: number;
48
+ private omitSessionTokenAfterRejection = false;
51
49
 
52
- constructor(
53
- config: ResolvedS3Backend,
54
- signal?: AbortSignal,
55
- requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
56
- ) {
57
- this.config = config;
58
- this.endpoint = new URL(config.profile.endpoint);
59
- this.signal = signal;
60
- this.requestTimeoutMs = requestTimeoutMs;
61
- }
50
+ constructor(config: ResolvedS3Backend, signal?: AbortSignal, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
51
+ this.config = config;
52
+ this.endpoint = new URL(config.profile.endpoint);
53
+ this.signal = signal;
54
+ this.requestTimeoutMs = requestTimeoutMs;
55
+ }
62
56
 
63
- /** Read-only access probe. A bare 404 cannot distinguish a missing key from a missing bucket. */
64
- async diagnoseRead(
65
- key: string,
66
- ): Promise<"readable" | "missing-key" | "missing-bucket" | "unknown-404"> {
67
- this.signal?.throwIfAborted();
68
- const response = await this.request("GET", key);
69
- if (response.ok) {
70
- await response.body?.cancel();
71
- this.signal?.throwIfAborted();
72
- return "readable";
73
- }
74
- const detail = await this.readErrorText(response);
75
- this.signal?.throwIfAborted();
76
- if (response.status === 404) {
77
- const code = /<Code>\s*([A-Za-z]+)\s*<\/Code>/u.exec(detail)?.[1];
78
- if (code === "NoSuchBucket") return "missing-bucket";
79
- if (code === "NoSuchKey") return "missing-key";
80
- return "unknown-404";
81
- }
82
- throw new S3HttpError(`S3 remote read failed (HTTP ${response.status}).`, response.status);
83
- }
57
+ /** Read-only access probe. A bare 404 cannot distinguish a missing key from a missing bucket. */
58
+ async diagnoseRead(key: string): Promise<"readable" | "missing-key" | "missing-bucket" | "unknown-404"> {
59
+ this.signal?.throwIfAborted();
60
+ const response = await this.request("GET", key);
61
+ if (response.ok) {
62
+ await response.body?.cancel();
63
+ this.signal?.throwIfAborted();
64
+ return "readable";
65
+ }
66
+ const detail = await this.readErrorText(response);
67
+ this.signal?.throwIfAborted();
68
+ if (response.status === 404) {
69
+ const code = /<Code>\s*([A-Za-z]+)\s*<\/Code>/u.exec(detail)?.[1];
70
+ if (code === "NoSuchBucket") return "missing-bucket";
71
+ if (code === "NoSuchKey") return "missing-key";
72
+ return "unknown-404";
73
+ }
74
+ throw new S3HttpError(`S3 remote read failed (HTTP ${response.status}).`, response.status);
75
+ }
84
76
 
85
- async getJson<T>(key: string): Promise<RemoteObject<T>> {
86
- const maxAttempts = 3;
87
- let lastError: unknown;
88
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
89
- const object = await this.request("GET", key);
90
- if (object.status === 404) return { missing: true };
91
- if (!object.ok) {
92
- throw new S3HttpError(
93
- `S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
94
- object.status,
95
- );
96
- }
97
- const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response");
98
- // R2 can intermittently return an empty 200 body (read-after-write
99
- // inconsistency on a long-lived keep-alive connection), which makes
100
- // response.json() throw "JSON Parse error: Unexpected EOF" under Bun.
101
- // Retry so the transient blip is absorbed instead of surfacing as a
102
- // "pi-sync auto sync skipped" warning on every session start.
103
- if (body.length > 0) {
104
- return {
105
- value: JSON.parse(body) as T,
106
- etag: normalizeEtag(object.headers.get("etag")),
107
- missing: false,
108
- };
109
- }
110
- lastError = new Error(`S3 GET returned an empty body for ${key}`);
111
- if (attempt < maxAttempts) {
112
- await sleep(250 * attempt, undefined, { signal: this.signal });
113
- }
114
- }
115
- throw lastError;
116
- }
77
+ async getJson<T>(key: string): Promise<RemoteObject<T>> {
78
+ const maxAttempts = 3;
79
+ let lastError: unknown;
80
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
81
+ const object = await this.request("GET", key);
82
+ if (object.status === 404) return { missing: true };
83
+ if (!object.ok) {
84
+ throw new S3HttpError(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`, object.status);
85
+ }
86
+ const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response");
87
+ // R2 can intermittently return an empty 200 body (read-after-write
88
+ // inconsistency on a long-lived keep-alive connection), which makes
89
+ // response.json() throw "JSON Parse error: Unexpected EOF" under Bun.
90
+ // Retry so the transient blip is absorbed instead of surfacing as a
91
+ // "pi-sync auto sync skipped" warning on every session start.
92
+ if (body.length > 0) {
93
+ return {
94
+ value: JSON.parse(body) as T,
95
+ etag: normalizeEtag(object.headers.get("etag")),
96
+ missing: false,
97
+ };
98
+ }
99
+ lastError = new Error(`S3 GET returned an empty body for ${key}`);
100
+ if (attempt < maxAttempts) {
101
+ await sleep(250 * attempt, undefined, { signal: this.signal });
102
+ }
103
+ }
104
+ throw lastError;
105
+ }
117
106
 
118
- async getBuffer(key: string): Promise<RemoteObject<Buffer>> {
119
- const maxAttempts = 3;
120
- let lastError: unknown;
121
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
122
- const object = await this.request("GET", key);
123
- if (object.status === 404) return { missing: true };
124
- if (!object.ok) {
125
- throw new S3HttpError(
126
- `S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
127
- object.status,
128
- );
129
- }
130
- const buffer = await readBoundedBuffer(
131
- object,
132
- MAX_SNAPSHOT_RESPONSE_BYTES,
133
- "S3 snapshot response",
134
- );
135
- // R2 can intermittently return an empty 200 body on a long-lived
136
- // keep-alive connection (same root cause as the getJson retry
137
- // above). getBuffer is only used for snapshot .json.gz payloads,
138
- // which are always non-empty, so an empty body is a transient
139
- // blip, not a legitimate response. Retry so the checksum guard
140
- // downstream doesn't surface it as "Remote snapshot checksum
141
- // mismatch".
142
- if (buffer.length > 0) {
143
- return { value: buffer, etag: normalizeEtag(object.headers.get("etag")), missing: false };
144
- }
145
- lastError = new Error(`S3 GET returned an empty body for ${key}`);
146
- if (attempt < maxAttempts) {
147
- await sleep(250 * attempt, undefined, { signal: this.signal });
148
- }
149
- }
150
- throw lastError;
151
- }
107
+ async getBuffer(key: string): Promise<RemoteObject<Buffer>> {
108
+ const maxAttempts = 3;
109
+ let lastError: unknown;
110
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
111
+ const object = await this.request("GET", key);
112
+ if (object.status === 404) return { missing: true };
113
+ if (!object.ok) {
114
+ throw new S3HttpError(`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`, object.status);
115
+ }
116
+ const buffer = await readBoundedBuffer(object, MAX_SNAPSHOT_RESPONSE_BYTES, "S3 snapshot response");
117
+ // R2 can intermittently return an empty 200 body on a long-lived
118
+ // keep-alive connection (same root cause as the getJson retry
119
+ // above). getBuffer is only used for snapshot .json.gz payloads,
120
+ // which are always non-empty, so an empty body is a transient
121
+ // blip, not a legitimate response. Retry so the checksum guard
122
+ // downstream doesn't surface it as "Remote snapshot checksum
123
+ // mismatch".
124
+ if (buffer.length > 0) {
125
+ return { value: buffer, etag: normalizeEtag(object.headers.get("etag")), missing: false };
126
+ }
127
+ lastError = new Error(`S3 GET returned an empty body for ${key}`);
128
+ if (attempt < maxAttempts) {
129
+ await sleep(250 * attempt, undefined, { signal: this.signal });
130
+ }
131
+ }
132
+ throw lastError;
133
+ }
152
134
 
153
- async putJson(key: string, value: unknown) {
154
- const body = Buffer.from(JSON.stringify(value, null, "\t"), "utf8");
155
- await this.putBuffer(key, body, "application/json");
156
- }
135
+ async putJson(key: string, value: unknown) {
136
+ const body = Buffer.from(JSON.stringify(value, null, "\t"), "utf8");
137
+ await this.putBuffer(key, body, "application/json");
138
+ }
157
139
 
158
- async putBuffer(
159
- key: string,
160
- body: Buffer,
161
- contentType: string,
162
- options: { ifAbsent?: boolean } = {},
163
- ) {
164
- const headers: Record<string, string> = { "content-type": contentType };
165
- if (options.ifAbsent) headers["if-none-match"] = "*";
166
- const response = await this.request("PUT", key, body, headers);
167
- if (options.ifAbsent && response.status === 412) {
168
- throw new S3ObjectAlreadyExistsError(key);
169
- }
170
- if (!response.ok) {
171
- throw new S3HttpError(
172
- `S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`,
173
- response.status,
174
- );
175
- }
176
- }
140
+ async putBuffer(key: string, body: Buffer, contentType: string, options: { ifAbsent?: boolean } = {}) {
141
+ const headers: Record<string, string> = { "content-type": contentType };
142
+ if (options.ifAbsent) headers["if-none-match"] = "*";
143
+ const response = await this.request("PUT", key, body, headers);
144
+ if (options.ifAbsent && response.status === 412) {
145
+ throw new S3ObjectAlreadyExistsError(key);
146
+ }
147
+ if (!response.ok) {
148
+ throw new S3HttpError(
149
+ `S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`,
150
+ response.status,
151
+ );
152
+ }
153
+ }
177
154
 
178
- private async request(
179
- method: "GET" | "PUT",
180
- key: string,
181
- body?: Buffer,
182
- extraHeaders: Record<string, string> = {},
183
- ) {
184
- const url = new URL(this.endpoint.toString());
185
- url.pathname = posixJoin(url.pathname, this.config.destination.bucket, encodeKey(key));
186
- const send = async (sessionToken: string | undefined) => {
187
- const transportSignal = this.transportSignal();
188
- const headers = await signedHeaders({
189
- method,
190
- url,
191
- body,
192
- extraHeaders,
193
- accessKeyId: this.config.profile.accessKeyId,
194
- secretAccessKey: this.config.profile.secretAccessKey,
195
- sessionToken,
196
- region: this.config.profile.region,
197
- });
198
- try {
199
- return await fetch(url, {
200
- method,
201
- headers,
202
- body: body ? new Uint8Array(body) : undefined,
203
- signal: transportSignal,
204
- });
205
- } catch (error) {
206
- if (error instanceof Error && error.name === "AbortError") throw error;
207
- if (error instanceof Error && error.name === "TimeoutError") {
208
- throw new Error("S3 request timed out.", { cause: error });
209
- }
210
- throw new Error(`S3 request failed: ${this.redact(errorMessage(error))}`, { cause: error });
211
- }
212
- };
213
- const sessionToken = this.omitSessionTokenAfterRejection
214
- ? undefined
215
- : this.config.profile.sessionToken;
216
- const response = await send(sessionToken);
217
- try {
218
- if (!(await this.shouldRetryWithoutSessionToken(response, sessionToken))) return response;
219
- } catch (error) {
220
- await response.body?.cancel().catch(() => undefined);
221
- throw error;
222
- }
223
- await response.body?.cancel();
224
- const retry = await send(undefined);
225
- if (retry.ok || retry.status === 404) this.omitSessionTokenAfterRejection = true;
226
- return retry;
227
- }
155
+ private async request(method: "GET" | "PUT", key: string, body?: Buffer, extraHeaders: Record<string, string> = {}) {
156
+ const url = new URL(this.endpoint.toString());
157
+ url.pathname = posixJoin(url.pathname, this.config.destination.bucket, encodeKey(key));
158
+ const send = async (sessionToken: string | undefined) => {
159
+ const transportSignal = this.transportSignal();
160
+ const headers = await signedHeaders({
161
+ method,
162
+ url,
163
+ body,
164
+ extraHeaders,
165
+ accessKeyId: this.config.profile.accessKeyId,
166
+ secretAccessKey: this.config.profile.secretAccessKey,
167
+ sessionToken,
168
+ region: this.config.profile.region,
169
+ });
170
+ try {
171
+ return await fetch(url, {
172
+ method,
173
+ headers,
174
+ body: body ? new Uint8Array(body) : undefined,
175
+ signal: transportSignal,
176
+ });
177
+ } catch (error) {
178
+ if (error instanceof Error && error.name === "AbortError") throw error;
179
+ if (error instanceof Error && error.name === "TimeoutError") {
180
+ throw new Error("S3 request timed out.", { cause: error });
181
+ }
182
+ throw new Error(`S3 request failed: ${this.redact(errorMessage(error))}`, { cause: error });
183
+ }
184
+ };
185
+ const sessionToken = this.omitSessionTokenAfterRejection ? undefined : this.config.profile.sessionToken;
186
+ const response = await send(sessionToken);
187
+ try {
188
+ if (!(await this.shouldRetryWithoutSessionToken(response, sessionToken))) return response;
189
+ } catch (error) {
190
+ await response.body?.cancel().catch(() => undefined);
191
+ throw error;
192
+ }
193
+ await response.body?.cancel();
194
+ const retry = await send(undefined);
195
+ if (retry.ok || retry.status === 404) this.omitSessionTokenAfterRejection = true;
196
+ return retry;
197
+ }
228
198
 
229
- private transportSignal() {
230
- const timeout = AbortSignal.timeout(this.requestTimeoutMs);
231
- return this.signal ? AbortSignal.any([this.signal, timeout]) : timeout;
232
- }
199
+ private transportSignal() {
200
+ const timeout = AbortSignal.timeout(this.requestTimeoutMs);
201
+ return this.signal ? AbortSignal.any([this.signal, timeout]) : timeout;
202
+ }
233
203
 
234
- private async shouldRetryWithoutSessionToken(
235
- response: Response,
236
- sessionToken: string | undefined,
237
- ) {
238
- if (
239
- !sessionToken ||
240
- !isCloudflareR2Endpoint(this.config.profile.endpoint) ||
241
- response.ok ||
242
- response.status !== 400
243
- ) {
244
- return false;
245
- }
246
- return isSecurityTokenInvalidArgument(
247
- await readBoundedText(response.clone(), MAX_ERROR_RESPONSE_BYTES, "S3 error response"),
248
- );
249
- }
204
+ private async shouldRetryWithoutSessionToken(response: Response, sessionToken: string | undefined) {
205
+ if (
206
+ !sessionToken ||
207
+ !isCloudflareR2Endpoint(this.config.profile.endpoint) ||
208
+ response.ok ||
209
+ response.status !== 400
210
+ ) {
211
+ return false;
212
+ }
213
+ return isSecurityTokenInvalidArgument(
214
+ await readBoundedText(response.clone(), MAX_ERROR_RESPONSE_BYTES, "S3 error response"),
215
+ );
216
+ }
250
217
 
251
- private async readErrorText(response: Response) {
252
- try {
253
- return this.redact(
254
- await readBoundedText(response, MAX_ERROR_RESPONSE_BYTES, "S3 error response"),
255
- );
256
- } catch (error) {
257
- return this.redact(errorMessage(error));
258
- }
259
- }
218
+ private async readErrorText(response: Response) {
219
+ try {
220
+ return this.redact(await readBoundedText(response, MAX_ERROR_RESPONSE_BYTES, "S3 error response"));
221
+ } catch (error) {
222
+ return this.redact(errorMessage(error));
223
+ }
224
+ }
260
225
 
261
- private redact(value: string) {
262
- let redacted = value;
263
- let endpointUsername: string | undefined;
264
- let endpointPassword: string | undefined;
265
- let endpointQueryValues: string[] = [];
266
- try {
267
- const endpoint = new URL(this.config.profile.endpoint);
268
- endpointUsername = endpoint.username;
269
- endpointPassword = endpoint.password;
270
- endpointQueryValues = [...endpoint.searchParams.values()];
271
- } catch {
272
- // The constructor already validates normal runtime endpoints.
273
- }
274
- for (const secret of [
275
- this.config.profile.accessKeyId,
276
- this.config.profile.secretAccessKey,
277
- this.config.profile.sessionToken,
278
- endpointUsername,
279
- endpointPassword,
280
- ...endpointQueryValues,
281
- ]) {
282
- if (secret) redacted = redacted.replaceAll(secret, "[REDACTED]");
283
- }
284
- return redacted;
285
- }
226
+ private redact(value: string) {
227
+ let redacted = value;
228
+ let endpointUsername: string | undefined;
229
+ let endpointPassword: string | undefined;
230
+ let endpointQueryValues: string[] = [];
231
+ try {
232
+ const endpoint = new URL(this.config.profile.endpoint);
233
+ endpointUsername = endpoint.username;
234
+ endpointPassword = endpoint.password;
235
+ endpointQueryValues = [...endpoint.searchParams.values()];
236
+ } catch {
237
+ // The constructor already validates normal runtime endpoints.
238
+ }
239
+ for (const secret of [
240
+ this.config.profile.accessKeyId,
241
+ this.config.profile.secretAccessKey,
242
+ this.config.profile.sessionToken,
243
+ endpointUsername,
244
+ endpointPassword,
245
+ ...endpointQueryValues,
246
+ ]) {
247
+ if (secret) redacted = redacted.replaceAll(secret, "[REDACTED]");
248
+ }
249
+ return redacted;
250
+ }
286
251
  }
287
252
 
288
253
  async function readBoundedText(response: Response, limit: number, label: string) {
289
- return (await readBoundedBuffer(response, limit, label)).toString("utf8");
254
+ return (await readBoundedBuffer(response, limit, label)).toString("utf8");
290
255
  }
291
256
 
292
257
  async function readBoundedBuffer(response: Response, limit: number, label: string) {
293
- const contentLength = Number(response.headers.get("content-length"));
294
- if (Number.isFinite(contentLength) && contentLength > limit) {
295
- // Do not await cancellation of a cloned stream before its sibling can be released.
296
- void response.body?.cancel().catch(() => undefined);
297
- throw new Error(`${label} exceeds the ${limit}-byte limit.`);
298
- }
299
- if (!response.body) return Buffer.alloc(0);
300
- const reader = response.body.getReader();
301
- const chunks: Buffer[] = [];
302
- let total = 0;
303
- try {
304
- while (true) {
305
- const { done, value } = await reader.read();
306
- if (done) break;
307
- total += value.byteLength;
308
- if (total > limit) {
309
- void reader.cancel().catch(() => undefined);
310
- throw new Error(`${label} exceeds the ${limit}-byte limit.`);
311
- }
312
- chunks.push(Buffer.from(value));
313
- }
314
- } finally {
315
- reader.releaseLock();
316
- }
317
- return Buffer.concat(chunks, total);
258
+ const contentLength = Number(response.headers.get("content-length"));
259
+ if (Number.isFinite(contentLength) && contentLength > limit) {
260
+ // Do not await cancellation of a cloned stream before its sibling can be released.
261
+ void response.body?.cancel().catch(() => undefined);
262
+ throw new Error(`${label} exceeds the ${limit}-byte limit.`);
263
+ }
264
+ if (!response.body) return Buffer.alloc(0);
265
+ const reader = response.body.getReader();
266
+ const chunks: Buffer[] = [];
267
+ let total = 0;
268
+ try {
269
+ while (true) {
270
+ const { done, value } = await reader.read();
271
+ if (done) break;
272
+ total += value.byteLength;
273
+ if (total > limit) {
274
+ void reader.cancel().catch(() => undefined);
275
+ throw new Error(`${label} exceeds the ${limit}-byte limit.`);
276
+ }
277
+ chunks.push(Buffer.from(value));
278
+ }
279
+ } finally {
280
+ reader.releaseLock();
281
+ }
282
+ return Buffer.concat(chunks, total);
318
283
  }
319
284
 
320
285
  function errorMessage(error: unknown) {
321
- return error instanceof Error ? error.message : String(error);
286
+ return error instanceof Error ? error.message : String(error);
322
287
  }
323
288
 
324
289
  async function signedHeaders(input: {
325
- method: string;
326
- url: URL;
327
- body?: Buffer;
328
- extraHeaders: Record<string, string>;
329
- accessKeyId: string;
330
- secretAccessKey: string;
331
- sessionToken?: string;
332
- region: string;
290
+ method: string;
291
+ url: URL;
292
+ body?: Buffer;
293
+ extraHeaders: Record<string, string>;
294
+ accessKeyId: string;
295
+ secretAccessKey: string;
296
+ sessionToken?: string;
297
+ region: string;
333
298
  }) {
334
- const now = new Date();
335
- const amzDate = iso8601Basic(now);
336
- const dateStamp = amzDate.slice(0, 8);
337
- const payloadHash = sha256(input.body ?? Buffer.alloc(0));
338
- const headers: Record<string, string> = {
339
- ...lowercaseKeys(input.extraHeaders),
340
- host: input.url.host,
341
- "x-amz-content-sha256": payloadHash,
342
- "x-amz-date": amzDate,
343
- };
344
- if (input.sessionToken) headers["x-amz-security-token"] = input.sessionToken;
345
- const signedHeaderNames = Object.keys(headers).sort();
346
- const canonicalHeaders = signedHeaderNames
347
- .map((name) => `${name}:${headers[name]?.trim()}\n`)
348
- .join("");
349
- const canonicalRequest = [
350
- input.method,
351
- input.url.pathname,
352
- input.url.searchParams.toString(),
353
- canonicalHeaders,
354
- signedHeaderNames.join(";"),
355
- payloadHash,
356
- ].join("\n");
357
- const scope = `${dateStamp}/${input.region}/s3/aws4_request`;
358
- const stringToSign = [
359
- "AWS4-HMAC-SHA256",
360
- amzDate,
361
- scope,
362
- sha256(Buffer.from(canonicalRequest)),
363
- ].join("\n");
364
- const signingKey = hmac(
365
- hmac(hmac(hmac(Buffer.from(`AWS4${input.secretAccessKey}`), dateStamp), input.region), "s3"),
366
- "aws4_request",
367
- );
368
- const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
369
- return {
370
- ...headers,
371
- authorization: `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaderNames.join(";")}, Signature=${signature}`,
372
- };
299
+ const now = new Date();
300
+ const amzDate = iso8601Basic(now);
301
+ const dateStamp = amzDate.slice(0, 8);
302
+ const payloadHash = sha256(input.body ?? Buffer.alloc(0));
303
+ const headers: Record<string, string> = {
304
+ ...lowercaseKeys(input.extraHeaders),
305
+ host: input.url.host,
306
+ "x-amz-content-sha256": payloadHash,
307
+ "x-amz-date": amzDate,
308
+ };
309
+ if (input.sessionToken) headers["x-amz-security-token"] = input.sessionToken;
310
+ const signedHeaderNames = Object.keys(headers).sort();
311
+ const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]?.trim()}\n`).join("");
312
+ const canonicalRequest = [
313
+ input.method,
314
+ input.url.pathname,
315
+ input.url.searchParams.toString(),
316
+ canonicalHeaders,
317
+ signedHeaderNames.join(";"),
318
+ payloadHash,
319
+ ].join("\n");
320
+ const scope = `${dateStamp}/${input.region}/s3/aws4_request`;
321
+ const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(Buffer.from(canonicalRequest))].join("\n");
322
+ const signingKey = hmac(
323
+ hmac(hmac(hmac(Buffer.from(`AWS4${input.secretAccessKey}`), dateStamp), input.region), "s3"),
324
+ "aws4_request",
325
+ );
326
+ const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex");
327
+ return {
328
+ ...headers,
329
+ authorization: `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${scope}, SignedHeaders=${signedHeaderNames.join(";")}, Signature=${signature}`,
330
+ };
373
331
  }
374
332
 
375
333
  function hmac(key: Buffer, value: string) {
376
- return createHmac("sha256", key).update(value).digest();
334
+ return createHmac("sha256", key).update(value).digest();
377
335
  }
378
336
 
379
337
  function sha256(value: Buffer) {
380
- return createHash("sha256").update(value).digest("hex");
338
+ return createHash("sha256").update(value).digest("hex");
381
339
  }
382
340
 
383
341
  function lowercaseKeys(value: Record<string, string>) {
384
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key.toLowerCase(), item]));
342
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key.toLowerCase(), item]));
385
343
  }
386
344
 
387
345
  function normalizeEtag(value: string | null) {
388
- return value ?? undefined;
346
+ return value ?? undefined;
389
347
  }
390
348
 
391
349
  function isSecurityTokenInvalidArgument(text: string) {
392
- return (
393
- text.includes("<Code>InvalidArgument</Code>") &&
394
- text.includes("<Message>X-Amz-Security-Token</Message>")
395
- );
350
+ return text.includes("<Code>InvalidArgument</Code>") && text.includes("<Message>X-Amz-Security-Token</Message>");
396
351
  }