@indigoai-us/hq-cloud 5.26.0 → 5.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +34 -0
- package/dist/bin/sync-runner.d.ts +38 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +75 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/sync/feature-flags.d.ts +136 -0
- package/dist/sync/feature-flags.d.ts.map +1 -0
- package/dist/sync/feature-flags.js +160 -0
- package/dist/sync/feature-flags.js.map +1 -0
- package/dist/sync/feature-flags.test.d.ts +24 -0
- package/dist/sync/feature-flags.test.d.ts.map +1 -0
- package/dist/sync/feature-flags.test.js +330 -0
- package/dist/sync/feature-flags.test.js.map +1 -0
- package/dist/sync/index.d.ts +10 -2
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +5 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/logger.d.ts +61 -0
- package/dist/sync/logger.d.ts.map +1 -0
- package/dist/sync/logger.js +51 -0
- package/dist/sync/logger.js.map +1 -0
- package/dist/sync/logger.test.d.ts +19 -0
- package/dist/sync/logger.test.d.ts.map +1 -0
- package/dist/sync/logger.test.js +199 -0
- package/dist/sync/logger.test.js.map +1 -0
- package/dist/sync/metrics.d.ts +89 -0
- package/dist/sync/metrics.d.ts.map +1 -0
- package/dist/sync/metrics.js +105 -0
- package/dist/sync/metrics.js.map +1 -0
- package/dist/sync/metrics.test.d.ts +19 -0
- package/dist/sync/metrics.test.d.ts.map +1 -0
- package/dist/sync/metrics.test.js +280 -0
- package/dist/sync/metrics.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +442 -0
- package/dist/sync/push-receiver.d.ts.map +1 -0
- package/dist/sync/push-receiver.js +782 -0
- package/dist/sync/push-receiver.js.map +1 -0
- package/dist/sync/push-receiver.test.d.ts +25 -0
- package/dist/sync/push-receiver.test.d.ts.map +1 -0
- package/dist/sync/push-receiver.test.js +477 -0
- package/dist/sync/push-receiver.test.js.map +1 -0
- package/dist/sync/push-transport.d.ts +84 -1
- package/dist/sync/push-transport.d.ts.map +1 -1
- package/dist/sync/push-transport.js +84 -0
- package/dist/sync/push-transport.js.map +1 -1
- package/dist/watcher.d.ts +127 -11
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +294 -57
- package/dist/watcher.js.map +1 -1
- package/package.json +9 -5
- package/src/bin/sync-runner.ts +102 -1
- package/src/index.ts +21 -0
- package/src/sync/feature-flags.test.ts +392 -0
- package/src/sync/feature-flags.ts +229 -0
- package/src/sync/index.ts +57 -2
- package/src/sync/logger.test.ts +241 -0
- package/src/sync/logger.ts +79 -0
- package/src/sync/metrics.test.ts +380 -0
- package/src/sync/metrics.ts +158 -0
- package/src/sync/push-receiver.test.ts +545 -0
- package/src/sync/push-receiver.ts +1077 -0
- package/src/sync/push-transport.ts +148 -1
- package/src/watcher.ts +408 -51
- package/test/e2e/sync/cross-tenant-isolation.test.ts +502 -0
- package/test/e2e/watcher-real-chokidar.test.ts +105 -0
- package/test/e2e/watcher-recursive-backend.test.ts +115 -0
package/src/bin/sync-runner.ts
CHANGED
|
@@ -94,6 +94,11 @@ import {
|
|
|
94
94
|
systemClock,
|
|
95
95
|
type Clock,
|
|
96
96
|
} from "../watcher.js";
|
|
97
|
+
import {
|
|
98
|
+
NoopPushReceiver,
|
|
99
|
+
type PushReceiver,
|
|
100
|
+
type SyncEngineFn,
|
|
101
|
+
} from "../sync/push-receiver.js";
|
|
97
102
|
|
|
98
103
|
/**
|
|
99
104
|
* Sync direction for a run.
|
|
@@ -1249,6 +1254,26 @@ export interface RunnerLoopDeps {
|
|
|
1249
1254
|
* handler (called during teardown so tests don't leak listeners).
|
|
1250
1255
|
*/
|
|
1251
1256
|
onShutdownSignal?: (handler: () => void) => () => void;
|
|
1257
|
+
/**
|
|
1258
|
+
* Factory for the Phase 2 pull-on-event receiver (US-009). Defaults to a
|
|
1259
|
+
* {@link NoopPushReceiver} — the daemon ships the receiver SEAM wired into
|
|
1260
|
+
* the lifecycle (start after the watcher, dispose before exit) but stays
|
|
1261
|
+
* DORMANT by default: the per-client SQS queue is provisioned server-side
|
|
1262
|
+
* (an unbuilt follow-up) and the receiver is feature-flag gated. A future
|
|
1263
|
+
* menubar/CLI release injects an {@link SqsPushReceiver} here once a queue
|
|
1264
|
+
* URL is available. Only consulted when `--event-push` is on.
|
|
1265
|
+
*
|
|
1266
|
+
* The factory is handed a {@link SyncEngineFn} that bridges a received
|
|
1267
|
+
* PushEvent to a TARGETED pull pass (`--company <slug> --direction pull`,
|
|
1268
|
+
* or a personal `--companies --direction pull`) routed by the event's
|
|
1269
|
+
* `relativePath`, funneled through the same in-flight guard as the poll
|
|
1270
|
+
* loop and the watcher push so a pull-on-event never overlaps an in-flight
|
|
1271
|
+
* pass.
|
|
1272
|
+
*/
|
|
1273
|
+
createReceiver?: (opts: {
|
|
1274
|
+
syncFn: SyncEngineFn;
|
|
1275
|
+
hqRoot: string;
|
|
1276
|
+
}) => PushReceiver;
|
|
1252
1277
|
}
|
|
1253
1278
|
|
|
1254
1279
|
/**
|
|
@@ -1318,6 +1343,28 @@ export function buildTargetedPushArgv(
|
|
|
1318
1343
|
return ["--companies", "--direction", "push", ...carried];
|
|
1319
1344
|
}
|
|
1320
1345
|
|
|
1346
|
+
/**
|
|
1347
|
+
* Build the argv for a targeted PULL pass from a routed change (US-009 — the
|
|
1348
|
+
* receiver's pull-on-event path). Mirrors {@link buildTargetedPushArgv} but
|
|
1349
|
+
* with `--direction pull`: a peer device pushed a change, so this device pulls
|
|
1350
|
+
* just the affected company/subtree instead of waiting for the next
|
|
1351
|
+
* `--poll-remote-ms` cycle. Company routes use `--company <slug>`; personal
|
|
1352
|
+
* routes use `--companies` (the personal-vault scope is resolved inside
|
|
1353
|
+
* runRunner's fanout; skipUnchanged no-ops the subtrees that didn't change).
|
|
1354
|
+
* Inherits `--hq-root` / `--on-conflict` from the base argv. Pure helper,
|
|
1355
|
+
* exported for unit testing the event→argv map.
|
|
1356
|
+
*/
|
|
1357
|
+
export function buildTargetedPullArgv(
|
|
1358
|
+
route: { kind: "company"; slug: string } | { kind: "personal" },
|
|
1359
|
+
baseArgv: string[],
|
|
1360
|
+
): string[] {
|
|
1361
|
+
const carried = carriedFlags(baseArgv);
|
|
1362
|
+
if (route.kind === "company") {
|
|
1363
|
+
return ["--company", route.slug, "--direction", "pull", ...carried];
|
|
1364
|
+
}
|
|
1365
|
+
return ["--companies", "--direction", "pull", ...carried];
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1321
1368
|
export async function runRunnerWithLoop(
|
|
1322
1369
|
argv: string[],
|
|
1323
1370
|
deps: RunnerLoopDeps = {},
|
|
@@ -1333,6 +1380,15 @@ export async function runRunnerWithLoop(
|
|
|
1333
1380
|
const pollMs =
|
|
1334
1381
|
pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
|
|
1335
1382
|
const eventPush = argv.includes("--event-push");
|
|
1383
|
+
// In `--companies` mode the sync scope is companies/*/{knowledge,projects,…}
|
|
1384
|
+
// (per .hqinclude), so the watcher must NOT apply the personal-vault
|
|
1385
|
+
// top-level exclusions (PERSONAL_VAULT_EXCLUDED_TOP_LEVEL drops `companies/`
|
|
1386
|
+
// and `workspace/`) — doing so would exclude exactly the paths being synced,
|
|
1387
|
+
// and no local edit would ever trigger an instant push. The shared ignore
|
|
1388
|
+
// stack (createIgnoreFilter / .hqignore / .hqinclude) already scopes the
|
|
1389
|
+
// watch filter correctly in companies mode. personalMode is only for a
|
|
1390
|
+
// personal-vault-as-root run, where companies/ et al. genuinely aren't synced.
|
|
1391
|
+
const companiesMode = argv.includes("--companies");
|
|
1336
1392
|
const hqIdx = argv.indexOf("--hq-root");
|
|
1337
1393
|
const hqRoot =
|
|
1338
1394
|
hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
|
|
@@ -1372,6 +1428,12 @@ export async function runRunnerWithLoop(
|
|
|
1372
1428
|
let driver: WatchPushDriver | null = null;
|
|
1373
1429
|
let detachSignal: (() => void) | null = null;
|
|
1374
1430
|
let lastChangedRel: string | null = null;
|
|
1431
|
+
// ---- pull-on-event receiver (Phase 2, US-009) ------------------------
|
|
1432
|
+
// Started after the watcher, disposed before the watcher (mirror of the
|
|
1433
|
+
// PushTransport ordering). Dormant by default: the default factory returns
|
|
1434
|
+
// a NoopPushReceiver, and even a real receiver stays dormant unless the
|
|
1435
|
+
// per-tenant feature flag is on AND a queue URL is provisioned server-side.
|
|
1436
|
+
let receiver: PushReceiver | null = null;
|
|
1375
1437
|
|
|
1376
1438
|
if (eventPush) {
|
|
1377
1439
|
const clock = deps.clock ?? systemClock;
|
|
@@ -1383,7 +1445,10 @@ export async function runRunnerWithLoop(
|
|
|
1383
1445
|
hqRoot: opts.hqRoot,
|
|
1384
1446
|
debounceMs: opts.debounceMs,
|
|
1385
1447
|
clock: opts.clock,
|
|
1386
|
-
|
|
1448
|
+
// false in --companies mode so the watch filter matches the sync
|
|
1449
|
+
// scope (companies/* are included via .hqinclude); true only for a
|
|
1450
|
+
// personal-vault-as-root run.
|
|
1451
|
+
personalMode: !companiesMode,
|
|
1387
1452
|
}));
|
|
1388
1453
|
watcher = createWatcher({ hqRoot, debounceMs, clock });
|
|
1389
1454
|
|
|
@@ -1416,6 +1481,31 @@ export async function runRunnerWithLoop(
|
|
|
1416
1481
|
driver?.notifyChange();
|
|
1417
1482
|
});
|
|
1418
1483
|
watcher.start();
|
|
1484
|
+
|
|
1485
|
+
// Pull-on-event receiver (US-009). The injected SyncEngineFn bridges a
|
|
1486
|
+
// received PushEvent → a TARGETED pull pass routed by relativePath, funneled
|
|
1487
|
+
// through the SAME `runGuarded` mutex as the poll loop + watcher push so a
|
|
1488
|
+
// pull-on-event never overlaps an in-flight pass. Started AFTER the watcher
|
|
1489
|
+
// so a live event can't race a half-built daemon. Default factory = noop
|
|
1490
|
+
// (dormant); a real SqsPushReceiver is injected by a later release once the
|
|
1491
|
+
// server-side per-client SQS queue is provisioned.
|
|
1492
|
+
const receiverSyncFn: SyncEngineFn = async (ctx) => {
|
|
1493
|
+
if (stopped) return;
|
|
1494
|
+
const route = routeChangeToTarget(ctx.event.relativePath);
|
|
1495
|
+
if (!route) return;
|
|
1496
|
+
const targetedArgv = buildTargetedPullArgv(route, passArgv);
|
|
1497
|
+
await runGuarded(() => runPass(targetedArgv));
|
|
1498
|
+
};
|
|
1499
|
+
const createReceiver =
|
|
1500
|
+
deps.createReceiver ?? (() => new NoopPushReceiver());
|
|
1501
|
+
receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
|
|
1502
|
+
// Fire-and-forget start: a receiver's start() kicks off its own poll loop
|
|
1503
|
+
// (SqsPushReceiver) or trivially flips connected (noop) — it must NOT block
|
|
1504
|
+
// the runner's poll loop from entering. Errors are swallowed; the cadence
|
|
1505
|
+
// poll is the safety net regardless of receiver health. (The await-free
|
|
1506
|
+
// start also keeps the poll loop's microtask timing identical to the
|
|
1507
|
+
// pre-US-009 wiring.)
|
|
1508
|
+
void Promise.resolve(receiver.start()).catch(() => undefined);
|
|
1419
1509
|
}
|
|
1420
1510
|
|
|
1421
1511
|
// ---- clean shutdown --------------------------------------------------
|
|
@@ -1429,6 +1519,17 @@ export async function runRunnerWithLoop(
|
|
|
1429
1519
|
const shutdown = (): void => {
|
|
1430
1520
|
if (stopped) return;
|
|
1431
1521
|
stopped = true;
|
|
1522
|
+
// Dispose the receiver FIRST (mirror of the PushTransport ordering:
|
|
1523
|
+
// inbound subscription torn down before the watcher) so no new
|
|
1524
|
+
// pull-on-event fires mid-teardown. dispose() is async (it drains the
|
|
1525
|
+
// in-flight pull up to its own deadline); fire-and-forget here — the
|
|
1526
|
+
// receiver's internal drain + the runGuarded mutex bound the work, and
|
|
1527
|
+
// SIGTERM teardown must not block. Errors are swallowed.
|
|
1528
|
+
try {
|
|
1529
|
+
void receiver?.dispose();
|
|
1530
|
+
} catch {
|
|
1531
|
+
/* ignore */
|
|
1532
|
+
}
|
|
1432
1533
|
try {
|
|
1433
1534
|
driver?.dispose();
|
|
1434
1535
|
} catch {
|
package/src/index.ts
CHANGED
|
@@ -292,9 +292,30 @@ export {
|
|
|
292
292
|
encodePushEvent,
|
|
293
293
|
decodePushEvent,
|
|
294
294
|
NoopPushTransport,
|
|
295
|
+
HttpPushTransport,
|
|
296
|
+
FEATURE_FLAG_TENANTS_ENV_VAR,
|
|
297
|
+
FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR,
|
|
298
|
+
EnvTenantListFlagProvider,
|
|
299
|
+
StaticFlagProvider,
|
|
300
|
+
defaultFlagProvider,
|
|
295
301
|
} from "./sync/index.js";
|
|
296
302
|
export type {
|
|
297
303
|
PushEvent,
|
|
298
304
|
PushEventDecodeIssue,
|
|
299
305
|
PushTransport,
|
|
306
|
+
HttpPushTransportOptions,
|
|
307
|
+
AuthTokenSource,
|
|
308
|
+
FetchLike,
|
|
309
|
+
EventDrivenPushFlagProvider,
|
|
310
|
+
FlagProviderWarnFn,
|
|
311
|
+
EnvTenantListFlagProviderOptions,
|
|
300
312
|
} from "./sync/index.js";
|
|
313
|
+
|
|
314
|
+
// US-008 — watcher PushEvent emitter (bridges TreeWatcher → PushTransport,
|
|
315
|
+
// flag-gated, failure-safe).
|
|
316
|
+
export { PushEventEmitter } from "./watcher.js";
|
|
317
|
+
export type {
|
|
318
|
+
PushEventEmitterOptions,
|
|
319
|
+
TreeChangeBatch,
|
|
320
|
+
TreeChangeListener,
|
|
321
|
+
} from "./watcher.js";
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-008 — client push transport unit tests.
|
|
3
|
+
*
|
|
4
|
+
* The PRD names this file as the home for US-008's coverage; it spans the
|
|
5
|
+
* four required assertions across the three US-008 modules:
|
|
6
|
+
*
|
|
7
|
+
* 1. flag gating — feature-flags.ts (env list, legacy global,
|
|
8
|
+
* static provider, malformed-entry tolerance)
|
|
9
|
+
* 2. POST — push-transport.ts HttpPushTransport posts an
|
|
10
|
+
* encoded PushEvent to `<apiUrl>/sync/push` with
|
|
11
|
+
* a Bearer token (mocked fetch — no real server)
|
|
12
|
+
* 3. emit — watcher.ts PushEventEmitter builds a valid
|
|
13
|
+
* PushEvent (sha256 hash, monotonic seq, tenant)
|
|
14
|
+
* per debounced change and hands it to the
|
|
15
|
+
* transport; dormant when the flag is OFF
|
|
16
|
+
* 4. transport failure — a publish rejection (or hash error) is caught,
|
|
17
|
+
* surfaced via onError, and never crashes / never
|
|
18
|
+
* propagates (cadence poll is the safety net)
|
|
19
|
+
*
|
|
20
|
+
* The live `/sync/push` endpoint is NOT deployed yet — every POST is asserted
|
|
21
|
+
* against a mocked fetch / mocked transport.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
EnvTenantListFlagProvider,
|
|
32
|
+
FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR,
|
|
33
|
+
FEATURE_FLAG_TENANTS_ENV_VAR,
|
|
34
|
+
StaticFlagProvider,
|
|
35
|
+
defaultFlagProvider,
|
|
36
|
+
} from "./feature-flags.js";
|
|
37
|
+
import {
|
|
38
|
+
HttpPushTransport,
|
|
39
|
+
NoopPushTransport,
|
|
40
|
+
type FetchLike,
|
|
41
|
+
type PushTransport,
|
|
42
|
+
} from "./push-transport.js";
|
|
43
|
+
import { decodePushEvent, type PushEvent } from "./push-event.js";
|
|
44
|
+
import { PushEventEmitter } from "../watcher.js";
|
|
45
|
+
import type { TreeChangeBatch } from "../watcher.js";
|
|
46
|
+
|
|
47
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function fakePushEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
50
|
+
return {
|
|
51
|
+
relativePath: "personal/notes/a.md",
|
|
52
|
+
contentHash: `sha256:${"a".repeat(64)}`,
|
|
53
|
+
mtime: "2026-05-21T12:00:00.000Z",
|
|
54
|
+
originDeviceId: "device-1",
|
|
55
|
+
originTenantId: "indigo",
|
|
56
|
+
sequenceNumber: 0,
|
|
57
|
+
eventTimestamp: "2026-05-21T12:00:01.000Z",
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A fetch double that records calls and returns a configurable response. */
|
|
63
|
+
function makeFetch(
|
|
64
|
+
res: { ok: boolean; status: number; body?: string } = {
|
|
65
|
+
ok: true,
|
|
66
|
+
status: 200,
|
|
67
|
+
},
|
|
68
|
+
): { fetch: FetchLike; calls: Array<{ url: string; init: unknown }> } {
|
|
69
|
+
const calls: Array<{ url: string; init: unknown }> = [];
|
|
70
|
+
const fetch: FetchLike = async (url, init) => {
|
|
71
|
+
calls.push({ url, init });
|
|
72
|
+
return {
|
|
73
|
+
ok: res.ok,
|
|
74
|
+
status: res.status,
|
|
75
|
+
text: async () => res.body ?? "",
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
return { fetch, calls };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── 1. Flag gating (feature-flags.ts) ────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe("EventDrivenPush feature flags — gating", () => {
|
|
84
|
+
it("StaticFlagProvider enables only listed tenants", () => {
|
|
85
|
+
const p = new StaticFlagProvider(["indigo"]);
|
|
86
|
+
expect(p.isEnabled("indigo")).toBe(true);
|
|
87
|
+
expect(p.isEnabled("acme")).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("env tenant list: empty/unset → all tenants OFF (default)", () => {
|
|
91
|
+
expect(new EnvTenantListFlagProvider({ env: {} }).isEnabled("indigo")).toBe(
|
|
92
|
+
false,
|
|
93
|
+
);
|
|
94
|
+
expect(
|
|
95
|
+
new EnvTenantListFlagProvider({
|
|
96
|
+
env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: "" },
|
|
97
|
+
}).isEnabled("indigo"),
|
|
98
|
+
).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("env tenant list enables exact, case-sensitive matches only", () => {
|
|
102
|
+
const p = new EnvTenantListFlagProvider({
|
|
103
|
+
env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: " indigo , acme " },
|
|
104
|
+
});
|
|
105
|
+
expect(p.isEnabled("indigo")).toBe(true);
|
|
106
|
+
expect(p.isEnabled("acme")).toBe(true);
|
|
107
|
+
expect(p.isEnabled("Indigo")).toBe(false);
|
|
108
|
+
expect(p.isEnabled("other")).toBe(false);
|
|
109
|
+
expect(p.enabledTenants).toEqual(new Set(["indigo", "acme"]));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("legacy global=true overrides → every tenant enabled", () => {
|
|
113
|
+
const p = new EnvTenantListFlagProvider({
|
|
114
|
+
env: { [FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR]: "true" },
|
|
115
|
+
});
|
|
116
|
+
expect(p.isEnabled("anything")).toBe(true);
|
|
117
|
+
expect(p.globalOverride).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("trailing/empty commas dropped silently; internal-whitespace warn-and-skipped (no throw)", () => {
|
|
121
|
+
const warn = vi.fn();
|
|
122
|
+
const p = new EnvTenantListFlagProvider({
|
|
123
|
+
env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: ",,indigo,,bad tenant," },
|
|
124
|
+
warn,
|
|
125
|
+
});
|
|
126
|
+
expect(p.isEnabled("indigo")).toBe(true);
|
|
127
|
+
expect(p.isEnabled("bad tenant")).toBe(false);
|
|
128
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(warn.mock.calls[0][1]).toMatchObject({ rawEntry: "bad tenant" });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("defaultFlagProvider reads from the supplied env snapshot", () => {
|
|
133
|
+
const p = defaultFlagProvider({
|
|
134
|
+
[FEATURE_FLAG_TENANTS_ENV_VAR]: "indigo",
|
|
135
|
+
});
|
|
136
|
+
expect(p.isEnabled("indigo")).toBe(true);
|
|
137
|
+
expect(p.isEnabled("acme")).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("enabledTenants returns a defensive copy (mutation does not corrupt cache)", () => {
|
|
141
|
+
const p = new EnvTenantListFlagProvider({
|
|
142
|
+
env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: "indigo" },
|
|
143
|
+
});
|
|
144
|
+
(p.enabledTenants as Set<string>).add("acme");
|
|
145
|
+
expect(p.isEnabled("acme")).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ─── 2. POST (HttpPushTransport) ──────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe("HttpPushTransport — POST /sync/push", () => {
|
|
152
|
+
it("POSTs an encoded PushEvent to <apiUrl>/sync/push with a Bearer token", async () => {
|
|
153
|
+
const { fetch, calls } = makeFetch({ ok: true, status: 200 });
|
|
154
|
+
const transport = new HttpPushTransport({
|
|
155
|
+
apiUrl: "https://vault-api.example.com/",
|
|
156
|
+
authToken: "tok-123",
|
|
157
|
+
fetchImpl: fetch,
|
|
158
|
+
});
|
|
159
|
+
await transport.start();
|
|
160
|
+
expect(transport.connected).toBe(true);
|
|
161
|
+
|
|
162
|
+
const event = fakePushEvent();
|
|
163
|
+
await transport.publish(event);
|
|
164
|
+
|
|
165
|
+
expect(calls).toHaveLength(1);
|
|
166
|
+
const call = calls[0];
|
|
167
|
+
// Trailing slash on apiUrl is normalized; default path is /sync/push.
|
|
168
|
+
expect(call.url).toBe("https://vault-api.example.com/sync/push");
|
|
169
|
+
const init = call.init as {
|
|
170
|
+
method: string;
|
|
171
|
+
headers: Record<string, string>;
|
|
172
|
+
body: string;
|
|
173
|
+
};
|
|
174
|
+
expect(init.method).toBe("POST");
|
|
175
|
+
expect(init.headers.Authorization).toBe("Bearer tok-123");
|
|
176
|
+
expect(init.headers["Content-Type"]).toBe("application/json");
|
|
177
|
+
// Body is a valid encoded PushEvent that round-trips.
|
|
178
|
+
expect(decodePushEvent(init.body)).toEqual(event);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("resolves the auth token per-request via an async getter (self-heals across refresh)", async () => {
|
|
182
|
+
const { fetch, calls } = makeFetch();
|
|
183
|
+
let n = 0;
|
|
184
|
+
const transport = new HttpPushTransport({
|
|
185
|
+
apiUrl: "https://api.example.com",
|
|
186
|
+
authToken: () => `tok-${n++}`,
|
|
187
|
+
fetchImpl: fetch,
|
|
188
|
+
});
|
|
189
|
+
await transport.publish(fakePushEvent());
|
|
190
|
+
await transport.publish(fakePushEvent());
|
|
191
|
+
expect((calls[0].init as any).headers.Authorization).toBe("Bearer tok-0");
|
|
192
|
+
expect((calls[1].init as any).headers.Authorization).toBe("Bearer tok-1");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("honors a custom pushPath + extra headers", async () => {
|
|
196
|
+
const { fetch, calls } = makeFetch();
|
|
197
|
+
const transport = new HttpPushTransport({
|
|
198
|
+
apiUrl: "https://api.example.com",
|
|
199
|
+
pushPath: "v1/sync/push",
|
|
200
|
+
authToken: "t",
|
|
201
|
+
headers: { "x-hq-client-name": "hq-sync-runner" },
|
|
202
|
+
fetchImpl: fetch,
|
|
203
|
+
});
|
|
204
|
+
await transport.publish(fakePushEvent());
|
|
205
|
+
expect(calls[0].url).toBe("https://api.example.com/v1/sync/push");
|
|
206
|
+
expect((calls[0].init as any).headers["x-hq-client-name"]).toBe(
|
|
207
|
+
"hq-sync-runner",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("rejects when apiUrl is missing", () => {
|
|
212
|
+
expect(
|
|
213
|
+
() => new HttpPushTransport({ apiUrl: "", authToken: "t" }),
|
|
214
|
+
).toThrow(/apiUrl is required/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("rejects (does not swallow) on a non-2xx response", async () => {
|
|
218
|
+
const { fetch } = makeFetch({ ok: false, status: 403, body: "forbidden" });
|
|
219
|
+
const transport = new HttpPushTransport({
|
|
220
|
+
apiUrl: "https://api.example.com",
|
|
221
|
+
authToken: "t",
|
|
222
|
+
fetchImpl: fetch,
|
|
223
|
+
});
|
|
224
|
+
await expect(transport.publish(fakePushEvent())).rejects.toThrow(/403/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("rejects on a malformed event before hitting the network", async () => {
|
|
228
|
+
const { fetch, calls } = makeFetch();
|
|
229
|
+
const transport = new HttpPushTransport({
|
|
230
|
+
apiUrl: "https://api.example.com",
|
|
231
|
+
authToken: "t",
|
|
232
|
+
fetchImpl: fetch,
|
|
233
|
+
});
|
|
234
|
+
await expect(
|
|
235
|
+
transport.publish(fakePushEvent({ contentHash: "not-a-hash" })),
|
|
236
|
+
).rejects.toBeTruthy();
|
|
237
|
+
expect(calls).toHaveLength(0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ─── 3 + 4. Emit + gating + failure handling (PushEventEmitter) ───────────────
|
|
242
|
+
|
|
243
|
+
describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
244
|
+
let dir: string;
|
|
245
|
+
let filePath: string;
|
|
246
|
+
const REL = "a.md";
|
|
247
|
+
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
dir = mkdtempSync(path.join(tmpdir(), "us008-"));
|
|
250
|
+
filePath = path.join(dir, REL);
|
|
251
|
+
writeFileSync(filePath, "hello world");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
afterEach(() => {
|
|
255
|
+
rmSync(dir, { recursive: true, force: true });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
function batchFor(...rels: string[]): TreeChangeBatch {
|
|
259
|
+
const paths = new Map<string, string>();
|
|
260
|
+
for (const rel of rels) paths.set(path.join(dir, rel), rel);
|
|
261
|
+
return { paths };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
it("emits a valid PushEvent (sha256 hash, tenant) per changed path and ships it", async () => {
|
|
265
|
+
const transport = new NoopPushTransport();
|
|
266
|
+
const published: PushEvent[] = [];
|
|
267
|
+
const spyTransport: PushTransport = {
|
|
268
|
+
start: () => transport.start(),
|
|
269
|
+
dispose: () => transport.dispose(),
|
|
270
|
+
get connected() {
|
|
271
|
+
return transport.connected;
|
|
272
|
+
},
|
|
273
|
+
publish: async (e) => {
|
|
274
|
+
published.push(e);
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
const emitter = new PushEventEmitter({
|
|
278
|
+
originTenantId: "indigo",
|
|
279
|
+
originDeviceId: "dev-1",
|
|
280
|
+
transport: spyTransport,
|
|
281
|
+
flagProvider: new StaticFlagProvider(["indigo"]),
|
|
282
|
+
now: () => new Date("2026-05-21T00:00:00.000Z"),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await emitter.emitForBatch(batchFor(REL));
|
|
286
|
+
|
|
287
|
+
expect(published).toHaveLength(1);
|
|
288
|
+
const e = published[0];
|
|
289
|
+
expect(e.relativePath).toBe(REL);
|
|
290
|
+
expect(e.originTenantId).toBe("indigo");
|
|
291
|
+
expect(e.originDeviceId).toBe("dev-1");
|
|
292
|
+
const expectedHex = createHash("sha256").update("hello world").digest("hex");
|
|
293
|
+
expect(e.contentHash).toBe(`sha256:${expectedHex}`);
|
|
294
|
+
// Round-trips through the wire contract.
|
|
295
|
+
expect(() => decodePushEvent(e)).not.toThrow();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("assigns monotonic per-device sequence numbers across emits", async () => {
|
|
299
|
+
const published: PushEvent[] = [];
|
|
300
|
+
const emitter = new PushEventEmitter({
|
|
301
|
+
originTenantId: "indigo",
|
|
302
|
+
originDeviceId: "dev-1",
|
|
303
|
+
transport: { start: async () => {}, dispose: async () => {}, connected: true, publish: async (e) => { published.push(e); } },
|
|
304
|
+
flagProvider: new StaticFlagProvider(["indigo"]),
|
|
305
|
+
});
|
|
306
|
+
writeFileSync(path.join(dir, "b.md"), "second");
|
|
307
|
+
await emitter.emitForBatch(batchFor(REL));
|
|
308
|
+
await emitter.emitForBatch(batchFor("b.md"));
|
|
309
|
+
expect(published.map((e) => e.sequenceNumber)).toEqual([0, 1]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("is DORMANT when the flag is OFF: ships nothing, attach is a no-op", async () => {
|
|
313
|
+
const publish = vi.fn(async () => {});
|
|
314
|
+
const emitter = new PushEventEmitter({
|
|
315
|
+
originTenantId: "indigo",
|
|
316
|
+
originDeviceId: "dev-1",
|
|
317
|
+
transport: { start: async () => {}, dispose: async () => {}, connected: false, publish },
|
|
318
|
+
flagProvider: new StaticFlagProvider([]), // indigo NOT enabled
|
|
319
|
+
});
|
|
320
|
+
expect(emitter.enabled).toBe(false);
|
|
321
|
+
await emitter.emitForBatch(batchFor(REL));
|
|
322
|
+
expect(publish).not.toHaveBeenCalled();
|
|
323
|
+
// attach returns a callable no-op when dormant.
|
|
324
|
+
const fakeWatcher = { onChange: vi.fn() } as any;
|
|
325
|
+
const off = emitter.attach(fakeWatcher);
|
|
326
|
+
expect(fakeWatcher.onChange).not.toHaveBeenCalled();
|
|
327
|
+
expect(() => off()).not.toThrow();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("transport publish failure is caught + surfaced, never throws (cadence safety net)", async () => {
|
|
331
|
+
const onError = vi.fn();
|
|
332
|
+
const emitter = new PushEventEmitter({
|
|
333
|
+
originTenantId: "indigo",
|
|
334
|
+
originDeviceId: "dev-1",
|
|
335
|
+
transport: {
|
|
336
|
+
start: async () => {},
|
|
337
|
+
dispose: async () => {},
|
|
338
|
+
connected: true,
|
|
339
|
+
publish: async () => {
|
|
340
|
+
throw new Error("network down");
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
flagProvider: new StaticFlagProvider(["indigo"]),
|
|
344
|
+
onError,
|
|
345
|
+
});
|
|
346
|
+
// Must not reject — the daemon stays alive.
|
|
347
|
+
await expect(emitter.emitForBatch(batchFor(REL))).resolves.toBeUndefined();
|
|
348
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
349
|
+
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
|
|
350
|
+
expect(onError.mock.calls[0][1]).toMatchObject({ relativePath: REL });
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("a missing file (hash/stat race) is surfaced, other paths still ship", async () => {
|
|
354
|
+
const published: PushEvent[] = [];
|
|
355
|
+
const onError = vi.fn();
|
|
356
|
+
const emitter = new PushEventEmitter({
|
|
357
|
+
originTenantId: "indigo",
|
|
358
|
+
originDeviceId: "dev-1",
|
|
359
|
+
transport: { start: async () => {}, dispose: async () => {}, connected: true, publish: async (e) => { published.push(e); } },
|
|
360
|
+
flagProvider: new StaticFlagProvider(["indigo"]),
|
|
361
|
+
onError,
|
|
362
|
+
});
|
|
363
|
+
// REL exists; "ghost.md" does not.
|
|
364
|
+
await emitter.emitForBatch(batchFor(REL, "ghost.md"));
|
|
365
|
+
expect(published.map((e) => e.relativePath)).toEqual([REL]);
|
|
366
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
367
|
+
expect(onError.mock.calls[0][1]).toMatchObject({ relativePath: "ghost.md" });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("end-to-end: emit → HttpPushTransport POST with a valid schema (mocked fetch)", async () => {
|
|
371
|
+
const { fetch, calls } = makeFetch({ ok: true, status: 200 });
|
|
372
|
+
const transport = new HttpPushTransport({
|
|
373
|
+
apiUrl: "https://api.example.com",
|
|
374
|
+
authToken: "tok",
|
|
375
|
+
fetchImpl: fetch,
|
|
376
|
+
});
|
|
377
|
+
const emitter = new PushEventEmitter({
|
|
378
|
+
originTenantId: "indigo",
|
|
379
|
+
originDeviceId: "dev-1",
|
|
380
|
+
transport,
|
|
381
|
+
flagProvider: new StaticFlagProvider(["indigo"]),
|
|
382
|
+
});
|
|
383
|
+
await transport.start();
|
|
384
|
+
await emitter.emitForBatch(batchFor(REL));
|
|
385
|
+
|
|
386
|
+
expect(calls).toHaveLength(1);
|
|
387
|
+
expect(calls[0].url).toBe("https://api.example.com/sync/push");
|
|
388
|
+
const posted = decodePushEvent((calls[0].init as any).body);
|
|
389
|
+
expect(posted.relativePath).toBe(REL);
|
|
390
|
+
expect(posted.originTenantId).toBe("indigo");
|
|
391
|
+
});
|
|
392
|
+
});
|