@indigoai-us/hq-cloud 5.46.0 → 5.47.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 (52) hide show
  1. package/dist/bin/sync-runner.d.ts +12 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +39 -0
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +27 -1
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +17 -2
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +2 -0
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync-scope.test.js +1 -0
  13. package/dist/cli/sync-scope.test.js.map +1 -1
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js +11 -1
  16. package/dist/cli/sync.js.map +1 -1
  17. package/dist/cli/sync.test.js +1 -0
  18. package/dist/cli/sync.test.js.map +1 -1
  19. package/dist/object-io.d.ts +218 -0
  20. package/dist/object-io.d.ts.map +1 -0
  21. package/dist/object-io.js +588 -0
  22. package/dist/object-io.js.map +1 -0
  23. package/dist/object-io.test.d.ts +11 -0
  24. package/dist/object-io.test.d.ts.map +1 -0
  25. package/dist/object-io.test.js +568 -0
  26. package/dist/object-io.test.js.map +1 -0
  27. package/dist/s3.d.ts +37 -0
  28. package/dist/s3.d.ts.map +1 -1
  29. package/dist/s3.js +225 -201
  30. package/dist/s3.js.map +1 -1
  31. package/dist/s3.test.js +21 -0
  32. package/dist/s3.test.js.map +1 -1
  33. package/dist/vault-client.d.ts +68 -0
  34. package/dist/vault-client.d.ts.map +1 -1
  35. package/dist/vault-client.js +35 -0
  36. package/dist/vault-client.js.map +1 -1
  37. package/package.json +1 -1
  38. package/scripts/presign-transport-e2e.mjs +203 -0
  39. package/scripts/vault-rebaseline.sh +275 -0
  40. package/scripts/vault-rescue.sh +8 -0
  41. package/src/bin/sync-runner.test.ts +41 -0
  42. package/src/bin/sync-runner.ts +52 -0
  43. package/src/cli/share.test.ts +2 -0
  44. package/src/cli/share.ts +29 -2
  45. package/src/cli/sync-scope.test.ts +1 -0
  46. package/src/cli/sync.test.ts +1 -0
  47. package/src/cli/sync.ts +22 -1
  48. package/src/object-io.test.ts +663 -0
  49. package/src/object-io.ts +782 -0
  50. package/src/s3.test.ts +24 -0
  51. package/src/s3.ts +277 -237
  52. package/src/vault-client.ts +101 -0
package/dist/s3.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"s3.d.ts","sourceRoot":"","sources":["../src/s3.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAYH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAkBhD;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,4DAA4D;IAC5D,OAAO,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAC;CACf;AA6BD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,uBAAuB,sBAAsB,CAAC;AAE3D;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAE7C;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUhE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,mBAAmB,gBAAgB,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,kBAAkB,YAAY,CAAC;AAE5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,eAAO,MAAM,mBAAmB,aAAa,CAAC;AAE9C;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,aAAa,CAAC;AAE9C;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAsB,UAAU,CAC9B,GAAG,EAAE,aAAa,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,YAAY,GACpB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAuG3B;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,aAAa,EAClB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,YAAY,GACpB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CA+C3B;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,aAAa,EAClB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC,CAgMhD;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,IAAI,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAsB,eAAe,CACnC,GAAG,EAAE,aAAa,EAClB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,UAAU,EAAE,CAAC,CAwDvB;AAED,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,aAAa,EAClB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CASf;AAED;;GAEG;AACH,wBAAsB,cAAc,CAClC,GAAG,EAAE,aAAa,EAClB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC;IAAE,YAAY,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,IAAI,CAAC,CAqBvG"}
1
+ {"version":3,"file":"s3.d.ts","sourceRoot":"","sources":["../src/s3.ts"],"names":[],"mappings":"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"}
package/dist/s3.js CHANGED
@@ -7,22 +7,7 @@
7
7
  */
8
8
  import * as fs from "fs";
9
9
  import * as path from "path";
