@indigoai-us/hq-cloud 5.25.0 → 5.27.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.
Files changed (87) hide show
  1. package/.github/workflows/ci.yml +34 -0
  2. package/dist/bin/sync-runner.d.ts +138 -1
  3. package/dist/bin/sync-runner.d.ts.map +1 -1
  4. package/dist/bin/sync-runner.js +288 -16
  5. package/dist/bin/sync-runner.js.map +1 -1
  6. package/dist/bin/sync-runner.test.js +372 -1
  7. package/dist/bin/sync-runner.test.js.map +1 -1
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +6 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/sync/feature-flags.d.ts +136 -0
  13. package/dist/sync/feature-flags.d.ts.map +1 -0
  14. package/dist/sync/feature-flags.js +160 -0
  15. package/dist/sync/feature-flags.js.map +1 -0
  16. package/dist/sync/feature-flags.test.d.ts +24 -0
  17. package/dist/sync/feature-flags.test.d.ts.map +1 -0
  18. package/dist/sync/feature-flags.test.js +330 -0
  19. package/dist/sync/feature-flags.test.js.map +1 -0
  20. package/dist/sync/index.d.ts +19 -0
  21. package/dist/sync/index.d.ts.map +1 -0
  22. package/dist/sync/index.js +13 -0
  23. package/dist/sync/index.js.map +1 -0
  24. package/dist/sync/logger.d.ts +61 -0
  25. package/dist/sync/logger.d.ts.map +1 -0
  26. package/dist/sync/logger.js +51 -0
  27. package/dist/sync/logger.js.map +1 -0
  28. package/dist/sync/logger.test.d.ts +19 -0
  29. package/dist/sync/logger.test.d.ts.map +1 -0
  30. package/dist/sync/logger.test.js +199 -0
  31. package/dist/sync/logger.test.js.map +1 -0
  32. package/dist/sync/metrics.d.ts +89 -0
  33. package/dist/sync/metrics.d.ts.map +1 -0
  34. package/dist/sync/metrics.js +105 -0
  35. package/dist/sync/metrics.js.map +1 -0
  36. package/dist/sync/metrics.test.d.ts +19 -0
  37. package/dist/sync/metrics.test.d.ts.map +1 -0
  38. package/dist/sync/metrics.test.js +280 -0
  39. package/dist/sync/metrics.test.js.map +1 -0
  40. package/dist/sync/push-event.d.ts +110 -0
  41. package/dist/sync/push-event.d.ts.map +1 -0
  42. package/dist/sync/push-event.js +153 -0
  43. package/dist/sync/push-event.js.map +1 -0
  44. package/dist/sync/push-event.test.d.ts +15 -0
  45. package/dist/sync/push-event.test.d.ts.map +1 -0
  46. package/dist/sync/push-event.test.js +188 -0
  47. package/dist/sync/push-event.test.js.map +1 -0
  48. package/dist/sync/push-receiver.d.ts +442 -0
  49. package/dist/sync/push-receiver.d.ts.map +1 -0
  50. package/dist/sync/push-receiver.js +782 -0
  51. package/dist/sync/push-receiver.js.map +1 -0
  52. package/dist/sync/push-receiver.test.d.ts +25 -0
  53. package/dist/sync/push-receiver.test.d.ts.map +1 -0
  54. package/dist/sync/push-receiver.test.js +477 -0
  55. package/dist/sync/push-receiver.test.js.map +1 -0
  56. package/dist/sync/push-transport.d.ts +150 -0
  57. package/dist/sync/push-transport.d.ts.map +1 -0
  58. package/dist/sync/push-transport.js +150 -0
  59. package/dist/sync/push-transport.js.map +1 -0
  60. package/dist/watcher.d.ts +271 -0
  61. package/dist/watcher.d.ts.map +1 -1
  62. package/dist/watcher.js +480 -3
  63. package/dist/watcher.js.map +1 -1
  64. package/dist/watcher.test.d.ts +2 -0
  65. package/dist/watcher.test.d.ts.map +1 -0
  66. package/dist/watcher.test.js +334 -0
  67. package/dist/watcher.test.js.map +1 -0
  68. package/package.json +10 -5
  69. package/src/bin/sync-runner.test.ts +487 -1
  70. package/src/bin/sync-runner.ts +406 -9
  71. package/src/index.ts +38 -0
  72. package/src/sync/feature-flags.test.ts +392 -0
  73. package/src/sync/feature-flags.ts +229 -0
  74. package/src/sync/index.ts +74 -0
  75. package/src/sync/logger.test.ts +241 -0
  76. package/src/sync/logger.ts +79 -0
  77. package/src/sync/metrics.test.ts +380 -0
  78. package/src/sync/metrics.ts +158 -0
  79. package/src/sync/push-event.test.ts +224 -0
  80. package/src/sync/push-event.ts +208 -0
  81. package/src/sync/push-receiver.test.ts +545 -0
  82. package/src/sync/push-receiver.ts +1077 -0
  83. package/src/sync/push-transport.ts +231 -0
  84. package/src/watcher.test.ts +388 -0
  85. package/src/watcher.ts +672 -4
  86. package/test/e2e/sync/cross-tenant-isolation.test.ts +502 -0
  87. package/test/e2e/watcher-real-chokidar.test.ts +105 -0
