@indigoai-us/hq-cloud 6.11.11 → 6.11.13

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 (220) hide show
  1. package/dist/bin/sync-runner-company.d.ts +35 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -0
  3. package/dist/bin/sync-runner-company.js +290 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -0
  5. package/dist/bin/sync-runner-events.d.ts +12 -0
  6. package/dist/bin/sync-runner-events.d.ts.map +1 -0
  7. package/dist/bin/sync-runner-events.js +12 -0
  8. package/dist/bin/sync-runner-events.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +53 -0
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -0
  11. package/dist/bin/sync-runner-planning.js +59 -0
  12. package/dist/bin/sync-runner-planning.js.map +1 -0
  13. package/dist/bin/sync-runner-rollup.d.ts +24 -0
  14. package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-rollup.js +46 -0
  16. package/dist/bin/sync-runner-rollup.js.map +1 -0
  17. package/dist/bin/sync-runner-telemetry.d.ts +5 -0
  18. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
  19. package/dist/bin/sync-runner-telemetry.js +5 -0
  20. package/dist/bin/sync-runner-telemetry.js.map +1 -0
  21. package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
  23. package/dist/bin/sync-runner-watch-loop.js +372 -0
  24. package/dist/bin/sync-runner-watch-loop.js.map +1 -0
  25. package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
  26. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
  27. package/dist/bin/sync-runner-watch-routes.js +74 -0
  28. package/dist/bin/sync-runner-watch-routes.js.map +1 -0
  29. package/dist/bin/sync-runner.d.ts +5 -54
  30. package/dist/bin/sync-runner.d.ts.map +1 -1
  31. package/dist/bin/sync-runner.js +76 -978
  32. package/dist/bin/sync-runner.js.map +1 -1
  33. package/dist/bin/sync-runner.test.js +265 -11
  34. package/dist/bin/sync-runner.test.js.map +1 -1
  35. package/dist/cli/reindex.d.ts.map +1 -1
  36. package/dist/cli/reindex.js +34 -17
  37. package/dist/cli/reindex.js.map +1 -1
  38. package/dist/cli/reindex.test.js +39 -5
  39. package/dist/cli/reindex.test.js.map +1 -1
  40. package/dist/cli/rescue-classify-ordering.test.js +75 -0
  41. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  42. package/dist/cli/rescue-core.d.ts +45 -0
  43. package/dist/cli/rescue-core.d.ts.map +1 -1
  44. package/dist/cli/rescue-core.js +320 -170
  45. package/dist/cli/rescue-core.js.map +1 -1
  46. package/dist/cli/share.d.ts +2 -1
  47. package/dist/cli/share.d.ts.map +1 -1
  48. package/dist/cli/share.js +276 -660
  49. package/dist/cli/share.js.map +1 -1
  50. package/dist/cli/share.test.js +30 -0
  51. package/dist/cli/share.test.js.map +1 -1
  52. package/dist/cli/sync.d.ts +28 -1
  53. package/dist/cli/sync.d.ts.map +1 -1
  54. package/dist/cli/sync.js +541 -748
  55. package/dist/cli/sync.js.map +1 -1
  56. package/dist/cli/sync.test.js +382 -1
  57. package/dist/cli/sync.test.js.map +1 -1
  58. package/dist/cognito-auth.d.ts.map +1 -1
  59. package/dist/cognito-auth.js +55 -10
  60. package/dist/cognito-auth.js.map +1 -1
  61. package/dist/cognito-auth.test.js +61 -0
  62. package/dist/cognito-auth.test.js.map +1 -1
  63. package/dist/daemon-worker.d.ts +2 -2
  64. package/dist/daemon-worker.js +3 -3
  65. package/dist/daemon-worker.js.map +1 -1
  66. package/dist/index.d.ts +2 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/journal.d.ts.map +1 -1
  71. package/dist/journal.js +93 -6
  72. package/dist/journal.js.map +1 -1
  73. package/dist/journal.test.js +59 -0
  74. package/dist/journal.test.js.map +1 -1
  75. package/dist/machine-auth.test.js +60 -2
  76. package/dist/machine-auth.test.js.map +1 -1
  77. package/dist/object-io.d.ts +37 -1
  78. package/dist/object-io.d.ts.map +1 -1
  79. package/dist/object-io.js +149 -30
  80. package/dist/object-io.js.map +1 -1
  81. package/dist/object-io.test.js +121 -0
  82. package/dist/object-io.test.js.map +1 -1
  83. package/dist/operation-lock.d.ts +8 -8
  84. package/dist/operation-lock.d.ts.map +1 -1
  85. package/dist/operation-lock.js +99 -32
  86. package/dist/operation-lock.js.map +1 -1
  87. package/dist/operation-lock.test.js +51 -4
  88. package/dist/operation-lock.test.js.map +1 -1
  89. package/dist/personal-vault.d.ts.map +1 -1
  90. package/dist/personal-vault.js +8 -2
  91. package/dist/personal-vault.js.map +1 -1
  92. package/dist/personal-vault.test.js +34 -0
  93. package/dist/personal-vault.test.js.map +1 -1
  94. package/dist/prefix-coalesce.d.ts +20 -9
  95. package/dist/prefix-coalesce.d.ts.map +1 -1
  96. package/dist/prefix-coalesce.js +124 -28
  97. package/dist/prefix-coalesce.js.map +1 -1
  98. package/dist/prefix-coalesce.test.js +57 -2
  99. package/dist/prefix-coalesce.test.js.map +1 -1
  100. package/dist/remote-pull.d.ts +8 -3
  101. package/dist/remote-pull.d.ts.map +1 -1
  102. package/dist/remote-pull.js +85 -16
  103. package/dist/remote-pull.js.map +1 -1
  104. package/dist/remote-pull.test.js +213 -2
  105. package/dist/remote-pull.test.js.map +1 -1
  106. package/dist/s3.d.ts +2 -0
  107. package/dist/s3.d.ts.map +1 -1
  108. package/dist/s3.js +197 -116
  109. package/dist/s3.js.map +1 -1
  110. package/dist/s3.test.js +109 -0
  111. package/dist/s3.test.js.map +1 -1
  112. package/dist/scope-shrink.d.ts +3 -2
  113. package/dist/scope-shrink.d.ts.map +1 -1
  114. package/dist/scope-shrink.js +1 -1
  115. package/dist/scope-shrink.js.map +1 -1
  116. package/dist/skill-telemetry.d.ts +1 -1
  117. package/dist/skill-telemetry.d.ts.map +1 -1
  118. package/dist/skill-telemetry.js +69 -9
  119. package/dist/skill-telemetry.js.map +1 -1
  120. package/dist/skill-telemetry.test.js +86 -0
  121. package/dist/skill-telemetry.test.js.map +1 -1
  122. package/dist/sync/event-sync.d.ts +6 -0
  123. package/dist/sync/event-sync.d.ts.map +1 -1
  124. package/dist/sync/event-sync.js +34 -1
  125. package/dist/sync/event-sync.js.map +1 -1
  126. package/dist/sync/event-sync.test.js +73 -0
  127. package/dist/sync/event-sync.test.js.map +1 -1
  128. package/dist/sync/metrics.d.ts +17 -1
  129. package/dist/sync/metrics.d.ts.map +1 -1
  130. package/dist/sync/metrics.js +32 -1
  131. package/dist/sync/metrics.js.map +1 -1
  132. package/dist/sync/metrics.test.js +74 -1
  133. package/dist/sync/metrics.test.js.map +1 -1
  134. package/dist/sync/pull-scope.d.ts.map +1 -1
  135. package/dist/sync/pull-scope.js +15 -7
  136. package/dist/sync/pull-scope.js.map +1 -1
  137. package/dist/sync/push-receiver.d.ts +12 -5
  138. package/dist/sync/push-receiver.d.ts.map +1 -1
  139. package/dist/sync/push-receiver.js +45 -17
  140. package/dist/sync/push-receiver.js.map +1 -1
  141. package/dist/sync/push-receiver.test.js +67 -1
  142. package/dist/sync/push-receiver.test.js.map +1 -1
  143. package/dist/sync-core.d.ts +27 -0
  144. package/dist/sync-core.d.ts.map +1 -0
  145. package/dist/sync-core.js +54 -0
  146. package/dist/sync-core.js.map +1 -0
  147. package/dist/telemetry.d.ts +1 -1
  148. package/dist/telemetry.d.ts.map +1 -1
  149. package/dist/telemetry.js +59 -6
  150. package/dist/telemetry.js.map +1 -1
  151. package/dist/telemetry.test.js +74 -0
  152. package/dist/telemetry.test.js.map +1 -1
  153. package/dist/types.d.ts +8 -0
  154. package/dist/types.d.ts.map +1 -1
  155. package/dist/vault-client.d.ts.map +1 -1
  156. package/dist/vault-client.js +284 -36
  157. package/dist/vault-client.js.map +1 -1
  158. package/dist/vault-client.test.js +59 -0
  159. package/dist/vault-client.test.js.map +1 -1
  160. package/dist/watcher.d.ts +38 -20
  161. package/dist/watcher.d.ts.map +1 -1
  162. package/dist/watcher.js +155 -143
  163. package/dist/watcher.js.map +1 -1
  164. package/dist/watcher.test.js +103 -0
  165. package/dist/watcher.test.js.map +1 -1
  166. package/package.json +1 -1
  167. package/src/bin/sync-runner-company.ts +350 -0
  168. package/src/bin/sync-runner-events.ts +25 -0
  169. package/src/bin/sync-runner-planning.ts +121 -0
  170. package/src/bin/sync-runner-rollup.ts +72 -0
  171. package/src/bin/sync-runner-telemetry.ts +8 -0
  172. package/src/bin/sync-runner-watch-loop.ts +443 -0
  173. package/src/bin/sync-runner-watch-routes.ts +86 -0
  174. package/src/bin/sync-runner.test.ts +298 -11
  175. package/src/bin/sync-runner.ts +99 -1054
  176. package/src/cli/reindex.test.ts +41 -3
  177. package/src/cli/reindex.ts +35 -19
  178. package/src/cli/rescue-classify-ordering.test.ts +81 -0
  179. package/src/cli/rescue-core.ts +400 -165
  180. package/src/cli/share.test.ts +38 -0
  181. package/src/cli/share.ts +420 -693
  182. package/src/cli/sync.test.ts +460 -1
  183. package/src/cli/sync.ts +788 -825
  184. package/src/cognito-auth.test.ts +77 -0
  185. package/src/cognito-auth.ts +73 -11
  186. package/src/daemon-worker.ts +3 -3
  187. package/src/index.ts +8 -0
  188. package/src/journal.test.ts +72 -0
  189. package/src/journal.ts +95 -8
  190. package/src/machine-auth.test.ts +64 -2
  191. package/src/object-io.test.ts +142 -0
  192. package/src/object-io.ts +183 -31
  193. package/src/operation-lock.test.ts +63 -4
  194. package/src/operation-lock.ts +99 -31
  195. package/src/personal-vault.test.ts +42 -0
  196. package/src/personal-vault.ts +8 -2
  197. package/src/prefix-coalesce.test.ts +71 -1
  198. package/src/prefix-coalesce.ts +155 -30
  199. package/src/remote-pull.test.ts +235 -1
  200. package/src/remote-pull.ts +106 -18
  201. package/src/s3.test.ts +126 -0
  202. package/src/s3.ts +237 -122
  203. package/src/scope-shrink.ts +6 -3
  204. package/src/skill-telemetry.test.ts +109 -0
  205. package/src/skill-telemetry.ts +82 -14
  206. package/src/sync/event-sync.test.ts +75 -0
  207. package/src/sync/event-sync.ts +54 -1
  208. package/src/sync/metrics.test.ts +81 -0
  209. package/src/sync/metrics.ts +59 -4
  210. package/src/sync/pull-scope.ts +23 -7
  211. package/src/sync/push-receiver.test.ts +73 -1
  212. package/src/sync/push-receiver.ts +56 -20
  213. package/src/sync-core.ts +58 -0
  214. package/src/telemetry.test.ts +85 -0
  215. package/src/telemetry.ts +69 -6
  216. package/src/types.ts +8 -0
  217. package/src/vault-client.test.ts +74 -0
  218. package/src/vault-client.ts +395 -43
  219. package/src/watcher.test.ts +117 -0
  220. package/src/watcher.ts +215 -174