10
- import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand, HeadObjectCommand, } from "@aws-sdk/client-s3";
11
- /**
12
- * Build an S3Client from an EntityContext's STS-scoped credentials.
13
- * A new client is created each time to ensure fresh credentials are used
14
- * (the caller handles caching/refresh at the EntityContext level).
15
- */
16
- function buildClient(ctx) {
17
- return new S3Client({
18
- region: ctx.region,
19
- credentials: {
20
- accessKeyId: ctx.credentials.accessKeyId,
21
- secretAccessKey: ctx.credentials.secretAccessKey,
22
- sessionToken: ctx.credentials.sessionToken,
23
- },
24
- });
25
- }
10
+ import { resolveObjectIO } from "./object-io.js";
26
11
  /**
27
12
  * S3 user metadata is ASCII-only (lowercased on read, capped at 2 KB total).
28
13
  * Values that fail the printable-ASCII test or would push the keys over the
@@ -212,100 +197,172 @@ export const FILE_BTIME_META_KEY = "hq-btime";
212
197
  export function encodeSymlinkBody(target) {
213
198
  return Buffer.from(SYMLINK_BODY_PREFIX + target, "utf-8");
214
199
  }
215
- export async function uploadFile(ctx, localPath, key, author) {
216
- const client = buildClient(ctx);
217
- const body = fs.readFileSync(localPath);
218
- // Capture source-side file mode (permission bits only) for Bug #5 see
219
- // FILE_MODE_META_KEY doc. Best-effort: lstat failure (raced rm, EPERM)
220
- // falls through to "no mode header" and the receiver keeps its umask
221
- // default same as the legacy back-compat path.
222
- //
223
- // 5.37.0: same lstat call also yields mtimeMs + birthtimeMs for the
224
- // hq-mtime / hq-btime metadata headers. Single lstat keeps the syscall
225
- // budget identical to 5.36.0; the additional metadata fields are pure
226
- // in-memory work on the lstat result. Symlinks skip ALL THREE stamps
227
- // symlink mode is OS-controlled, symlink mtime isn't user-meaningful
228
- // because the wire body is `hq-symlink:` + target (not real file
229
- // content), and fs.utimesSync follows links so applying it on receive
230
- // would mutate the target's mtime instead of the link's.
231
- let modeOctal;
232
- let mtimeMsStamp;
233
- let btimeMsStamp;
234
- try {
235
- const lstat = fs.lstatSync(localPath);
236
- if (!lstat.isSymbolicLink()) {
237
- modeOctal = (lstat.mode & 0o777).toString(8);
238
- // Math.floor truncates the sub-millisecond fractional component
239
- // some filesystems report (APFS returns full ms+fraction; ext4
240
- // is integer-ms). String(int) on the read side matches the
241
- // strict-numeric regex `^-?[0-9]{1,16}$` optional leading `-`,
242
- // no leading zeros, no decimals, no exponents.
243
- //
244
- // Codex PR #27 P2: accept the full finite range, including 0
245
- // (Unix epoch) and negatives (pre-epoch / reproducible-build
246
- // clamping). Earlier `> 0` filter silently dropped legitimate
247
- // timestamps and broke the round-trip guarantee for that subset.
248
- const mtimeFloor = Math.floor(lstat.mtimeMs);
249
- if (Number.isFinite(lstat.mtimeMs)) {
250
- mtimeMsStamp = String(mtimeFloor);
251
- }
252
- // birthtimeMs filter: only stamp when the filesystem actually
253
- // tracks a separate creation time. ext4 historically returns 0
254
- // (unsupported) or equals mtimeMs (no distinct tracking); tmpfs
255
- // and some FUSE mounts behave similarly. Filtering at the source
256
- // keeps the metadata header free of noise — the receiver can
257
- // assume hq-btime, if present, carries real signal.
258
- //
259
- // Compare the floored values (not raw lstat.birthtimeMs vs
260
- // lstat.mtimeMs) because APFS exposes sub-millisecond fractions —
261
- // two timestamps representing the "same moment" for sync purposes
262
- // can differ by < 1 ms and pass a strict `!==` check while serializing
263
- // to the same integer-ms string. Comparing floor-to-floor matches
264
- // what we actually emit on the wire.
265
- const btimeFloor = Math.floor(lstat.birthtimeMs);
266
- if (Number.isFinite(lstat.birthtimeMs) &&
267
- btimeFloor > 0 &&
268
- btimeFloor !== mtimeFloor) {
269
- btimeMsStamp = String(btimeFloor);
270
- }
271
- }
272
- }
273
- catch {
274
- // Leave stamps undefined; receiver applies its umask default and
275
- // leaves mtime at write-time (the legacy back-compat path).
200
+ /**
201
+ * Batch pre-mint transport URLs for `keys` under `op` so the subsequent
202
+ * per-file transfer calls (downloadFile/headRemoteFile/…) reuse them instead
203
+ * of presigning one key at a time. On the presigned-URL transport this turns
204
+ * an N-file leg from N presign requests into ceil(N/100) the difference
205
+ * between completing a bulk pull and 429ing past the 100-req/hr limit. No-op
206
+ * on the S3 SDK transport (which has no presign step) and harmless if called
207
+ * with an empty list. Best-effort: a prime failure never propagates — the
208
+ * per-file path falls back to a single presign.
209
+ *
210
+ * Call it once, right before a transfer loop, with the full key set the loop
211
+ * will touch. The presigned transport memoizes one IO instance per company for
212
+ * the run, so the warmed cache is the same one the loop drains.
213
+ */
214
+ export async function primeObjectTransport(ctx, op, keys) {
215
+ if (keys.length === 0)
216
+ return;
217
+ const io = resolveObjectIO(ctx);
218
+ if (!io.prime)
219
+ return;
220
+ await io.prime(op, keys.map((key) => ({ key })));
221
+ }
222
+ /**
223
+ * Source-side mode + mtime (+ btime when distinct) metadata for a regular
224
+ * file, from a single lstat. Symlinks carry none (OS-controlled mode; a link's
225
+ * mtime isn't user-meaningful the wire body is the target string, not file
226
+ * content). Shared by uploadFile and the primeUploads pre-pass so the PUT
227
+ * metadata they produce is byte-identical. See the FILE_*_META_KEY docs for the
228
+ * per-field rationale.
229
+ */
230
+ function buildModeTimeMetadata(lstat) {
231
+ const meta = {};
232
+ if (lstat.isSymbolicLink())
233
+ return meta;
234
+ meta[FILE_MODE_META_KEY] = (lstat.mode & 0o777).toString(8);
235
+ const mtimeFloor = Math.floor(lstat.mtimeMs);
236
+ if (Number.isFinite(lstat.mtimeMs))
237
+ meta[FILE_MTIME_META_KEY] = String(mtimeFloor);
238
+ const btimeFloor = Math.floor(lstat.birthtimeMs);
239
+ if (Number.isFinite(lstat.birthtimeMs) &&
240
+ btimeFloor > 0 &&
241
+ btimeFloor !== mtimeFloor) {
242
+ meta[FILE_BTIME_META_KEY] = String(btimeFloor);
276
243
  }
277
- // Preserve the original `created-at` across re-uploads when the object
278
- // already exists with author metadata — same convention the hq-console
279
- // upload route uses, so the NEW-pill ageing window doesn't reset on every
280
- // sync tick. HEAD failure (NoSuchKey, perm, transient 5xx) falls through
281
- // to "now", which is correct for a first upload.
244
+ return meta;
245
+ }
246
+ /**
247
+ * Resolve the created-at to stamp: the existing object's value (preserved
248
+ * across re-uploads so the hq-console NEW-pill window doesn't reset) or now for
249
+ * a first upload. HEAD failure / no author → now. Shared by upload* and
250
+ * primeUploads so both agree on the value signed into the PUT.
251
+ */
252
+ async function resolveCreatedAt(io, key, author) {
282
253
  let createdAt = new Date().toISOString();
283
254
  if (author) {
284
255
  try {
285
- const head = await client.send(new HeadObjectCommand({ Bucket: ctx.bucketName, Key: key }));
286
- const existing = head.Metadata?.["created-at"];
256
+ const head = await io.headObject(key);
257
+ const existing = head?.metadata?.["created-at"];
287
258
  if (typeof existing === "string" && existing.length > 0) {
288
259
  createdAt = existing;
289
260
  }
290
261
  }
291
262
  catch {
292
- // Object doesn't exist yet, or HEAD denied — keep `now`.
263
+ // Object doesn't exist yet, or HEAD failed — keep now (first upload).
264
+ }
265
+ }
266
+ return createdAt;
267
+ }
268
+ /**
269
+ * Batch pre-mint PUT URLs (+ the created-at HEADs they depend on) for a set of
270
+ * uploads, signing the SAME metadata uploadFile/uploadSymlink would compute so
271
+ * the transfer loop can replay the cached headers. Turns an N-file push from
272
+ * ~N presign calls (1 per PUT, sometimes 2-3 with HEADs) into ceil(N/1000) GET
273
+ * + ceil(N/1000) PUT — the difference between completing a bulk push and 429ing
274
+ * past the 100/hr limit. No-op on the S3 SDK transport; best-effort.
275
+ *
276
+ * The per-item created-at HEADs run over the GET cache primed first, so they
277
+ * cost S3 round-trips but NO extra presign calls (not counted against 100/hr).
278
+ */
279
+ export async function primeUploads(ctx, items) {
280
+ const io = resolveObjectIO(ctx);
281
+ if (!io.prime || items.length === 0)
282
+ return;
283
+ // Prime GET first so each item's created-at HEAD reuses a cached URL.
284
+ await io.prime("get", items.map((i) => ({ key: i.key })));
285
+ // Build per-key PUT metadata with the SAME builders the upload path uses,
286
+ // bounded-concurrently (the HEADs are cheap cached-GET fetches).
287
+ const putKeys = [];
288
+ const CONCURRENCY = 16;
289
+ let next = 0;
290
+ const worker = async () => {
291
+ while (next < items.length) {
292
+ const it = items[next++];
293
+ const createdAt = await resolveCreatedAt(io, it.key, it.author);
294
+ if (it.isSymlink) {
295
+ putKeys.push({
296
+ key: it.key,
297
+ contentType: "application/octet-stream",
298
+ metadata: {
299
+ [SYMLINK_TARGET_META_KEY]: SYMLINK_MARKER_META_VALUE,
300
+ ...(it.author ? buildAuthorMetadata(it.author, createdAt) : {}),
301
+ },
302
+ });
303
+ }
304
+ else {
305
+ let modeTime = {};
306
+ try {
307
+ modeTime = buildModeTimeMetadata(fs.lstatSync(it.localPath));
308
+ }
309
+ catch {
310
+ // raced rm / EPERM — leave stamps off (receiver umask default).
311
+ }
312
+ putKeys.push({
313
+ key: it.key,
314
+ contentType: getMimeType(it.key),
315
+ metadata: {
316
+ ...(it.author ? buildAuthorMetadata(it.author, createdAt) : {}),
317
+ ...modeTime,
318
+ },
319
+ });
320
+ }
293
321
  }
322
+ };
323
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
324
+ await io.prime("put", putKeys);
325
+ }
326
+ export async function uploadFile(ctx, localPath, key, author) {
327
+ const io = resolveObjectIO(ctx);
328
+ const body = fs.readFileSync(localPath);
329
+ // Fast path: a primeUploads() pre-pass already signed this file's metadata
330
+ // into a cached PUT URL. Skip the lstat-metadata + created-at HEAD and just
331
+ // send the body — putObject replays the cached headers (computed by the SAME
332
+ // builders below, so identical). hasPrimedPut only reports true with >60s of
333
+ // URL lifetime left, so the cache can't expire before the putObject below.
334
+ if (io.hasPrimedPut?.(key)) {
335
+ const primed = await io.putObject({
336
+ key,
337
+ body,
338
+ contentType: getMimeType(key),
339
+ metadata: {},
340
+ });
341
+ return { etag: primed.etag };
342
+ }
343
+ // Source-side mode/mtime/btime (Bug #5 + 5.37.0) and the preserved
344
+ // created-at (so the NEW-pill window doesn't reset on re-upload). Both via
345
+ // the shared builders that primeUploads uses, so a primed PUT carries the
346
+ // identical metadata — see buildModeTimeMetadata / resolveCreatedAt.
347
+ let modeTime = {};
348
+ try {
349
+ modeTime = buildModeTimeMetadata(fs.lstatSync(localPath));
350
+ }
351
+ catch {
352
+ // raced rm / EPERM — leave stamps off; receiver keeps its umask default.
294
353
  }
354
+ const createdAt = await resolveCreatedAt(io, key, author);
295
355
  const Metadata = {
296
356
  ...(author ? buildAuthorMetadata(author, createdAt) : {}),
297
- ...(modeOctal ? { [FILE_MODE_META_KEY]: modeOctal } : {}),
298
- ...(mtimeMsStamp ? { [FILE_MTIME_META_KEY]: mtimeMsStamp } : {}),
299
- ...(btimeMsStamp ? { [FILE_BTIME_META_KEY]: btimeMsStamp } : {}),
357
+ ...modeTime,
300
358
  };
301
- const response = await client.send(new PutObjectCommand({
302
- Bucket: ctx.bucketName,
303
- Key: key,
304
- Body: body,
305
- ContentType: getMimeType(key),
306
- ...(Object.keys(Metadata).length > 0 ? { Metadata } : {}),
307
- }));
308
- return { etag: response.ETag || "" };
359
+ const response = await io.putObject({
360
+ key,
361
+ body,
362
+ contentType: getMimeType(key),
363
+ metadata: Metadata,
364
+ });
365
+ return { etag: response.etag };
309
366
  }
