@indigoai-us/hq-cloud 6.11.11 → 6.11.12
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.
- package/dist/bin/sync-runner.d.ts +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +231 -52
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +265 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +58 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.js +138 -15
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +30 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +178 -58
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +362 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +55 -10
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +61 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +93 -6
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +59 -0
- package/dist/journal.test.js.map +1 -1
- package/dist/machine-auth.test.js +60 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +37 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +148 -29
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +121 -0
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +8 -8
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +99 -32
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +51 -4
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +8 -2
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +34 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +20 -9
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +124 -28
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +57 -2
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/remote-pull.d.ts +6 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +62 -13
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +189 -0
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +2 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +197 -116
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +109 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +3 -2
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +1 -1
- package/dist/scope-shrink.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +69 -9
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +86 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +6 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +34 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +73 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/metrics.d.ts +17 -1
- package/dist/sync/metrics.d.ts.map +1 -1
- package/dist/sync/metrics.js +32 -1
- package/dist/sync/metrics.js.map +1 -1
- package/dist/sync/metrics.test.js +74 -1
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +15 -7
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +6 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +13 -15
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +36 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +59 -6
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +74 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +36 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +152 -30
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +103 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +298 -11
- package/src/bin/sync-runner.ts +254 -52
- package/src/cli/rescue-classify-ordering.test.ts +61 -0
- package/src/cli/rescue-core.ts +174 -15
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +103 -34
- package/src/cli/sync.test.ts +435 -1
- package/src/cli/sync.ts +217 -64
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/index.ts +8 -0
- package/src/journal.test.ts +72 -0
- package/src/journal.ts +95 -8
- package/src/machine-auth.test.ts +64 -2
- package/src/object-io.test.ts +142 -0
- package/src/object-io.ts +182 -30
- package/src/operation-lock.test.ts +63 -4
- package/src/operation-lock.ts +99 -31
- package/src/personal-vault.test.ts +42 -0
- package/src/personal-vault.ts +8 -2
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +205 -0
- package/src/remote-pull.ts +77 -14
- package/src/s3.test.ts +126 -0
- package/src/s3.ts +237 -122
- package/src/scope-shrink.ts +6 -3
- package/src/skill-telemetry.test.ts +109 -0
- package/src/skill-telemetry.ts +82 -14
- package/src/sync/event-sync.test.ts +75 -0
- package/src/sync/event-sync.ts +54 -1
- package/src/sync/metrics.test.ts +81 -0
- package/src/sync/metrics.ts +59 -4
- package/src/sync/pull-scope.ts +23 -7
- package/src/sync/push-receiver.test.ts +38 -1
- package/src/sync/push-receiver.ts +15 -18
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +209 -33
package/src/remote-pull.ts
CHANGED
|
@@ -29,7 +29,13 @@ import type {
|
|
|
29
29
|
ExplicitGrant,
|
|
30
30
|
MembershipSyncConfig,
|
|
31
31
|
} from "./vault-client.js";
|
|
32
|
-
import {
|
|
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.
|
|
@@ -204,12 +221,12 @@ export function resolveCompanyScope(
|
|
|
204
221
|
};
|
|
205
222
|
}
|
|
206
223
|
|
|
207
|
-
let raw:
|
|
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 <
|
|
244
|
-
batches.push(
|
|
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
|
}
|
|
@@ -320,14 +338,15 @@ export async function listRemoteForScope(
|
|
|
320
338
|
|
|
321
339
|
if (scope.strategy === "broad-postfilter") {
|
|
322
340
|
const all = await list(ctx);
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
);
|
|
341
|
+
const scopeEntries = toScopePrefixEntries(scope.prefixSet);
|
|
342
|
+
return all.filter((f) => isCoveredByAny(f.key, scopeEntries));
|
|
326
343
|
}
|
|
327
344
|
|
|
328
345
|
// vend-fanout
|
|
329
346
|
if (scope.prefixSet.length === 0) return [];
|
|
330
|
-
const
|
|
347
|
+
const scopeEntries = toScopePrefixEntries(scope.prefixSet);
|
|
348
|
+
const listPrefixes = scopeEntries.map((entry) => entry.prefix);
|
|
349
|
+
const batches = batchPrefixesForVend(listPrefixes);
|
|
331
350
|
const perBatch = await mapWithConcurrency(
|
|
332
351
|
batches,
|
|
333
352
|
VEND_FANOUT_CONCURRENCY,
|
|
@@ -343,7 +362,9 @@ export async function listRemoteForScope(
|
|
|
343
362
|
return lists.flat();
|
|
344
363
|
},
|
|
345
364
|
);
|
|
346
|
-
return dedupByKey(
|
|
365
|
+
return dedupByKey(
|
|
366
|
+
perBatch.flat().filter((f) => isCoveredByAny(f.key, scopeEntries)),
|
|
367
|
+
);
|
|
347
368
|
}
|
|
348
369
|
|
|
349
370
|
function dedupByKey(files: RemoteFile[]): RemoteFile[] {
|
|
@@ -434,11 +455,12 @@ export async function pullCompany(
|
|
|
434
455
|
// `all` -> `shared` flip this correctly flags shared-mode orphans.
|
|
435
456
|
[companyPrefixOf(input.scope, last)];
|
|
436
457
|
|
|
458
|
+
const currentPrefixSet = input.scope.prefixSet;
|
|
437
459
|
const scopeShrinkPlan = buildScopeShrinkPlan({
|
|
438
460
|
journal: input.journal,
|
|
439
461
|
hqRoot: input.hqRoot,
|
|
440
462
|
lastPrefixSet,
|
|
441
|
-
currentPrefixSet
|
|
463
|
+
currentPrefixSet,
|
|
442
464
|
callerSub: input.callerSub,
|
|
443
465
|
// Background runner pull: protect the caller's own work and don't make a
|
|
444
466
|
// destructive guess about unknown-author (legacy) orphans. The explicit
|
|
@@ -479,6 +501,13 @@ export async function pullCompany(
|
|
|
479
501
|
remoteFiles,
|
|
480
502
|
journal: input.journal,
|
|
481
503
|
conflictKeys,
|
|
504
|
+
protectedMissingKeys: collectScopeProtectedMissingKeys({
|
|
505
|
+
journal: input.journal,
|
|
506
|
+
lastPrefixSet,
|
|
507
|
+
currentPrefixSet,
|
|
508
|
+
callerSub: input.callerSub,
|
|
509
|
+
protectUnknownAuthors: true,
|
|
510
|
+
}),
|
|
482
511
|
});
|
|
483
512
|
|
|
484
513
|
const completedAt = now().toISOString();
|
|
@@ -509,6 +538,39 @@ export async function pullCompany(
|
|
|
509
538
|
};
|
|
510
539
|
}
|
|
511
540
|
|
|
541
|
+
function collectScopeProtectedMissingKeys(input: {
|
|
542
|
+
journal: SyncJournal;
|
|
543
|
+
lastPrefixSet: readonly ScopePrefixInput[];
|
|
544
|
+
currentPrefixSet: readonly ScopePrefixInput[];
|
|
545
|
+
callerSub?: string;
|
|
546
|
+
protectUnknownAuthors: boolean;
|
|
547
|
+
}): Set<string> {
|
|
548
|
+
const protectedKeys = new Set<string>();
|
|
549
|
+
for (const [relPath, entry] of Object.entries(input.journal.files)) {
|
|
550
|
+
if (entry.removedAt) continue;
|
|
551
|
+
if (entry.direction !== "down") continue;
|
|
552
|
+
if (isCoveredByAny(relPath, input.currentPrefixSet)) {
|
|
553
|
+
if (entry.outOfScopeProtected) delete entry.outOfScopeProtected;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (entry.outOfScopeProtected) {
|
|
557
|
+
protectedKeys.add(relPath);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (!isCoveredByAny(relPath, input.lastPrefixSet)) continue;
|
|
561
|
+
if (input.callerSub && entry.createdBySub === input.callerSub) {
|
|
562
|
+
entry.outOfScopeProtected = true;
|
|
563
|
+
protectedKeys.add(relPath);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (input.protectUnknownAuthors && entry.createdBySub === undefined) {
|
|
567
|
+
entry.outOfScopeProtected = true;
|
|
568
|
+
protectedKeys.add(relPath);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return protectedKeys;
|
|
572
|
+
}
|
|
573
|
+
|
|
512
574
|
/**
|
|
513
575
|
* Recover the "company prefix" for a v1-migrated record with no recorded
|
|
514
576
|
* `prefixSet`. We derive it from the current scope's first prefix's parent
|
|
@@ -521,10 +583,11 @@ function companyPrefixOf(
|
|
|
521
583
|
_last: PullRecord | undefined,
|
|
522
584
|
): string {
|
|
523
585
|
// For `all` mode, scope.prefixSet[0] IS the company prefix.
|
|
524
|
-
|
|
586
|
+
const firstEntry = toScopePrefixEntries(scope.prefixSet)[0];
|
|
587
|
+
if (scope.strategy === "all" && firstEntry) return firstEntry.prefix;
|
|
525
588
|
// Otherwise, derive `companies/{slug}/` from the first prefix. ACL grant
|
|
526
589
|
// paths always start with `companies/{slug}/...`.
|
|
527
|
-
const first =
|
|
590
|
+
const first = firstEntry?.prefix ?? "";
|
|
528
591
|
const m = first.match(/^(companies\/[^/]+\/)/);
|
|
529
592
|
return m ? m[1]! : first;
|
|
530
593
|
}
|
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
|