@indigoai-us/hq-cloud 6.11.12 → 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.
- package/dist/bin/sync-runner-company.d.ts +35 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.js +290 -0
- package/dist/bin/sync-runner-company.js.map +1 -0
- package/dist/bin/sync-runner-events.d.ts +12 -0
- package/dist/bin/sync-runner-events.d.ts.map +1 -0
- package/dist/bin/sync-runner-events.js +12 -0
- package/dist/bin/sync-runner-events.js.map +1 -0
- package/dist/bin/sync-runner-planning.d.ts +53 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.js +59 -0
- package/dist/bin/sync-runner-planning.js.map +1 -0
- package/dist/bin/sync-runner-rollup.d.ts +24 -0
- package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
- package/dist/bin/sync-runner-rollup.js +46 -0
- package/dist/bin/sync-runner-rollup.js.map +1 -0
- package/dist/bin/sync-runner-telemetry.d.ts +5 -0
- package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
- package/dist/bin/sync-runner-telemetry.js +5 -0
- package/dist/bin/sync-runner-telemetry.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-loop.js +372 -0
- package/dist/bin/sync-runner-watch-loop.js.map +1 -0
- package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
- package/dist/bin/sync-runner-watch-routes.js +74 -0
- package/dist/bin/sync-runner-watch-routes.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +3 -54
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +73 -1154
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +34 -17
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +39 -5
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +17 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +45 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +197 -170
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +224 -676
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +399 -726
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +20 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/daemon-worker.d.ts +2 -2
- package/dist/daemon-worker.js +3 -3
- package/dist/daemon-worker.js.map +1 -1
- package/dist/object-io.js +1 -1
- package/dist/object-io.js.map +1 -1
- package/dist/remote-pull.d.ts +2 -2
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +23 -3
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +24 -2
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +6 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +32 -2
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +31 -0
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync-core.d.ts +27 -0
- package/dist/sync-core.d.ts.map +1 -0
- package/dist/sync-core.js +54 -0
- package/dist/sync-core.js.map +1 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +284 -36
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +59 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +2 -20
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +3 -113
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +350 -0
- package/src/bin/sync-runner-events.ts +25 -0
- package/src/bin/sync-runner-planning.ts +121 -0
- package/src/bin/sync-runner-rollup.ts +72 -0
- package/src/bin/sync-runner-telemetry.ts +8 -0
- package/src/bin/sync-runner-watch-loop.ts +443 -0
- package/src/bin/sync-runner-watch-routes.ts +86 -0
- package/src/bin/sync-runner.ts +96 -1253
- package/src/cli/reindex.test.ts +41 -3
- package/src/cli/reindex.ts +35 -19
- package/src/cli/rescue-classify-ordering.test.ts +20 -0
- package/src/cli/rescue-core.ts +252 -176
- package/src/cli/share.ts +363 -705
- package/src/cli/sync.test.ts +25 -0
- package/src/cli/sync.ts +612 -802
- package/src/daemon-worker.ts +3 -3
- package/src/object-io.ts +1 -1
- package/src/remote-pull.test.ts +30 -1
- package/src/remote-pull.ts +29 -4
- package/src/sync/push-receiver.test.ts +35 -0
- package/src/sync/push-receiver.ts +41 -2
- package/src/sync-core.ts +58 -0
- package/src/vault-client.test.ts +74 -0
- package/src/vault-client.ts +395 -43
- package/src/watcher.ts +6 -141
package/src/daemon-worker.ts
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Day 1: not invoked by CLI surface; retained for future automatic-sync milestone.
|
|
6
6
|
* When re-enabled, this worker will need to resolve an EntityContext before
|
|
7
|
-
* constructing the
|
|
8
|
-
* context (slug or UID) and vault-service config.
|
|
7
|
+
* constructing the active watcher/runner path. The process argv will need to
|
|
8
|
+
* include company context (slug or UID) and vault-service config.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
// Day 1:
|
|
11
|
+
// Day 1: this worker still requires EntityContext-aware startup wiring.
|
|
12
12
|
// This file is retained for the automatic-sync milestone but is not functional
|
|
13
13
|
// until the daemon startup path is updated to resolve entity context.
|
|
14
14
|
|
package/src/object-io.ts
CHANGED
package/src/remote-pull.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Failing-test seed for the Auto-sync (Beta) remote-pull loop.
|
|
3
3
|
*
|
|
4
|
-
* Background:
|
|
4
|
+
* Background: the watcher push path ships local edits to S3 in seconds,
|
|
5
5
|
* but pulls happen only on a manual sync today. Auto-sync adds a periodic
|
|
6
6
|
* (every 10 min) remote-pull pass per company. The decision of *which* keys
|
|
7
7
|
* to download / delete locally / skip is pure given a remote listing, the
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
POST_FILTER_THRESHOLD,
|
|
26
26
|
pullCompany,
|
|
27
27
|
resolveCompanyScope,
|
|
28
|
+
VEND_FANOUT_CONCURRENCY,
|
|
28
29
|
VEND_PATH_CAP,
|
|
29
30
|
} from "./remote-pull.js";
|
|
30
31
|
import type { RemoteFile } from "./s3.js";
|
|
@@ -579,6 +580,34 @@ describe("listRemoteForScope", () => {
|
|
|
579
580
|
expect(calls.size).toBe(VEND_PATH_CAP + 3); // every prefix listed
|
|
580
581
|
});
|
|
581
582
|
|
|
583
|
+
it("F28: bounds concurrent per-prefix listings across vend-fanout batches", async () => {
|
|
584
|
+
const prefixes = Array.from(
|
|
585
|
+
{ length: POST_FILTER_THRESHOLD },
|
|
586
|
+
(_, i) => `companies/indigo/p${i}/`,
|
|
587
|
+
);
|
|
588
|
+
let active = 0;
|
|
589
|
+
let maxActive = 0;
|
|
590
|
+
|
|
591
|
+
await listRemoteForScope({
|
|
592
|
+
ctx: makeCtx(),
|
|
593
|
+
scope: {
|
|
594
|
+
companyUid: "cmp_indigo",
|
|
595
|
+
syncMode: "shared",
|
|
596
|
+
prefixSet: prefixes,
|
|
597
|
+
strategy: "vend-fanout",
|
|
598
|
+
},
|
|
599
|
+
listFn: async () => {
|
|
600
|
+
active += 1;
|
|
601
|
+
maxActive = Math.max(maxActive, active);
|
|
602
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
603
|
+
active -= 1;
|
|
604
|
+
return [];
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
expect(maxActive).toBeLessThanOrEqual(VEND_FANOUT_CONCURRENCY);
|
|
609
|
+
});
|
|
610
|
+
|
|
582
611
|
it("strategy=vend-fanout uses vendForBatchFn to narrow credentials per batch", async () => {
|
|
583
612
|
const vendCalls: Array<{ paths: string[] }> = [];
|
|
584
613
|
await listRemoteForScope({
|
package/src/remote-pull.ts
CHANGED
|
@@ -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
|
|
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";
|
|
@@ -160,7 +160,7 @@ export const VEND_PATH_CAP = 10;
|
|
|
160
160
|
*/
|
|
161
161
|
export const POST_FILTER_THRESHOLD = 50;
|
|
162
162
|
|
|
163
|
-
/** Bounded parallelism for vend fan-out (5 concurrent
|
|
163
|
+
/** Bounded parallelism for vend fan-out (5 concurrent vends/list paginators). */
|
|
164
164
|
export const VEND_FANOUT_CONCURRENCY = 5;
|
|
165
165
|
|
|
166
166
|
/**
|
|
@@ -291,6 +291,27 @@ async function mapWithConcurrency<T, R>(
|
|
|
291
291
|
return results;
|
|
292
292
|
}
|
|
293
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
|
+
|
|
294
315
|
export interface ListRemoteForScopeInput {
|
|
295
316
|
ctx: EntityContext;
|
|
296
317
|
scope: CompanyScope;
|
|
@@ -347,6 +368,7 @@ export async function listRemoteForScope(
|
|
|
347
368
|
const scopeEntries = toScopePrefixEntries(scope.prefixSet);
|
|
348
369
|
const listPrefixes = scopeEntries.map((entry) => entry.prefix);
|
|
349
370
|
const batches = batchPrefixesForVend(listPrefixes);
|
|
371
|
+
const listWithLimit = createConcurrencyLimiter(VEND_FANOUT_CONCURRENCY);
|
|
350
372
|
const perBatch = await mapWithConcurrency(
|
|
351
373
|
batches,
|
|
352
374
|
VEND_FANOUT_CONCURRENCY,
|
|
@@ -357,8 +379,11 @@ export async function listRemoteForScope(
|
|
|
357
379
|
// For a coalesced batch we issue one ListObjectsV2 per prefix in the
|
|
358
380
|
// batch. We can't issue one ListObjectsV2 across N prefixes (the API
|
|
359
381
|
// takes a single Prefix); the per-batch grouping exists for the STS
|
|
360
|
-
// session policy ceiling, not the list call itself.
|
|
361
|
-
|
|
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
|
+
);
|
|
362
387
|
return lists.flat();
|
|
363
388
|
},
|
|
364
389
|
);
|
|
@@ -396,6 +396,41 @@ describe("US-009: SqsPushReceiver — reconnect / catch-up replay", () => {
|
|
|
396
396
|
expect(sqs.deleted).not.toContain("rh-first-attempt");
|
|
397
397
|
expect(sqs.deleted).toContain("rh-redelivery");
|
|
398
398
|
});
|
|
399
|
+
|
|
400
|
+
it("F29: bounds dedupe state by evicting least-recently-used paths", async () => {
|
|
401
|
+
const sqs = new FakeSqs();
|
|
402
|
+
const processed: string[] = [];
|
|
403
|
+
const syncFn: SyncEngineFn = async (ctx) => {
|
|
404
|
+
processed.push(ctx.event.relativePath);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
sqs.enqueue([msg(makeEvent({ relativePath: "companies/indigo/a.md", sequenceNumber: 1 }), "rh-a1")]);
|
|
408
|
+
sqs.enqueue([msg(makeEvent({ relativePath: "companies/indigo/b.md", sequenceNumber: 1 }), "rh-b1")]);
|
|
409
|
+
sqs.enqueue([msg(makeEvent({ relativePath: "companies/indigo/c.md", sequenceNumber: 1 }), "rh-c1")]);
|
|
410
|
+
sqs.enqueue([msg(makeEvent({ relativePath: "companies/indigo/a.md", sequenceNumber: 1 }), "rh-a-redelivered")]);
|
|
411
|
+
|
|
412
|
+
const receiver = new SqsPushReceiver({
|
|
413
|
+
tenantId: TENANT,
|
|
414
|
+
queueUrl: QUEUE_URL,
|
|
415
|
+
sqs,
|
|
416
|
+
syncFn,
|
|
417
|
+
enabled: true,
|
|
418
|
+
sleep: fastSleep,
|
|
419
|
+
dedupeMaxPaths: 2,
|
|
420
|
+
});
|
|
421
|
+
await receiver.start();
|
|
422
|
+
await waitFor(() => receiver.processedCount === 4);
|
|
423
|
+
await receiver.dispose();
|
|
424
|
+
|
|
425
|
+
expect(processed).toEqual([
|
|
426
|
+
"companies/indigo/a.md",
|
|
427
|
+
"companies/indigo/b.md",
|
|
428
|
+
"companies/indigo/c.md",
|
|
429
|
+
"companies/indigo/a.md",
|
|
430
|
+
]);
|
|
431
|
+
expect(receiver.dedupedCount).toBe(0);
|
|
432
|
+
expect(sqs.deleted).toContain("rh-a-redelivered");
|
|
433
|
+
});
|
|
399
434
|
});
|
|
400
435
|
|
|
401
436
|
describe("US-009: SqsPushReceiver — flag gating", () => {
|
|
@@ -125,6 +125,39 @@ export const DEFAULT_MAX_MESSAGES = 10;
|
|
|
125
125
|
export const DEFAULT_RECONNECT_INITIAL_MS = 250;
|
|
126
126
|
export const DEFAULT_RECONNECT_MAX_MS = 30_000;
|
|
127
127
|
|
|
128
|
+
/** Maximum distinct paths retained in receiver dedupe state. */
|
|
129
|
+
export const DEFAULT_RECEIVER_DEDUPE_MAX_PATHS = 50_000;
|
|
130
|
+
|
|
131
|
+
class BoundedSequenceDedupe {
|
|
132
|
+
private readonly maxPaths: number;
|
|
133
|
+
private readonly seen = new Map<string, number>();
|
|
134
|
+
|
|
135
|
+
constructor(maxPaths: number | undefined) {
|
|
136
|
+
this.maxPaths = Math.max(
|
|
137
|
+
1,
|
|
138
|
+
Math.floor(maxPaths ?? DEFAULT_RECEIVER_DEDUPE_MAX_PATHS),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get(relativePath: string): number | undefined {
|
|
143
|
+
const sequenceNumber = this.seen.get(relativePath);
|
|
144
|
+
if (sequenceNumber === undefined) return undefined;
|
|
145
|
+
this.seen.delete(relativePath);
|
|
146
|
+
this.seen.set(relativePath, sequenceNumber);
|
|
147
|
+
return sequenceNumber;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
set(relativePath: string, sequenceNumber: number): void {
|
|
151
|
+
if (this.seen.has(relativePath)) this.seen.delete(relativePath);
|
|
152
|
+
this.seen.set(relativePath, sequenceNumber);
|
|
153
|
+
while (this.seen.size > this.maxPaths) {
|
|
154
|
+
const oldest = this.seen.keys().next().value;
|
|
155
|
+
if (oldest === undefined) return;
|
|
156
|
+
this.seen.delete(oldest);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
128
161
|
// ─── Narrow SQS surface (the injectable transport seam) ──────────────────────
|
|
129
162
|
|
|
130
163
|
/**
|
|
@@ -321,6 +354,8 @@ export interface SqsPushReceiverOptions {
|
|
|
321
354
|
maxMessages?: number;
|
|
322
355
|
/** Max time `dispose()` waits for an in-flight syncFn after abort. */
|
|
323
356
|
disposeDrainMs?: number;
|
|
357
|
+
/** Maximum distinct paths retained in dedupe state. */
|
|
358
|
+
dedupeMaxPaths?: number;
|
|
324
359
|
/** Reconnect backoff config. */
|
|
325
360
|
reconnect?: {
|
|
326
361
|
initialMs?: number;
|
|
@@ -384,7 +419,7 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
384
419
|
private inFlightSync: Promise<void> | null = null;
|
|
385
420
|
|
|
386
421
|
/** Per-path highest sequence number already PROCESSED by syncFn. */
|
|
387
|
-
private readonly seenSequencePerPath
|
|
422
|
+
private readonly seenSequencePerPath: BoundedSequenceDedupe;
|
|
388
423
|
|
|
389
424
|
private _processedCount = 0;
|
|
390
425
|
private _dedupedCount = 0;
|
|
@@ -413,6 +448,7 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
413
448
|
this.maxMessages = opts.maxMessages ?? DEFAULT_MAX_MESSAGES;
|
|
414
449
|
this.disposeDrainMs =
|
|
415
450
|
opts.disposeDrainMs ?? DEFAULT_RECEIVER_DISPOSE_DRAIN_MS;
|
|
451
|
+
this.seenSequencePerPath = new BoundedSequenceDedupe(opts.dedupeMaxPaths);
|
|
416
452
|
this.reconnectInitialMs =
|
|
417
453
|
opts.reconnect?.initialMs ?? DEFAULT_RECONNECT_INITIAL_MS;
|
|
418
454
|
this.reconnectMaxMs = opts.reconnect?.maxMs ?? DEFAULT_RECONNECT_MAX_MS;
|
|
@@ -821,6 +857,8 @@ export interface InMemoryPushReceiverOptions {
|
|
|
821
857
|
flagProvider?: EventDrivenPushFlagProvider;
|
|
822
858
|
env?: Record<string, string | undefined>;
|
|
823
859
|
disposeDrainMs?: number;
|
|
860
|
+
/** Maximum distinct paths retained in dedupe state. */
|
|
861
|
+
dedupeMaxPaths?: number;
|
|
824
862
|
}
|
|
825
863
|
|
|
826
864
|
/**
|
|
@@ -846,7 +884,7 @@ export class InMemoryPushReceiver implements PushReceiver {
|
|
|
846
884
|
|
|
847
885
|
private disconnectedFlag = false;
|
|
848
886
|
private readonly pendingDuringDisconnect: PushEvent[] = [];
|
|
849
|
-
private readonly seenSequencePerPath
|
|
887
|
+
private readonly seenSequencePerPath: BoundedSequenceDedupe;
|
|
850
888
|
|
|
851
889
|
private inFlightAbort: AbortController | null = null;
|
|
852
890
|
private inFlightSync: Promise<void> | null = null;
|
|
@@ -862,6 +900,7 @@ export class InMemoryPushReceiver implements PushReceiver {
|
|
|
862
900
|
this.logger = opts.logger ?? NOOP_LOGGER;
|
|
863
901
|
this.disposeDrainMs =
|
|
864
902
|
opts.disposeDrainMs ?? DEFAULT_RECEIVER_DISPOSE_DRAIN_MS;
|
|
903
|
+
this.seenSequencePerPath = new BoundedSequenceDedupe(opts.dedupeMaxPaths);
|
|
865
904
|
this.enabled = resolveEnabled({
|
|
866
905
|
explicit: opts.enabled,
|
|
867
906
|
flagProvider: opts.flagProvider,
|
package/src/sync-core.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { normalizeEtag } from "./journal.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Is this error the S3/STS "access denied" class? Expected when a scoped
|
|
7
|
+
* member/guest credential touches a key outside its granted ACL prefixes
|
|
8
|
+
* (the server's `SCOPE_EXCEEDS_PARENT` surfaces as a 403 AccessDenied /
|
|
9
|
+
* Forbidden).
|
|
10
|
+
*/
|
|
11
|
+
export function isAccessDenied(err: unknown): boolean {
|
|
12
|
+
if (err && typeof err === "object" && "name" in err) {
|
|
13
|
+
const name = (err as { name?: unknown }).name;
|
|
14
|
+
return name === "AccessDenied" || name === "Forbidden";
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveTransferConcurrency(): number {
|
|
20
|
+
const raw = process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
|
|
21
|
+
if (raw === undefined || raw === "") return 16;
|
|
22
|
+
const parsed = Number.parseInt(raw, 10);
|
|
23
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 16;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve active company from .hq/config.json.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveActiveCompany(hqRoot: string): string | undefined {
|
|
30
|
+
const configPath = path.join(hqRoot, ".hq", "config.json");
|
|
31
|
+
if (fs.existsSync(configPath)) {
|
|
32
|
+
try {
|
|
33
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
34
|
+
return config.activeCompany ?? config.companySlug;
|
|
35
|
+
} catch {
|
|
36
|
+
// Ignore parse errors
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns true when the remote object appears to have moved since the
|
|
44
|
+
* journal entry's last-recorded sync. Prefers ETag equality; falls back to
|
|
45
|
+
* `lastModified > syncedAt` for legacy entries written before remoteEtag
|
|
46
|
+
* was tracked. Conservative on tie (`<=` skews "remote unchanged") so an
|
|
47
|
+
* S3-side mtime that exactly equals our syncedAt is not treated as drift.
|
|
48
|
+
*/
|
|
49
|
+
export function hasRemoteChanged(
|
|
50
|
+
remote: { lastModified: Date; etag: string },
|
|
51
|
+
entry: { syncedAt: string; remoteEtag?: string },
|
|
52
|
+
): boolean {
|
|
53
|
+
if (entry.remoteEtag) {
|
|
54
|
+
return normalizeEtag(remote.etag) !== entry.remoteEtag;
|
|
55
|
+
}
|
|
56
|
+
const syncedAt = new Date(entry.syncedAt).getTime();
|
|
57
|
+
return remote.lastModified.getTime() > syncedAt;
|
|
58
|
+
}
|
package/src/vault-client.test.ts
CHANGED
|
@@ -440,6 +440,80 @@ describe("API surface", () => {
|
|
|
440
440
|
});
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
+
describe("response validation (F26)", () => {
|
|
444
|
+
it("F26: validates a valid files/list response and returns typed data", async () => {
|
|
445
|
+
fetchSpy.mockResolvedValueOnce(
|
|
446
|
+
jsonResponse(200, {
|
|
447
|
+
objects: [
|
|
448
|
+
{
|
|
449
|
+
key: "shared/docs/a.txt",
|
|
450
|
+
size: 123,
|
|
451
|
+
lastModified: "2026-05-20T12:00:00.000Z",
|
|
452
|
+
etag: "abc123",
|
|
453
|
+
permission: "read",
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
cursor: null,
|
|
457
|
+
truncated: false,
|
|
458
|
+
}),
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
const page = await client.listFiles("cmp_abc", "shared/docs/");
|
|
462
|
+
|
|
463
|
+
expect(page.objects[0]).toEqual({
|
|
464
|
+
key: "shared/docs/a.txt",
|
|
465
|
+
size: 123,
|
|
466
|
+
lastModified: "2026-05-20T12:00:00.000Z",
|
|
467
|
+
etag: "abc123",
|
|
468
|
+
permission: "read",
|
|
469
|
+
});
|
|
470
|
+
expect(page.cursor).toBeNull();
|
|
471
|
+
expect(page.truncated).toBe(false);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("F26: defaults omitted files/list paging fields", async () => {
|
|
475
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
476
|
+
|
|
477
|
+
const page = await client.listFiles("cmp_abc", "shared/docs/");
|
|
478
|
+
|
|
479
|
+
expect(page).toEqual({
|
|
480
|
+
objects: [],
|
|
481
|
+
cursor: null,
|
|
482
|
+
truncated: false,
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("F26: defaults omitted telemetry opt-in fields to disabled", async () => {
|
|
487
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
488
|
+
|
|
489
|
+
const optIn = await client.getTelemetryOptIn();
|
|
490
|
+
|
|
491
|
+
expect(optIn).toEqual({ enabled: false, updatedAt: null });
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("F26: maps a malformed files/list response to VaultClientError", async () => {
|
|
495
|
+
fetchSpy.mockResolvedValueOnce(
|
|
496
|
+
jsonResponse(200, {
|
|
497
|
+
objects: [
|
|
498
|
+
{
|
|
499
|
+
key: "shared/docs/a.txt",
|
|
500
|
+
size: 123,
|
|
501
|
+
lastModified: "2026-05-20T12:00:00.000Z",
|
|
502
|
+
permission: "read",
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
cursor: null,
|
|
506
|
+
truncated: false,
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
await expect(client.listFiles("cmp_abc")).rejects.toMatchObject({
|
|
511
|
+
name: "VaultClientError",
|
|
512
|
+
statusCode: 502,
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
443
517
|
describe("VaultClient identity bootstrap", () => {
|
|
444
518
|
let client: VaultClient;
|
|
445
519
|
let fetchSpy: MockInstance<typeof fetch>;
|