310
367
  /**
311
368
  * Upload a symlink as a zero-byte object whose user metadata carries the
@@ -320,23 +377,22 @@ export async function uploadFile(ctx, localPath, key, author) {
320
377
  * caller (currently: upload anyway, never silently rewrite).
321
378
  */
322
379
  export async function uploadSymlink(ctx, target, key, author) {
323
- const client = buildClient(ctx);
324
- // Same created-at preservation logic as uploadFile so the hq-console
325
- // NEW-pill ageing window doesn't reset when a symlink is re-uploaded
326
- // unchanged across syncs.
327
- let createdAt = new Date().toISOString();
328
- if (author) {
329
- try {
330
- const head = await client.send(new HeadObjectCommand({ Bucket: ctx.bucketName, Key: key }));
331
- const existing = head.Metadata?.["created-at"];
332
- if (typeof existing === "string" && existing.length > 0) {
333
- createdAt = existing;
334
- }
335
- }
336
- catch {
337
- // First upload of this key, or HEAD denied — keep `now`.
338
- }
380
+ const io = resolveObjectIO(ctx);
381
+ const symlinkBody = encodeSymlinkBody(target);
382
+ // Fast path: primeUploads() already signed this symlink's metadata into a
383
+ // cached PUT URL — send the body, replay the cached headers.
384
+ if (io.hasPrimedPut?.(key)) {
385
+ const primed = await io.putObject({
386
+ key,
387
+ body: symlinkBody,
388
+ contentType: "application/octet-stream",
389
+ metadata: {},
390
+ });
391
+ return { etag: primed.etag };
339
392
  }
393
+ // Same created-at preservation as uploadFile (shared resolveCreatedAt) so the
394
+ // NEW-pill window doesn't reset on re-upload, and so a primed PUT matches.
395
+ const createdAt = await resolveCreatedAt(io, key, author);
340
396
  const Metadata = {
341
397
  // Marker-only: a constant flag value, not the target. The body
342
398
  // is the source of truth for the target (no 2 KiB cap, no
@@ -345,20 +401,19 @@ export async function uploadSymlink(ctx, target, key, author) {
345
401
  [SYMLINK_TARGET_META_KEY]: SYMLINK_MARKER_META_VALUE,
346
402
  ...(author ? buildAuthorMetadata(author, createdAt) : {}),
347
403
  };
348
- const response = await client.send(new PutObjectCommand({
349
- Bucket: ctx.bucketName,
350
- Key: key,
404
+ const response = await io.putObject({
405
+ key,
351
406
  // Body = SYMLINK_BODY_PREFIX + target (UTF-8). The prefix is what
352
407
  // makes a symlink record's ETag distinguishable from a regular
353
408
  // file whose contents happen to equal the target string — the
354
409
  // LIST-based pull planner can't see per-object metadata, so ETag
355
410
  // is its only drift signal across symlink ↔ regular-file
356
411
  // transitions. See SYMLINK_BODY_PREFIX doc above.
357
- Body: encodeSymlinkBody(target),
358
- ContentType: "application/octet-stream",
359
- Metadata,
360
- }));
361
- return { etag: response.ETag || "" };
412
+ body: symlinkBody,
413
+ contentType: "application/octet-stream",
414
+ metadata: Metadata,
415
+ });
416
+ return { etag: response.etag };
362
417
  }
363
418
  /**
364
419
  * Download an object to localPath and return its S3 user-metadata.
@@ -370,14 +425,13 @@ export async function uploadSymlink(ctx, target, key, author) {
370
425
  * attribute downloaded files to their author with zero extra network.
371
426
  */
372
427
  export async function downloadFile(ctx, key, localPath) {
373
- const client = buildClient(ctx);
374
- const response = await client.send(new GetObjectCommand({
375
- Bucket: ctx.bucketName,
376
- Key: key,
377
- }));
378
- if (!response.Body) {
379
- throw new Error(`Empty response for ${key}`);
380
- }
428
+ const io = resolveObjectIO(ctx);
429
+ // The transport returns the full object body buffered + its user metadata.
430
+ // downloadFile already buffered the whole object (writeFileSync of the
431
+ // concatenated chunks), so buffering at the transport layer is behavior-
432
+ // preserving — symlink record bodies are tiny and regular files were read
433
+ // fully into memory regardless.
434
+ const { body: objectBody, metadata } = await io.getObject(key);
381
435
  const dir = path.dirname(localPath);
382
436
  if (!fs.existsSync(dir)) {
383
437
  fs.mkdirSync(dir, { recursive: true });
@@ -390,21 +444,25 @@ export async function downloadFile(ctx, key, localPath) {
390
444
  // S3 lowercases user-metadata keys on read (and sometimes on
391
445
  // write), so the lookup uses the lowercased form. We don't
392
446
  // normalize Metadata keys ourselves — the AWS SDK already does it.
393
- const symlinkMarker = response.Metadata?.[SYMLINK_TARGET_META_KEY];
394
- const isSymlinkRecord = typeof symlinkMarker === "string" && symlinkMarker.length > 0;
447
+ const symlinkMarker = metadata?.[SYMLINK_TARGET_META_KEY];
448
+ // Discriminator: the metadata marker is the primary signal, but the body
449
+ // prefix is a header-loss fallback (S3 cross-region replication of data
450
+ // only, a console copy that drops Metadata, a metadata-stripping transport,
451
+ // or a poisoned regular-file re-upload of a sentinel). Honor BOTH — a
452
+ // marker-less object whose body starts with SYMLINK_BODY_PREFIX still
453
+ // rematerializes as a link instead of being written out as plain
454
+ // `hq-symlink:<target>` text, which would poison the key on the next push.
455
+ // The body is already buffered (tiny for symlink records); reading it here
456
+ // is behavior-preserving for regular files (whose body never starts with
457
+ // the prefix per the SYMLINK_BODY_PREFIX doc). See SYMLINK_BODY_PREFIX.
458
+ const bodyString = objectBody.toString("utf-8");
459
+ const isSymlinkRecord = (typeof symlinkMarker === "string" && symlinkMarker.length > 0) ||
460
+ bodyString.startsWith(SYMLINK_BODY_PREFIX);
395
461
  if (isSymlinkRecord) {
396
- // Consume the body to extract the target. Symlink record bodies
397
- // are bounded by target length (typically <300 bytes for
398
- // relative paths, hard-capped by S3's 5 GB object size); the
399
- // read is cheap. Drain explicitly so the SDK's HTTP socket is
400
- // released back to the connection pool — without this, a sync
401
- // over a tree with many symlinks can stall or pool-exhaust.
402
- const chunks = [];
403
- const stream = response.Body;
404
- for await (const chunk of stream) {
405
- chunks.push(Buffer.from(chunk));
406
- }
407
- const bodyString = Buffer.concat(chunks).toString("utf-8");
462
+ // The target lives in the body (marker-only metadata convention).
463
+ // Symlink record bodies are bounded by target length (typically
464
+ // <300 bytes for relative paths, hard-capped by S3's 5 GB object
465
+ // size); the transport already buffered it.
408
466
  let symlinkTarget;
409
467
  if (bodyString.startsWith(SYMLINK_BODY_PREFIX)) {
410
468
  symlinkTarget = bodyString.slice(SYMLINK_BODY_PREFIX.length);
@@ -414,8 +472,12 @@ export async function downloadFile(ctx, key, localPath) {
414
472
  // this PR's lifetime stored the target in metadata (raw or
415
473
  // base64) rather than the body. decodeSymlinkMetadataValue
416
474
  // round-trip-validates so a raw value passes through and a
417
- // base64 value decodes; either way we get the target.
418
- symlinkTarget = decodeSymlinkMetadataValue(symlinkMarker);
475
+ // base64 value decodes; either way we get the target. This branch
476
+ // is only reachable when the body lacks the prefix, which (given
477
+ // isSymlinkRecord) means the marker is present — the `?? ""` is a
478
+ // type guard for that invariant, and the length-0 check below
479
+ // catches the impossible empty case rather than passing it on.
480
+ symlinkTarget = decodeSymlinkMetadataValue(symlinkMarker ?? "");
419
481
  }
420
482
  if (symlinkTarget.length === 0) {
421
483
  throw new Error(`Symlink record for ${key} had no target (body: ${bodyString.length} bytes, marker: ${symlinkMarker})`);
@@ -436,7 +498,7 @@ export async function downloadFile(ctx, key, localPath) {
436
498
  }
437
499
  }
438
500
  fs.symlinkSync(symlinkTarget, localPath);
439
- return { metadata: response.Metadata };
501
+ return { metadata };
440
502
  }
441
503
  // Symmetric to the symlink branch above: when a key was previously a
442
504
  // symlink and is later replaced in S3 by a regular object, the local
@@ -460,12 +522,7 @@ export async function downloadFile(ctx, key, localPath) {
460
522
  throw err;
461
523
  }
462
524
  }
463
- const chunks = [];
464
- const stream = response.Body;
465
- for await (const chunk of stream) {
466
- chunks.push(Buffer.from(chunk));
467
- }
468
- fs.writeFileSync(localPath, Buffer.concat(chunks));
525
+ fs.writeFileSync(localPath, objectBody);
469
526
  // Bug #5 — apply source-side mode after the byte write. See
470
527
  // FILE_MODE_META_KEY for the metadata contract. Parses defensively:
471
528
  // a malformed value falls through with no chmod so the umask default
@@ -480,7 +537,7 @@ export async function downloadFile(ctx, key, localPath) {
480
537
  // requires 1–4 pure octal digits (`[0-7]{1,4}$`), which matches what
481
538
  // the upload side stamps (`(mode & 0o777).toString(8)` → at most
482
539
  // three digits, all 0–7) and rejects everything else.
483
- const modeOctal = response.Metadata?.[FILE_MODE_META_KEY];
540
+ const modeOctal = metadata?.[FILE_MODE_META_KEY];
484
541
  if (typeof modeOctal === "string" && /^[0-7]{1,4}$/.test(modeOctal)) {
485
542
  const parsed = parseInt(modeOctal, 8);
486
543
  if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 0o777) {
@@ -519,7 +576,7 @@ export async function downloadFile(ctx, key, localPath) {
519
576
  // similarly lstats after downloadFile). If a future caller stamps the
520
577
  // journal BEFORE downloadFile completes, the fast-path will stale and
521
578
  // re-hash every sync forever — keep the call-site invariant intact.
522
- const mtimeRaw = response.Metadata?.[FILE_MTIME_META_KEY];
579
+ const mtimeRaw = metadata?.[FILE_MTIME_META_KEY];
523
580
  if (typeof mtimeRaw === "string" && /^-?[0-9]{1,16}$/.test(mtimeRaw)) {
524
581
  const mtimeMs = parseInt(mtimeRaw, 10);
525
582
  if (Number.isFinite(mtimeMs)) {
@@ -543,26 +600,15 @@ export async function downloadFile(ctx, key, localPath) {
543
600
  // The push side already emits hq-btime when the source FS tracks a
544
601
  // distinct creation time, so a future receiver upgrade picks it up
545
602
  // automatically without a server-side data migration.
546
- return { metadata: response.Metadata };
603
+ return { metadata };
547
604
  }
548
605
  export async function listRemoteFiles(ctx, prefix) {
549
- const client = buildClient(ctx);
606
+ const io = resolveObjectIO(ctx);
550
607
  const files = [];
551
608
  let continuationToken;
552
609
  do {
553
- const response = await client.send(new ListObjectsV2Command({
554
- Bucket: ctx.bucketName,
555
- Prefix: prefix,
556
- ContinuationToken: continuationToken,
557
- }));
558
- for (const obj of response.Contents || []) {
559
- // Pre-fix this guard was `!obj.Key || !obj.Size`. The `!obj.Size` test
560
- // is truthy when Size === 0 (a real 0-byte object like `.gitkeep`),
561
- // silently filtering legitimate placeholder files out of every pull
562
- // plan. Narrow the guard to "no key" only; surface real 0-byte
563
- // objects to the planner.
564
- if (!obj.Key)
565
- continue;
610
+ const page = await io.listObjects({ prefix, continuationToken });
611
+ for (const obj of page.objects) {
566
612
  // Drop S3 directory-marker objects: the canonical shape is `0-byte
567
613
  // key ending in '/'` (S3 console "Create folder", `aws s3 sync` of
568
614
  // empty dirs, sync tools that mirror empty trees). Two downstream
@@ -572,61 +618,39 @@ export async function listRemoteFiles(ctx, prefix) {
572
618
  // → EISDIR "open" after the parent mkdir creates the leaf as a
573
619
  // directory). Filtering here eliminates both.
574
620
  //
575
- // Narrow on Size===0 (not just trailing-slash) so a hypothetical
621
+ // Narrow on size===0 (not just trailing-slash) so a hypothetical
576
622
  // non-empty object whose key happens to end in '/' is NOT silently
577
623
  // hidden — it stays visible and downloadFile surfaces the same
578
624
  // EISDIR "open" error pointing at the specific key, which is the
579
625
  // signal an operator needs to reconcile the bucket. The vault
580
626
  // service doesn't have a code path that produces such an object,
581
- // but ListObjectsV2 returns whatever lives in the bucket; silent
627
+ // but the listing returns whatever lives in the bucket; silent
582
628
  // drop would be worse than loud failure for that case.
583
629
  //
584
630
  // Real 0-byte placeholders like `.gitkeep` never end in `/` and
585
631
  // continue to flow through — the 5.13.0 `.gitkeep` regression
586
- // remains fixed.
587
- if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0)
632
+ // remains fixed. (The `!key` guard now lives in the ObjectIO layer.)
633
+ if (obj.key.endsWith("/") && obj.size === 0)
588
634
  continue;
589
635
  files.push({
590
- key: obj.Key,
591
- size: obj.Size ?? 0,
592
- lastModified: obj.LastModified || new Date(),
593
- etag: obj.ETag || "",
636
+ key: obj.key,
637
+ size: obj.size,
638
+ lastModified: obj.lastModified,
639
+ etag: obj.etag,
594
640
  });
595
641
  }
596
- continuationToken = response.NextContinuationToken;
642
+ continuationToken = page.nextContinuationToken;
597
643
  } while (continuationToken);
598
644
  return files;
599
645
  }
600
646
  export async function deleteRemoteFile(ctx, key) {
601
- const client = buildClient(ctx);
602
- await client.send(new DeleteObjectCommand({
603
- Bucket: ctx.bucketName,
604
- Key: key,
605
- }));
647
+ await resolveObjectIO(ctx).deleteObject(key);
606
648
  }
607
649
  /**
608
650
  * Check if a remote key exists and return its metadata.
609
651
  */
610
652
  export async function headRemoteFile(ctx, key) {
611
- const client = buildClient(ctx);
612
- try {
613
- const response = await client.send(new HeadObjectCommand({
614
- Bucket: ctx.bucketName,
615
- Key: key,
616
- }));
617
- return {
618
- lastModified: response.LastModified || new Date(),
619
- etag: response.ETag || "",
620
- size: response.ContentLength || 0,
621
- metadata: response.Metadata,
622
- };
623
- }
624
- catch (err) {
625
- if (err && typeof err === "object" && "name" in err && err.name === "NotFound") {
626
- return null;
627
- }
628
- throw err;
629
- }
653
+ return resolveObjectIO(ctx).headObject(key);
630
654
  }
631
655
  function getMimeType(filePath) {
632
656
  const ext = path.extname(filePath).toLowerCase();