@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
|
@@ -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
|
+
}
|
package/src/sync/index.ts
CHANGED
|
@@ -15,5 +15,60 @@ export {
|
|
|
15
15
|
} from "./push-event.js";
|
|
16
16
|
export type { PushEvent, PushEventDecodeIssue } from "./push-event.js";
|
|
17
17
|
|
|
18
|
-
export { NoopPushTransport } from "./push-transport.js";
|
|
19
|
-
export type {
|
|
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";
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-011 — unit tests for the pino logger factory + the 3-log diagnostic
|
|
3
|
+
* chain's CLIENT-side correlated fields.
|
|
4
|
+
*
|
|
5
|
+
* The PRD requires three correlated logs sharing the same `sequenceNumber`:
|
|
6
|
+
* 1. `watcher.emit` — client push (US-008 PushEventEmitter) [client]
|
|
7
|
+
* 2. `push.receive` — server (hq-pro) [server]
|
|
8
|
+
* 3. `fanout.receive` — client fanout-receive (US-009 receiver) [client]
|
|
9
|
+
*
|
|
10
|
+
* This package owns the two CLIENT-side links. These tests prove:
|
|
11
|
+
* - `createLogger` stamps the `component` tag on every line and respects
|
|
12
|
+
* level + injected destination.
|
|
13
|
+
* - the watcher emits `event=watcher.emit` carrying `sequenceNumber`.
|
|
14
|
+
* - the receiver emits `event=fanout.receive` carrying the SAME
|
|
15
|
+
* `sequenceNumber` → an operator can join the two client links (and the
|
|
16
|
+
* server `push.receive` line, which carries the same key) end-to-end.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { Writable } from "node:stream";
|
|
23
|
+
|
|
24
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
25
|
+
|
|
26
|
+
import { createLogger } from "./logger.js";
|
|
27
|
+
import { encodePushEvent, type PushEvent } from "./push-event.js";
|
|
28
|
+
import { NoopPushTransport } from "./push-transport.js";
|
|
29
|
+
import { StaticFlagProvider } from "./feature-flags.js";
|
|
30
|
+
import {
|
|
31
|
+
SqsPushReceiver,
|
|
32
|
+
type SqsClientLike,
|
|
33
|
+
type SqsMessageLike,
|
|
34
|
+
} from "./push-receiver.js";
|
|
35
|
+
import { PushEventEmitter, type TreeChangeBatch } from "../watcher.js";
|
|
36
|
+
|
|
37
|
+
const TENANT = "tenant-indigo";
|
|
38
|
+
|
|
39
|
+
// ── Capture stream + JSON line reader ─────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function captureStream(): { stream: Writable; lines: () => Array<Record<string, unknown>> } {
|
|
42
|
+
const chunks: string[] = [];
|
|
43
|
+
const stream = new Writable({
|
|
44
|
+
write(chunk, _enc, cb) {
|
|
45
|
+
chunks.push(
|
|
46
|
+
typeof chunk === "string" ? chunk : (chunk as Buffer).toString("utf8"),
|
|
47
|
+
);
|
|
48
|
+
cb();
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
stream,
|
|
53
|
+
lines: () =>
|
|
54
|
+
chunks
|
|
55
|
+
.join("")
|
|
56
|
+
.split("\n")
|
|
57
|
+
.filter((s) => s.length > 0)
|
|
58
|
+
.map((s) => JSON.parse(s) as Record<string, unknown>),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function until(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
|
|
63
|
+
const start = Date.now();
|
|
64
|
+
while (!predicate()) {
|
|
65
|
+
if (Date.now() - start > timeoutMs) throw new Error("until() timed out");
|
|
66
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
71
|
+
return {
|
|
72
|
+
relativePath: "companies/indigo/notes.md",
|
|
73
|
+
contentHash:
|
|
74
|
+
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
75
|
+
mtime: "2026-05-21T12:34:56.000Z",
|
|
76
|
+
originDeviceId: "device-A",
|
|
77
|
+
originTenantId: TENANT,
|
|
78
|
+
sequenceNumber: 1,
|
|
79
|
+
eventTimestamp: "2026-05-21T12:34:56.000Z",
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class OneBatchSqs implements SqsClientLike {
|
|
85
|
+
private batch: SqsMessageLike[] | null;
|
|
86
|
+
constructor(messages: SqsMessageLike[]) {
|
|
87
|
+
this.batch = messages;
|
|
88
|
+
}
|
|
89
|
+
async receiveMessage(args: {
|
|
90
|
+
queueUrl: string;
|
|
91
|
+
maxMessages: number;
|
|
92
|
+
waitTimeSeconds: number;
|
|
93
|
+
signal: AbortSignal;
|
|
94
|
+
}): Promise<{ messages: SqsMessageLike[] }> {
|
|
95
|
+
if (this.batch) {
|
|
96
|
+
const b = this.batch;
|
|
97
|
+
this.batch = null;
|
|
98
|
+
return { messages: b };
|
|
99
|
+
}
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
if (args.signal.aborted) return resolve({ messages: [] });
|
|
102
|
+
const t = setTimeout(() => resolve({ messages: [] }), 5);
|
|
103
|
+
(t as { unref?: () => void }).unref?.();
|
|
104
|
+
args.signal.addEventListener(
|
|
105
|
+
"abort",
|
|
106
|
+
() => {
|
|
107
|
+
clearTimeout(t);
|
|
108
|
+
resolve({ messages: [] });
|
|
109
|
+
},
|
|
110
|
+
{ once: true },
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
async deleteMessage(): Promise<void> {
|
|
115
|
+
/* no-op */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── createLogger ──────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
describe("createLogger", () => {
|
|
122
|
+
it("stamps the `component` tag on every line", () => {
|
|
123
|
+
const { stream, lines } = captureStream();
|
|
124
|
+
const log = createLogger({
|
|
125
|
+
component: "sync-watcher",
|
|
126
|
+
destination: stream,
|
|
127
|
+
level: "info",
|
|
128
|
+
});
|
|
129
|
+
log.info({ event: "x" }, "hello");
|
|
130
|
+
log.warn({ event: "y" }, "world");
|
|
131
|
+
const out = lines();
|
|
132
|
+
expect(out).toHaveLength(2);
|
|
133
|
+
expect(out.every((l) => l.component === "sync-watcher")).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("respects the injected level (debug below info is dropped at info)", () => {
|
|
137
|
+
const { stream, lines } = captureStream();
|
|
138
|
+
const log = createLogger({
|
|
139
|
+
component: "c",
|
|
140
|
+
destination: stream,
|
|
141
|
+
level: "info",
|
|
142
|
+
});
|
|
143
|
+
log.debug({ event: "d" }, "dropped");
|
|
144
|
+
log.info({ event: "i" }, "kept");
|
|
145
|
+
const out = lines();
|
|
146
|
+
expect(out).toHaveLength(1);
|
|
147
|
+
expect(out[0]!.event).toBe("i");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── 3-log chain: client-side correlated fields share sequenceNumber ───────────
|
|
152
|
+
|
|
153
|
+
describe("3-log diagnostic chain (client links share sequenceNumber)", () => {
|
|
154
|
+
let tmp: string | undefined;
|
|
155
|
+
let receiver: SqsPushReceiver | undefined;
|
|
156
|
+
|
|
157
|
+
afterEach(async () => {
|
|
158
|
+
if (receiver) {
|
|
159
|
+
await receiver.dispose();
|
|
160
|
+
receiver = undefined;
|
|
161
|
+
}
|
|
162
|
+
if (tmp) {
|
|
163
|
+
await rm(tmp, { recursive: true, force: true });
|
|
164
|
+
tmp = undefined;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("watcher.emit and fanout.receive carry the SAME sequenceNumber", async () => {
|
|
169
|
+
const SEQ = 4242;
|
|
170
|
+
|
|
171
|
+
// ── Link 1: watcher.emit (client push side) ──────────────────────────
|
|
172
|
+
tmp = await mkdtemp(join(tmpdir(), "us011-logger-"));
|
|
173
|
+
const absPath = join(tmp, "notes.md");
|
|
174
|
+
await writeFile(absPath, "hello world", "utf8");
|
|
175
|
+
|
|
176
|
+
const watcherCap = captureStream();
|
|
177
|
+
const watcherLog = createLogger({
|
|
178
|
+
component: "sync-watcher",
|
|
179
|
+
destination: watcherCap.stream,
|
|
180
|
+
level: "info",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const emitter = new PushEventEmitter({
|
|
184
|
+
originTenantId: TENANT,
|
|
185
|
+
originDeviceId: "device-A",
|
|
186
|
+
transport: new NoopPushTransport(),
|
|
187
|
+
flagProvider: new StaticFlagProvider([TENANT]),
|
|
188
|
+
getSequenceNumber: () => SEQ,
|
|
189
|
+
logger: watcherLog,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const batch: TreeChangeBatch = {
|
|
193
|
+
paths: new Map([[absPath, "companies/indigo/notes.md"]]),
|
|
194
|
+
};
|
|
195
|
+
await emitter.emitForBatch(batch);
|
|
196
|
+
|
|
197
|
+
const emitLine = watcherCap
|
|
198
|
+
.lines()
|
|
199
|
+
.find((l) => l.event === "watcher.emit");
|
|
200
|
+
expect(emitLine).toBeDefined();
|
|
201
|
+
expect(emitLine!.sequenceNumber).toBe(SEQ);
|
|
202
|
+
expect(emitLine!.component).toBe("sync-watcher");
|
|
203
|
+
|
|
204
|
+
// ── Link 3: fanout.receive (client receive side) ─────────────────────
|
|
205
|
+
const receiverCap = captureStream();
|
|
206
|
+
const receiverLog = createLogger({
|
|
207
|
+
component: "sync-receiver",
|
|
208
|
+
destination: receiverCap.stream,
|
|
209
|
+
level: "info",
|
|
210
|
+
});
|
|
211
|
+
const event = makeEvent({ sequenceNumber: SEQ });
|
|
212
|
+
|
|
213
|
+
receiver = new SqsPushReceiver({
|
|
214
|
+
tenantId: TENANT,
|
|
215
|
+
queueUrl: "https://sqs.local/q",
|
|
216
|
+
sqs: new OneBatchSqs([{ Body: encodePushEvent(event), ReceiptHandle: "rh" }]),
|
|
217
|
+
syncFn: async () => {
|
|
218
|
+
/* success */
|
|
219
|
+
},
|
|
220
|
+
enabled: true,
|
|
221
|
+
logger: receiverLog,
|
|
222
|
+
// Inject a no-op metric so no real AWS is touched.
|
|
223
|
+
publishMetric: async () => {},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await receiver.start();
|
|
227
|
+
await until(() =>
|
|
228
|
+
receiverCap.lines().some((l) => l.event === "fanout.receive"),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const recvLine = receiverCap
|
|
232
|
+
.lines()
|
|
233
|
+
.find((l) => l.event === "fanout.receive");
|
|
234
|
+
expect(recvLine).toBeDefined();
|
|
235
|
+
expect(recvLine!.sequenceNumber).toBe(SEQ);
|
|
236
|
+
expect(recvLine!.component).toBe("sync-receiver");
|
|
237
|
+
|
|
238
|
+
// ── The join key matches across both client links ────────────────────
|
|
239
|
+
expect(emitLine!.sequenceNumber).toBe(recvLine!.sequenceNumber);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component-tagged logger factory for the hq-cloud sync subsystem
|
|
3
|
+
* (project event-driven-sync-menubar US-011).
|
|
4
|
+
*
|
|
5
|
+
* Every log line emitted from a sync module SHOULD carry a `component` field
|
|
6
|
+
* so operators can grep / route by subsystem in the aggregated JSON stream.
|
|
7
|
+
* `createLogger({ component: "sync-watcher" })` is the canonical entry point —
|
|
8
|
+
* modules call this once at module-load time and use the returned logger for
|
|
9
|
+
* the lifetime of the process.
|
|
10
|
+
*
|
|
11
|
+
* The 3-log diagnostic chain (US-011)
|
|
12
|
+
* ───────────────────────────────────
|
|
13
|
+
* Three correlated log lines share a single `sequenceNumber` join key so an
|
|
14
|
+
* operator can walk one event end-to-end:
|
|
15
|
+
* 1. `event=watcher.emit` — client push side (US-008 PushEventEmitter)
|
|
16
|
+
* 2. `event=push.receive` — server side (hq-pro; context only here)
|
|
17
|
+
* 3. `event=fanout.receive` — client fanout-receive side (US-009 receiver)
|
|
18
|
+
* The watcher.emit + fanout.receive halves live in this client package; the
|
|
19
|
+
* push.receive half lives server-side in hq-pro. All three stamp the same
|
|
20
|
+
* `sequenceNumber` for log-chain correlation.
|
|
21
|
+
*
|
|
22
|
+
* Output format: pino's default newline-delimited JSON. No transports, no
|
|
23
|
+
* pretty-printing — the daemon consumes the raw JSON stream. (Operators who
|
|
24
|
+
* want pretty output pipe through `pino-pretty` themselves.)
|
|
25
|
+
*
|
|
26
|
+
* Destination injection: tests pass a `destination` stream so they can capture
|
|
27
|
+
* log lines and assert on them. Production callers omit it and pino defaults
|
|
28
|
+
* to `process.stdout`.
|
|
29
|
+
*
|
|
30
|
+
* Adapted from indigoai-us/hq-pro PR #112 (src/sync/logger.ts) into
|
|
31
|
+
* @indigoai-us/hq-cloud (Path B).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
pino,
|
|
36
|
+
type DestinationStream,
|
|
37
|
+
type Level,
|
|
38
|
+
type Logger,
|
|
39
|
+
type LoggerOptions,
|
|
40
|
+
} from "pino";
|
|
41
|
+
|
|
42
|
+
export interface CreateLoggerOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Component tag stamped on every log line as `"component": <value>`.
|
|
45
|
+
* Required so daemon / watcher / receiver lines never appear in the stream
|
|
46
|
+
* untagged.
|
|
47
|
+
*/
|
|
48
|
+
component: string;
|
|
49
|
+
/**
|
|
50
|
+
* Optional pino level. Default: pino's own default (`info`). Set via
|
|
51
|
+
* `LOG_LEVEL` env var, command-line flag, or test injection.
|
|
52
|
+
*/
|
|
53
|
+
level?: Level;
|
|
54
|
+
/**
|
|
55
|
+
* Optional pino destination. Default: `process.stdout`. Tests inject a
|
|
56
|
+
* memory stream here to capture lines for assertion.
|
|
57
|
+
*/
|
|
58
|
+
destination?: DestinationStream;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a pino logger pre-bound to a `component` tag.
|
|
63
|
+
*
|
|
64
|
+
* Use this — not a bare `pino()` call — so every sync module's log lines carry
|
|
65
|
+
* the tag uniformly. Adding more bound fields (e.g. `deviceId`, `tenantId`) is
|
|
66
|
+
* a `logger.child({ ... })` call away.
|
|
67
|
+
*/
|
|
68
|
+
export function createLogger(opts: CreateLoggerOptions): Logger {
|
|
69
|
+
const { component, level, destination } = opts;
|
|
70
|
+
const pinoOpts: LoggerOptions = {
|
|
71
|
+
base: { component },
|
|
72
|
+
...(level === undefined ? {} : { level }),
|
|
73
|
+
};
|
|
74
|
+
return destination === undefined
|
|
75
|
+
? pino(pinoOpts)
|
|
76
|
+
: pino(pinoOpts, destination);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type { Logger } from "pino";
|