@@ -8,7 +8,7 @@
8
8
  * sync-runner.ts trivial: list S3 → call `decideRemotePulls` → drive S3 +
9
9
  * filesystem from the result.
10
10
  *
11
- * Pairs with `SyncWatcher` (push-side) — together they implement the
11
+ * Pairs with the TreeWatcher push path — together they implement the
12
12
  * bidirectional auto-sync the Settings toggle exposes.
13
13
  */
14
14
  import type { RemoteFile } from "./s3.js";
@@ -29,7 +29,13 @@ import type {
29
29
  ExplicitGrant,
30
30
  MembershipSyncConfig,
31
31
  } from "./vault-client.js";
32
- import { coalescePrefixes } from "./prefix-coalesce.js";
32
+ import {
33
+ coalescePrefixes,
34
+ isCoveredByAny,
35
+ pathToScopePrefix,
36
+ toScopePrefixEntries,
37
+ type ScopePrefixInput,
38
+ } from "./prefix-coalesce.js";
33
39
  import {
34
40
  applyScopeShrink,
35
41
  buildScopeShrinkPlan,
@@ -73,12 +79,18 @@ export interface DecideRemotePullsInput {
73
79
  * conflict resolution can't be silently overwritten.
74
80
  */
75
81
  conflictKeys: Set<string>;
82
+ /**
83
+ * Journal keys intentionally retained by scope-shrink authorship guards
84
+ * even though they are outside the current remote listing scope.
85
+ */
86
+ protectedMissingKeys?: Set<string>;
76
87
  }
77
88
 
78
89
  export function decideRemotePulls({
79
90
  remoteFiles,
80
91
  journal,
81
92
  conflictKeys,
93
+ protectedMissingKeys = new Set<string>(),
82
94
  }: DecideRemotePullsInput): RemotePullDecision {
83
95
  const download: RemoteFile[] = [];
84
96
  const skip: SkippedKey[] = [];
@@ -111,6 +123,11 @@ export function decideRemotePulls({
111
123
  // Tombstone pass: anything in the journal that's no longer remote.
112
124
  for (const relativePath of Object.keys(journal.files)) {
113
125
  if (seenRemote.has(relativePath)) continue;
126
+ if (journal.files[relativePath]?.removedAt) continue;
127
+ if (protectedMissingKeys.has(relativePath)) {
128
+ skip.push({ key: relativePath });
129
+ continue;
130
+ }
114
131
  if (conflictKeys.has(relativePath)) {
115
132
  // Remote tombstone for a file the user is conflict-resolving locally.
116
133
  // Skip — record so callers can log/report, but do NOT delete.
@@ -143,7 +160,7 @@ export const VEND_PATH_CAP = 10;
143
160
  */
144
161
  export const POST_FILTER_THRESHOLD = 50;
145
162
 
146
- /** Bounded parallelism for vend fan-out (5 concurrent STS+ListObjectsV2 calls). */
163
+ /** Bounded parallelism for vend fan-out (5 concurrent vends/list paginators). */
147
164
  export const VEND_FANOUT_CONCURRENCY = 5;
148
165
 
149
166
  /**
@@ -204,12 +221,12 @@ export function resolveCompanyScope(
204
221
  };
205
222
  }
206
223
 
207
- let raw: string[];
224
+ let raw: ScopePrefixInput[];
208
225
  if (syncConfig.syncMode === "custom") {
209
- raw = syncConfig.customPaths ?? [];
226
+ raw = (syncConfig.customPaths ?? []).map(pathToScopePrefix);
210
227
  } else {
211
228
  // 'shared'
212
- raw = (explicitGrants ?? []).map((g) => g.path);
229
+ raw = (explicitGrants ?? []).map((g) => pathToScopePrefix(g.path));
213
230
  }
214
231
  const prefixSet = coalescePrefixes(raw);
215
232
 
@@ -239,9 +256,10 @@ export function batchPrefixesForVend(
239
256
  cap: number = VEND_PATH_CAP,
240
257
  ): string[][] {
241
258
  if (cap <= 0) throw new Error(`batchPrefixesForVend: cap must be > 0`);
259
+ const vendPrefixes = toScopePrefixEntries(prefixes).map((entry) => entry.prefix);
242
260
  const batches: string[][] = [];
243
- for (let i = 0; i < prefixes.length; i += cap) {
244
- batches.push(prefixes.slice(i, i + cap));
261
+ for (let i = 0; i < vendPrefixes.length; i += cap) {
262
+ batches.push(vendPrefixes.slice(i, i + cap));
245
263
  }
246
264
  return batches;
247
265
  }
@@ -273,6 +291,27 @@ async function mapWithConcurrency<T, R>(
273
291
  return results;
274
292
  }
275
293
 
294
+ function createConcurrencyLimiter(
295
+ concurrency: number,
296
+ ): <R>(fn: () => Promise<R>) => Promise<R> {
297
+ const limit = Math.max(1, concurrency);
298
+ let active = 0;
299
+ const waiters: Array<() => void> = [];
300
+
301
+ return async function runLimited<R>(fn: () => Promise<R>): Promise<R> {
302
+ if (active >= limit) {
303
+ await new Promise<void>((resolve) => waiters.push(resolve));
304
+ }
305
+ active += 1;
306
+ try {
307
+ return await fn();
308
+ } finally {
309
+ active -= 1;
310
+ waiters.shift()?.();
311
+ }
312
+ };
313
+ }
314
+
276
315
  export interface ListRemoteForScopeInput {
277
316
  ctx: EntityContext;
278
317
  scope: CompanyScope;
@@ -320,14 +359,16 @@ export async function listRemoteForScope(
320
359
 
321
360
  if (scope.strategy === "broad-postfilter") {
322
361
  const all = await list(ctx);
323
- return all.filter((f) =>
324
- scope.prefixSet.some((p) => f.key.startsWith(p)),
325
- );
362
+ const scopeEntries = toScopePrefixEntries(scope.prefixSet);
363
+ return all.filter((f) => isCoveredByAny(f.key, scopeEntries));
326
364
  }
327
365
 
328
366
  // vend-fanout
329
367
  if (scope.prefixSet.length === 0) return [];
330
- const batches = batchPrefixesForVend(scope.prefixSet);
368
+ const scopeEntries = toScopePrefixEntries(scope.prefixSet);
369
+ const listPrefixes = scopeEntries.map((entry) => entry.prefix);
370
+ const batches = batchPrefixesForVend(listPrefixes);
371
+ const listWithLimit = createConcurrencyLimiter(VEND_FANOUT_CONCURRENCY);
331
372
  const perBatch = await mapWithConcurrency(
332
373
  batches,
333
374
  VEND_FANOUT_CONCURRENCY,
@@ -338,12 +379,17 @@ export async function listRemoteForScope(
338
379
  // For a coalesced batch we issue one ListObjectsV2 per prefix in the
339
380
  // batch. We can't issue one ListObjectsV2 across N prefixes (the API
340
381
  // takes a single Prefix); the per-batch grouping exists for the STS
341
- // session policy ceiling, not the list call itself.
342
- const lists = await Promise.all(paths.map((p) => list(batchCtx, p)));
382
+ // session policy ceiling, not the list call itself. The shared limiter
383
+ // keeps the total active prefix paginators bounded across all batches.
384
+ const lists = await Promise.all(
385
+ paths.map((p) => listWithLimit(() => list(batchCtx, p))),
386
+ );
343
387
  return lists.flat();
344
388
  },
345
389
  );
346
- return dedupByKey(perBatch.flat());
390
+ return dedupByKey(
391
+ perBatch.flat().filter((f) => isCoveredByAny(f.key, scopeEntries)),
392
+ );
347
393
  }
348
394
 
349
395
  function dedupByKey(files: RemoteFile[]): RemoteFile[] {
@@ -434,11 +480,12 @@ export async function pullCompany(
434
480
  // `all` -> `shared` flip this correctly flags shared-mode orphans.
435
481
  [companyPrefixOf(input.scope, last)];
436
482
 
483
+ const currentPrefixSet = input.scope.prefixSet;
437
484
  const scopeShrinkPlan = buildScopeShrinkPlan({
438
485
  journal: input.journal,
439
486
  hqRoot: input.hqRoot,
440
487
  lastPrefixSet,
441
- currentPrefixSet: input.scope.prefixSet,
488
+ currentPrefixSet,
442
489
  callerSub: input.callerSub,
443
490
  // Background runner pull: protect the caller's own work and don't make a
444
491
  // destructive guess about unknown-author (legacy) orphans. The explicit
@@ -479,6 +526,13 @@ export async function pullCompany(
479
526
  remoteFiles,
480
527
  journal: input.journal,
481
528
  conflictKeys,
529
+ protectedMissingKeys: collectScopeProtectedMissingKeys({
530
+ journal: input.journal,
531
+ lastPrefixSet,
532
+ currentPrefixSet,
533
+ callerSub: input.callerSub,
534
+ protectUnknownAuthors: true,
535
+ }),
482
536
  });
483
537
 
484
538
  const completedAt = now().toISOString();
@@ -509,6 +563,39 @@ export async function pullCompany(
509
563
  };
510
564
  }
511
565
 
566
+ function collectScopeProtectedMissingKeys(input: {
567
+ journal: SyncJournal;
568
+ lastPrefixSet: readonly ScopePrefixInput[];
569
+ currentPrefixSet: readonly ScopePrefixInput[];
570
+ callerSub?: string;
571
+ protectUnknownAuthors: boolean;
572
+ }): Set<string> {
573
+ const protectedKeys = new Set<string>();
574
+ for (const [relPath, entry] of Object.entries(input.journal.files)) {
575
+ if (entry.removedAt) continue;
576
+ if (entry.direction !== "down") continue;
577
+ if (isCoveredByAny(relPath, input.currentPrefixSet)) {
578
+ if (entry.outOfScopeProtected) delete entry.outOfScopeProtected;
579
+ continue;
580
+ }
581
+ if (entry.outOfScopeProtected) {
582
+ protectedKeys.add(relPath);
583
+ continue;
584
+ }
585
+ if (!isCoveredByAny(relPath, input.lastPrefixSet)) continue;
586
+ if (input.callerSub && entry.createdBySub === input.callerSub) {
587
+ entry.outOfScopeProtected = true;
588
+ protectedKeys.add(relPath);
589
+ continue;
590
+ }
591
+ if (input.protectUnknownAuthors && entry.createdBySub === undefined) {
592
+ entry.outOfScopeProtected = true;
593
+ protectedKeys.add(relPath);
594
+ }
595
+ }
596
+ return protectedKeys;
597
+ }
598
+
512
599
  /**
513
600
  * Recover the "company prefix" for a v1-migrated record with no recorded
514
601
  * `prefixSet`. We derive it from the current scope's first prefix's parent
@@ -521,10 +608,11 @@ function companyPrefixOf(
521
608
  _last: PullRecord | undefined,
522
609
  ): string {
523
610
  // For `all` mode, scope.prefixSet[0] IS the company prefix.
524
- if (scope.strategy === "all" && scope.prefixSet[0]) return scope.prefixSet[0];
611
+ const firstEntry = toScopePrefixEntries(scope.prefixSet)[0];
612
+ if (scope.strategy === "all" && firstEntry) return firstEntry.prefix;
525
613
  // Otherwise, derive `companies/{slug}/` from the first prefix. ACL grant
526
614
  // paths always start with `companies/{slug}/...`.
527
- const first = scope.prefixSet[0] ?? "";
615
+ const first = firstEntry?.prefix ?? "";
528
616
  const m = first.match(/^(companies\/[^/]+\/)/);
529
617
  return m ? m[1]! : first;
530
618
  }
package/src/s3.test.ts CHANGED
@@ -10,6 +10,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
10
  import * as fs from "fs";
11
11
  import * as os from "os";
12
12
  import * as path from "path";
13
+ import * as crypto from "crypto";
13
14
 
14
15
  // Capture every command sent to the S3Client across the test suite. Cleared
15
16
  // in beforeEach so per-test assertions don't leak from neighbours.
@@ -96,6 +97,7 @@ import {
96
97
  import {
97
98
  setObjectIOFactory,
98
99
  presignObjectIOFactory,
100
+ type ObjectIO,
99
101
  type PresignTransportClient,
100
102
  } from "./object-io.js";
101
103
  import type { PresignResultRow } from "./vault-client.js";
@@ -896,6 +898,130 @@ describe("downloadFile", () => {
896
898
  expect(fs.readlinkSync(localPath)).toBe("fresh-target.md");
897
899
  });
898
900
 
901
+ it("F11: failed symlink downloads preserve the previous local file", async () => {
902
+ const localPath = path.join(tmpRoot, "preserve-on-failure.md");
903
+ fs.writeFileSync(localPath, "existing local copy");
904
+
905
+ nextGetObjectResponse = {
906
+ Body: (async function* () {
907
+ yield new TextEncoder().encode(SYMLINK_BODY_PREFIX + "x".repeat(10_000));
908
+ })(),
909
+ Metadata: { "hq-symlink-target": SYMLINK_MARKER_META_VALUE },
910
+ };
911
+
912
+ await expect(
913
+ downloadFile(makeCtx(), "preserve-on-failure.md", localPath),
914
+ ).rejects.toThrow();
915
+
916
+ expect(fs.existsSync(localPath)).toBe(true);
917
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(false);
918
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("existing local copy");
919
+ });
920
+
921
+ it("R-F11: downloads to a near component-limit basename via a bounded temp name", async () => {
922
+ nextGetObjectResponse = {
923
+ Body: (async function* () {
924
+ yield Buffer.from("near-limit bytes");
925
+ })(),
926
+ Metadata: {},
927
+ };
928
+
929
+ const localPath = path.join(tmpRoot, "x".repeat(240));
930
+ await downloadFile(makeCtx(), "near-limit.bin", localPath);
931
+
932
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("near-limit bytes");
933
+ });
934
+
935
+ it("F20: regular downloads do not decode the full body as UTF-8", async () => {
936
+ const body = Buffer.alloc(64 * 1024, 0x61);
937
+ const originalToString = body.toString;
938
+ body.toString = ((encoding?: BufferEncoding, start?: number, end?: number) => {
939
+ const sliceStart = start ?? 0;
940
+ const sliceEnd = end ?? body.length;
941
+ if (sliceEnd - sliceStart > SYMLINK_BODY_PREFIX.length) {
942
+ throw new Error("full-body UTF-8 decode attempted");
943
+ }
944
+ return originalToString.call(body, encoding, start, end);
945
+ }) as Buffer["toString"];
946
+
947
+ const io: ObjectIO = {
948
+ putObject: async () => ({ etag: '"unused"' }),
949
+ getObject: async () => ({ body, metadata: {} }),
950
+ listObjects: async () => ({ objects: [] }),
951
+ deleteObject: async () => undefined,
952
+ headObject: async () => null,
953
+ };
954
+
955
+ const localPath = path.join(tmpRoot, "large-regular.bin");
956
+ setObjectIOFactory(() => io);
957
+ try {
958
+ await downloadFile(makeCtx(), "large-regular.bin", localPath);
959
+ } finally {
960
+ setObjectIOFactory(null);
961
+ }
962
+
963
+ const written = fs.readFileSync(localPath);
964
+ expect(written.length).toBe(body.length);
965
+ expect(written[0]).toBe(0x61);
966
+ expect(written[written.length - 1]).toBe(0x61);
967
+ });
968
+
969
+ it("R-F20: streams regular downloads with hash metadata and still detects symlink records", async () => {
970
+ const regularChunks = [
971
+ Buffer.from("alpha-"),
972
+ Buffer.from("beta-"),
973
+ Buffer.from("gamma"),
974
+ ];
975
+ const regularBody = Buffer.concat(regularChunks);
976
+ const linkTarget = "../target.md";
977
+
978
+ const io: ObjectIO = {
979
+ putObject: async () => ({ etag: '"unused"' }),
980
+ getObject: async () => {
981
+ throw new Error("buffered getObject attempted");
982
+ },
983
+ getObjectStream: async (key: string) => {
984
+ if (key === "streamed-link") {
985
+ return {
986
+ body: (async function* () {
987
+ yield Buffer.from("hq-");
988
+ yield Buffer.from("symlink:");
989
+ yield Buffer.from(linkTarget);
990
+ })(),
991
+ metadata: {},
992
+ };
993
+ }
994
+ return {
995
+ body: (async function* () {
996
+ for (const chunk of regularChunks) yield chunk;
997
+ })(),
998
+ metadata: {},
999
+ };
1000
+ },
1001
+ listObjects: async () => ({ objects: [] }),
1002
+ deleteObject: async () => undefined,
1003
+ headObject: async () => null,
1004
+ };
1005
+
1006
+ setObjectIOFactory(() => io);
1007
+ try {
1008
+ const localPath = path.join(tmpRoot, "streamed.bin");
1009
+ const result = await downloadFile(makeCtx(), "streamed.bin", localPath);
1010
+ expect(fs.readFileSync(localPath)).toEqual(regularBody);
1011
+ expect(result.contentHash).toBe(
1012
+ crypto.createHash("sha256").update(regularBody).digest("hex"),
1013
+ );
1014
+ expect(result.contentSize).toBe(regularBody.length);
1015
+
1016
+ const linkPath = path.join(tmpRoot, "streamed-link");
1017
+ await downloadFile(makeCtx(), "streamed-link", linkPath);
1018
+ expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
1019
+ expect(fs.readlinkSync(linkPath)).toBe(linkTarget);
1020
+ } finally {
1021
+ setObjectIOFactory(null);
1022
+ }
1023
+ });
1024
+
899
1025
  it("applies hq-mode metadata via chmod after byte write (Bug #5 — preserve permissions)", async () => {
900
1026
  // Round-trip pair to the s3.upload test: source-side mode lives in
901
1027
  // \`Metadata['hq-mode']\` as an octal string; the receiver must chmod