@@ -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
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Per-tenant feature-flag module for the event-driven push pipeline.
3
+ *
4
+ * Ported from indigoai-us/hq-pro PR #112 (src/sync/feature-flags.ts) into
5
+ * @indigoai-us/hq-cloud (Path B) per project event-driven-sync-menubar US-008.
6
+ *
7
+ * Why this exists
8
+ * ───────────────
9
+ * The event-driven push pipeline must default OFF for all tenants and be
10
+ * enabled per-tenant ("OFF for all tenants by default, ON for Indigo only").
11
+ * This module provides the read-only seam the watcher (and a later receiver)
12
+ * consult at start time to decide whether to emit/ship PushEvents at all:
13
+ * - {@link EventDrivenPushFlagProvider} — the small read-only seam every
14
+ * caller consults. Returns a boolean for a given tenantId.
15
+ * - {@link EnvTenantListFlagProvider} — production implementation. Reads
16
+ * a comma-separated allow-list from {@link FEATURE_FLAG_TENANTS_ENV_VAR}
17
+ * AND honors the legacy global `HQ_SYNC_EVENT_DRIVEN_PUSH_ENABLED=true`
18
+ * as a backwards-compat global override. The legacy global wins when set
19
+ * — it enables all tenants.
20
+ * - {@link StaticFlagProvider} — small test helper. Constructor takes an
21
+ * iterable of enabled tenant IDs. Used by watcher + feature-flag tests.
22
+ * - {@link defaultFlagProvider} — factory callers default to when no
23
+ * provider is explicitly supplied.
24
+ *
25
+ * Validation posture
26
+ * ──────────────────
27
+ * Tenant IDs from the env are trimmed, empty entries are dropped, and
28
+ * entries containing internal whitespace are warn-and-skipped (NOT thrown).
29
+ * The watcher runs inside a long-lived daemon — a malformed env var must
30
+ * not crash daemon startup; it must surface in logs and fall through to the
31
+ * default-OFF behavior for the misconfigured entry.
32
+ *
33
+ * @see src/watcher.ts — the TreeWatcher consults this seam at start time to
34
+ * decide whether to emit + ship PushEvents (dormant when OFF).
35
+ */
36
+
37
+ // ─── Constants ─────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Env var holding a comma-separated allow-list of tenant IDs for which the
41
+ * event-driven push pipeline is enabled. Empty/unset means no tenants are
42
+ * enabled (default OFF for all). Example: `indigo,acme`.
43
+ *
44
+ * Tenant matching is exact and case-sensitive after trimming. Whitespace
45
+ * around commas is tolerated; internal whitespace within a tenant ID is
46
+ * not (warned + skipped — see `parseTenantList` below).
47
+ */
48
+ export const FEATURE_FLAG_TENANTS_ENV_VAR =
49
+ "HQ_SYNC_EVENT_DRIVEN_PUSH_ENABLED_TENANTS";
50
+
51
+ /**
52
+ * Legacy process-wide env var. When set to the literal string `"true"`, the
53
+ * pipeline is unconditionally enabled. Preserved as a "global override that
54
+ * enables ALL tenants" so a single-flag rollout (or local dev) keeps working.
55
+ *
56
+ * Operators flipping rollout via deploy-time config should prefer the
57
+ * per-tenant {@link FEATURE_FLAG_TENANTS_ENV_VAR} instead.
58
+ */
59
+ export const FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR =
60
+ "HQ_SYNC_EVENT_DRIVEN_PUSH_ENABLED";
61
+
62
+ // ─── Public types ──────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Read-only seam for the per-tenant event-driven push feature flag.
66
+ *
67
+ * Implementations are pure / synchronous. Callers MUST treat the result as
68
+ * the truth at the moment of the call — a deploy-time env-var change does
69
+ * NOT propagate to running daemons until they restart.
70
+ */
71
+ export interface EventDrivenPushFlagProvider {
72
+ /**
73
+ * Returns true iff the event-driven push pipeline is enabled for
74
+ * `tenantId`. Defaults to false. Case-sensitive exact match against the
75
+ * configured allow-list (or the legacy global override).
76
+ */
77
+ isEnabled(tenantId: string): boolean;
78
+ }
79
+
80
+ /**
81
+ * Optional injection seam for warnings emitted while parsing the env var
82
+ * (invalid entries). Defaults to `console.warn`.
83
+ */
84
+ export type FlagProviderWarnFn = (
85
+ message: string,
86
+ context: Record<string, unknown>,
87
+ ) => void;
88
+
89
+ // ─── Env-driven provider ───────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Options for {@link EnvTenantListFlagProvider}.
93
+ */
94
+ export interface EnvTenantListFlagProviderOptions {
95
+ /**
96
+ * Env snapshot. Defaults to `process.env`. Tests inject `{}` or a
97
+ * synthetic object so they don't mutate real env state.
98
+ */
99
+ env?: Record<string, string | undefined>;
100
+ /**
101
+ * Where to send "invalid entry, skipping" warnings. Defaults to
102
+ * `console.warn`.
103
+ */
104
+ warn?: FlagProviderWarnFn;
105
+ }
106
+
107
+ /**
108
+ * Production flag provider. Reads {@link FEATURE_FLAG_TENANTS_ENV_VAR} at
109
+ * construction time and caches the parsed set — `isEnabled()` is O(1).
110
+ *
111
+ * The legacy {@link FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR} (set to literal
112
+ * `"true"`) acts as a global override: when set, `isEnabled()` returns
113
+ * true for every tenant.
114
+ *
115
+ * Parse rules:
116
+ * - Empty / unset {@link FEATURE_FLAG_TENANTS_ENV_VAR} → no tenants enabled.
117
+ * - Whitespace around commas is tolerated and trimmed.
118
+ * - Empty entries (`",,foo,"`) are silently dropped.
119
+ * - Entries with INTERNAL whitespace (`"indigo team"`) are warn-and-skipped.
120
+ * - Matching is exact + case-sensitive.
121
+ */
122
+ export class EnvTenantListFlagProvider implements EventDrivenPushFlagProvider {
123
+ private readonly tenants: ReadonlySet<string>;
124
+ private readonly legacyGlobal: boolean;
125
+
126
+ constructor(opts: EnvTenantListFlagProviderOptions = {}) {
127
+ const env = opts.env ?? process.env;
128
+ const warn = opts.warn ?? defaultWarn;
129
+ this.tenants = parseTenantList(env[FEATURE_FLAG_TENANTS_ENV_VAR], warn);
130
+ this.legacyGlobal = env[FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR] === "true";
131
+ }
132
+
133
+ isEnabled(tenantId: string): boolean {
134
+ if (this.legacyGlobal) return true;
135
+ return this.tenants.has(tenantId);
136
+ }
137
+
138
+ /**
139
+ * Debug-only accessor — returns a defensive copy of the cached allow-list.
140
+ * Used by tests and the daemon's startup log line.
141
+ */
142
+ get enabledTenants(): ReadonlySet<string> {
143
+ return new Set(this.tenants);
144
+ }
145
+
146
+ /** Debug-only accessor — true iff the legacy global override is in effect. */
147
+ get globalOverride(): boolean {
148
+ return this.legacyGlobal;
149
+ }
150
+ }
151
+
152
+ // ─── Static provider (tests) ───────────────────────────────────────────────
153
+
154
+ /**
155
+ * In-memory provider for unit tests. The constructor takes the set of
156
+ * enabled tenant IDs; `isEnabled()` is a plain `Set.has` check.
157
+ *
158
+ * Production callers should NEVER construct this — use
159
+ * {@link EnvTenantListFlagProvider} via {@link defaultFlagProvider}.
160
+ */
161
+ export class StaticFlagProvider implements EventDrivenPushFlagProvider {
162
+ private readonly tenants: ReadonlySet<string>;
163
+
164
+ constructor(enabledTenants: Iterable<string>) {
165
+ this.tenants = new Set(enabledTenants);
166
+ }
167
+
168
+ isEnabled(tenantId: string): boolean {
169
+ return this.tenants.has(tenantId);
170
+ }
171
+ }
172
+
173
+ // ─── Factory ───────────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Build the default {@link EventDrivenPushFlagProvider} from an env
177
+ * snapshot. Production callers use this with no args — `process.env` is
178
+ * the implicit input.
179
+ */
180
+ export function defaultFlagProvider(
181
+ env: Record<string, string | undefined> = process.env,
182
+ ): EventDrivenPushFlagProvider {
183
+ return new EnvTenantListFlagProvider({ env });
184
+ }
185
+
186
+ // ─── Internals ─────────────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Parse a raw env value into a Set of valid tenant IDs.
190
+ *
191
+ * Kept as a free function so unit tests can call it via the class accessor
192
+ * with a synthetic warn spy without exporting an internal symbol.
193
+ */
194
+ function parseTenantList(
195
+ raw: string | undefined,
196
+ warn: FlagProviderWarnFn,
197
+ ): ReadonlySet<string> {
198
+ if (raw === undefined || raw === "") return new Set<string>();
199
+ const out = new Set<string>();
200
+ for (const piece of raw.split(",")) {
201
+ const trimmed = piece.trim();
202
+ if (trimmed === "") {
203
+ // Empty slot (`",,foo,"` or trailing commas) — silently skipped.
204
+ continue;
205
+ }
206
+ if (/\s/.test(trimmed)) {
207
+ // Internal whitespace — operator misconfiguration. Warn but don't
208
+ // throw: the daemon must keep starting, the misconfigured tenant
209
+ // just stays OFF.
210
+ warn("feature-flag: skipping tenant entry with internal whitespace", {
211
+ envVar: FEATURE_FLAG_TENANTS_ENV_VAR,
212
+ rawEntry: trimmed,
213
+ });
214
+ continue;
215
+ }
216
+ out.add(trimmed);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /**
222
+ * Default warn destination — plain `console.warn`.
223
+ */
224
+ function defaultWarn(
225
+ message: string,
226
+ context: Record<string, unknown>,
227
+ ): void {
228
+ console.warn(message, context);
229
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @indigoai-us/hq-cloud — event-driven sync (`src/sync`) barrel.
3
+ *
4
+ * Surfaces the PushEvent wire contract + the PushTransport shipping seam
5
+ * ported from hq-pro PR #112 (project event-driven-sync-menubar US-007).
6
+ */
7
+
8
+ export {
9
+ CONTENT_HASH_PATTERN,
10
+ ISO8601_DATETIME_PATTERN,
11
+ PushEventSchema,
12
+ PushEventDecodeError,
13
+ encodePushEvent,
14
+ decodePushEvent,
15
+ } from "./push-event.js";
16
+ export type { PushEvent, PushEventDecodeIssue } from "./push-event.js";
17
+
18
+ export { NoopPushTransport, HttpPushTransport } from "./push-transport.js";
19
+ export type {
20
+ PushTransport,
21
+ HttpPushTransportOptions,
22
+ AuthTokenSource,
23
+ FetchLike,
24
+ } from "./push-transport.js";
25
+
26
+ export {
27
+ FEATURE_FLAG_TENANTS_ENV_VAR,
28
+ FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR,
29
+ EnvTenantListFlagProvider,
30
+ StaticFlagProvider,
31
+ defaultFlagProvider,
32
+ } from "./feature-flags.js";
33
+ export type {
34
+ EventDrivenPushFlagProvider,
35
+ FlagProviderWarnFn,
36
+ EnvTenantListFlagProviderOptions,
37
+ } from "./feature-flags.js";
38
+
39
+ export {
40
+ NoopPushReceiver,
41
+ SqsPushReceiver,
42
+ InMemoryFanout,
43
+ InMemoryPushReceiver,
44
+ createPushReceiver,
45
+ DEFAULT_RECEIVER_DISPOSE_DRAIN_MS,
46
+ DEFAULT_WAIT_TIME_SECONDS,
47
+ DEFAULT_MAX_MESSAGES,
48
+ } from "./push-receiver.js";
49
+ export type {
50
+ PushReceiver,
51
+ PushReceiverContext,
52
+ SyncEngineFn,
53
+ PublishMetricFn,
54
+ ReceiverLogger,
55
+ SqsClientLike,
56
+ SqsMessageLike,
57
+ SqsPushReceiverOptions,
58
+ InMemoryPushReceiverOptions,
59
+ CreatePushReceiverOptions,
60
+ } from "./push-receiver.js";
61
+
62
+ export {
63
+ SYNC_METRIC_NAMESPACE,
64
+ SYNC_LATENCY_METRIC_NAME,
65
+ _setSyncCloudWatchClient,
66
+ publishSyncLatencyMetric,
67
+ } from "./metrics.js";
68
+ export type {
69
+ SyncLatencyMetric,
70
+ PublishSyncLatencyMetricOptions,
71
+ } from "./metrics.js";
72
+
73
+ export { createLogger } from "./logger.js";
74
+ export type { CreateLoggerOptions, Logger } from "./logger.js";