@indigoai-us/hq-cloud 6.14.49 → 6.14.50

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 (112) hide show
  1. package/.github/workflows/unreleased-commits-nag.yml +256 -0
  2. package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
  3. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  4. package/dist/bin/sync-runner-watch-loop.js +153 -25
  5. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  6. package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
  7. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-watch-routes.js +11 -0
  9. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
  11. package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
  12. package/dist/bin/sync-runner-watch-routes.test.js +51 -0
  13. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
  14. package/dist/bin/sync-runner.d.ts +11 -12
  15. package/dist/bin/sync-runner.d.ts.map +1 -1
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/sync-runner.test.js +173 -52
  18. package/dist/bin/sync-runner.test.js.map +1 -1
  19. package/dist/cli/rescue-classify-ordering.test.js +8 -2
  20. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  21. package/dist/cli/rescue-core.d.ts +85 -0
  22. package/dist/cli/rescue-core.d.ts.map +1 -1
  23. package/dist/cli/rescue-core.js +390 -1
  24. package/dist/cli/rescue-core.js.map +1 -1
  25. package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
  26. package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
  27. package/dist/cli/rescue-settings-reconcile.test.js +476 -0
  28. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/journal.d.ts.map +1 -1
  34. package/dist/journal.js +81 -51
  35. package/dist/journal.js.map +1 -1
  36. package/dist/journal.test.js +11 -19
  37. package/dist/journal.test.js.map +1 -1
  38. package/dist/sync/event-sync.d.ts +8 -25
  39. package/dist/sync/event-sync.d.ts.map +1 -1
  40. package/dist/sync/event-sync.js +23 -48
  41. package/dist/sync/event-sync.js.map +1 -1
  42. package/dist/sync/event-sync.test.js +6 -32
  43. package/dist/sync/event-sync.test.js.map +1 -1
  44. package/dist/sync/feature-flags.d.ts +20 -117
  45. package/dist/sync/feature-flags.d.ts.map +1 -1
  46. package/dist/sync/feature-flags.js +24 -147
  47. package/dist/sync/feature-flags.js.map +1 -1
  48. package/dist/sync/feature-flags.test.js +15 -61
  49. package/dist/sync/feature-flags.test.js.map +1 -1
  50. package/dist/sync/index.d.ts +1 -1
  51. package/dist/sync/index.d.ts.map +1 -1
  52. package/dist/sync/index.js +1 -1
  53. package/dist/sync/index.js.map +1 -1
  54. package/dist/sync/lease-client.d.ts +67 -0
  55. package/dist/sync/lease-client.d.ts.map +1 -0
  56. package/dist/sync/lease-client.js +158 -0
  57. package/dist/sync/lease-client.js.map +1 -0
  58. package/dist/sync/lease-client.test.d.ts +2 -0
  59. package/dist/sync/lease-client.test.d.ts.map +1 -0
  60. package/dist/sync/lease-client.test.js +119 -0
  61. package/dist/sync/lease-client.test.js.map +1 -0
  62. package/dist/sync/push-receiver.d.ts +8 -19
  63. package/dist/sync/push-receiver.d.ts.map +1 -1
  64. package/dist/sync/push-receiver.js +6 -50
  65. package/dist/sync/push-receiver.js.map +1 -1
  66. package/dist/sync/push-receiver.test.js +6 -57
  67. package/dist/sync/push-receiver.test.js.map +1 -1
  68. package/dist/sync/realtime-rollout.d.ts +85 -0
  69. package/dist/sync/realtime-rollout.d.ts.map +1 -0
  70. package/dist/sync/realtime-rollout.js +206 -0
  71. package/dist/sync/realtime-rollout.js.map +1 -0
  72. package/dist/sync/realtime-rollout.test.d.ts +2 -0
  73. package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
  74. package/dist/sync/realtime-rollout.test.js +71 -0
  75. package/dist/sync/realtime-rollout.test.js.map +1 -0
  76. package/dist/sync/state-store.d.ts +40 -0
  77. package/dist/sync/state-store.d.ts.map +1 -0
  78. package/dist/sync/state-store.js +680 -0
  79. package/dist/sync/state-store.js.map +1 -0
  80. package/dist/sync/state-store.test.d.ts +2 -0
  81. package/dist/sync/state-store.test.d.ts.map +1 -0
  82. package/dist/sync/state-store.test.js +159 -0
  83. package/dist/sync/state-store.test.js.map +1 -0
  84. package/dist/types.d.ts +12 -0
  85. package/dist/types.d.ts.map +1 -1
  86. package/package.json +1 -1
  87. package/src/bin/sync-runner-watch-loop.ts +176 -32
  88. package/src/bin/sync-runner-watch-routes.test.ts +71 -0
  89. package/src/bin/sync-runner-watch-routes.ts +27 -0
  90. package/src/bin/sync-runner.test.ts +188 -53
  91. package/src/bin/sync-runner.ts +11 -12
  92. package/src/cli/rescue-classify-ordering.test.ts +9 -3
  93. package/src/cli/rescue-core.ts +459 -1
  94. package/src/cli/rescue-settings-reconcile.test.ts +583 -0
  95. package/src/index.ts +2 -4
  96. package/src/journal.test.ts +19 -21
  97. package/src/journal.ts +87 -54
  98. package/src/sync/event-sync.test.ts +9 -44
  99. package/src/sync/event-sync.ts +26 -52
  100. package/src/sync/feature-flags.test.ts +22 -72
  101. package/src/sync/feature-flags.ts +32 -199
  102. package/src/sync/index.ts +2 -4
  103. package/src/sync/lease-client.test.ts +128 -0
  104. package/src/sync/lease-client.ts +207 -0
  105. package/src/sync/push-receiver.test.ts +6 -58
  106. package/src/sync/push-receiver.ts +9 -74
  107. package/src/sync/realtime-rollout.test.ts +86 -0
  108. package/src/sync/realtime-rollout.ts +262 -0
  109. package/src/sync/state-store.test.ts +194 -0
  110. package/src/sync/state-store.ts +727 -0
  111. package/src/types.ts +14 -0
  112. package/test/e2e/sync/transient-company-leg.test.ts +8 -4
@@ -25,7 +25,6 @@
25
25
  import { describe, expect, it } from "vitest";
26
26
 
27
27
  import { encodePushEvent, type PushEvent } from "./push-event.js";
28
- import { StaticFlagProvider } from "./feature-flags.js";
29
28
  import {
30
29
  InMemoryFanout,
31
30
  InMemoryPushReceiver,
@@ -155,7 +154,6 @@ describe("US-009: SqsPushReceiver — subscribe lifecycle", () => {
155
154
  queueUrl: QUEUE_URL,
156
155
  sqs,
157
156
  syncFn,
158
- enabled: true,
159
157
  sleep: fastSleep,
160
158
  });
161
159
 
@@ -176,7 +174,6 @@ describe("US-009: SqsPushReceiver — subscribe lifecycle", () => {
176
174
  queueUrl: QUEUE_URL,
177
175
  sqs,
178
176
  syncFn: async () => undefined,
179
- enabled: true,
180
177
  sleep: fastSleep,
181
178
  });
182
179
  await receiver.start();
@@ -203,7 +200,6 @@ describe("US-009: SqsPushReceiver — subscribe lifecycle", () => {
203
200
  queueUrl: QUEUE_URL,
204
201
  sqs: spy,
205
202
  syncFn: async () => undefined,
206
- enabled: true,
207
203
  sleep: fastSleep,
208
204
  });
209
205
  await receiver.start();
@@ -220,7 +216,6 @@ describe("US-009: SqsPushReceiver — subscribe lifecycle", () => {
220
216
  queueUrl: QUEUE_URL,
221
217
  sqs: new FakeSqs(),
222
218
  syncFn: async () => undefined,
223
- enabled: true,
224
219
  }),
225
220
  ).toThrow(/tenantId is required/);
226
221
  expect(
@@ -230,7 +225,6 @@ describe("US-009: SqsPushReceiver — subscribe lifecycle", () => {
230
225
  queueUrl: "",
231
226
  sqs: new FakeSqs(),
232
227
  syncFn: async () => undefined,
233
- enabled: true,
234
228
  }),
235
229
  ).toThrow(/queueUrl is required/);
236
230
  });
@@ -253,7 +247,6 @@ describe("US-009: SqsPushReceiver — dedupe", () => {
253
247
  queueUrl: QUEUE_URL,
254
248
  sqs,
255
249
  syncFn,
256
- enabled: true,
257
250
  sleep: fastSleep,
258
251
  });
259
252
  await receiver.start();
@@ -281,7 +274,6 @@ describe("US-009: SqsPushReceiver — dedupe", () => {
281
274
  queueUrl: QUEUE_URL,
282
275
  sqs,
283
276
  syncFn,
284
- enabled: true,
285
277
  sleep: fastSleep,
286
278
  });
287
279
  await receiver.start();
@@ -302,7 +294,6 @@ describe("US-009: SqsPushReceiver — dedupe", () => {
302
294
  queueUrl: QUEUE_URL,
303
295
  sqs,
304
296
  syncFn: async () => undefined,
305
- enabled: true,
306
297
  sleep: fastSleep,
307
298
  });
308
299
  await receiver.start();
@@ -324,7 +315,6 @@ describe("US-009: SqsPushReceiver — reconnect / catch-up replay", () => {
324
315
  queueUrl: QUEUE_URL,
325
316
  sqs,
326
317
  syncFn: async () => undefined,
327
- enabled: true,
328
318
  sleep: fastSleep,
329
319
  reconnect: { initialMs: 1, maxMs: 4, jitter: false },
330
320
  });
@@ -349,7 +339,6 @@ describe("US-009: SqsPushReceiver — reconnect / catch-up replay", () => {
349
339
  queueUrl: QUEUE_URL,
350
340
  sqs,
351
341
  syncFn,
352
- enabled: true,
353
342
  sleep: fastSleep,
354
343
  });
355
344
  await receiver.start();
@@ -383,7 +372,6 @@ describe("US-009: SqsPushReceiver — reconnect / catch-up replay", () => {
383
372
  queueUrl: QUEUE_URL,
384
373
  sqs,
385
374
  syncFn,
386
- enabled: true,
387
375
  sleep: fastSleep,
388
376
  });
389
377
  await receiver.start();
@@ -414,7 +402,6 @@ describe("US-009: SqsPushReceiver — reconnect / catch-up replay", () => {
414
402
  queueUrl: QUEUE_URL,
415
403
  sqs,
416
404
  syncFn,
417
- enabled: true,
418
405
  sleep: fastSleep,
419
406
  dedupeMaxPaths: 2,
420
407
  });
@@ -433,25 +420,8 @@ describe("US-009: SqsPushReceiver — reconnect / catch-up replay", () => {
433
420
  });
434
421
  });
435
422
 
436
- describe("US-009: SqsPushReceiver — flag gating", () => {
437
- it("is dormant when the per-tenant flag is OFF (no queue polled, connected stays false)", async () => {
438
- const sqs = new FakeSqs();
439
- const receiver = new SqsPushReceiver({
440
- tenantId: TENANT,
441
- queueUrl: QUEUE_URL,
442
- sqs,
443
- syncFn: async () => undefined,
444
- flagProvider: new StaticFlagProvider([]), // tenant NOT enabled
445
- sleep: fastSleep,
446
- });
447
- await receiver.start();
448
- await new Promise((r) => setTimeout(r, 20));
449
- expect(receiver.connected).toBe(false);
450
- expect(sqs.receiveCalls).toBe(0);
451
- await receiver.dispose();
452
- });
453
-
454
- it("is active when the per-tenant flag includes the tenant", async () => {
423
+ describe("U15: SqsPushReceiver — server-granted handoff", () => {
424
+ it("starts from supplied server queue material with no tenant client gate", async () => {
455
425
  const sqs = new FakeSqs();
456
426
  sqs.enqueue([msg(makeEvent({ sequenceNumber: 1 }))]);
457
427
  const receiver = new SqsPushReceiver({
@@ -459,7 +429,6 @@ describe("US-009: SqsPushReceiver — flag gating", () => {
459
429
  queueUrl: QUEUE_URL,
460
430
  sqs,
461
431
  syncFn: async () => undefined,
462
- flagProvider: new StaticFlagProvider([TENANT]),
463
432
  sleep: fastSleep,
464
433
  });
465
434
  await receiver.start();
@@ -468,23 +437,6 @@ describe("US-009: SqsPushReceiver — flag gating", () => {
468
437
  await receiver.dispose();
469
438
  });
470
439
 
471
- it("explicit enabled wins over the provider", async () => {
472
- const sqs = new FakeSqs();
473
- const receiver = new SqsPushReceiver({
474
- tenantId: TENANT,
475
- queueUrl: QUEUE_URL,
476
- sqs,
477
- syncFn: async () => undefined,
478
- enabled: false,
479
- flagProvider: new StaticFlagProvider([TENANT]),
480
- sleep: fastSleep,
481
- });
482
- await receiver.start();
483
- await new Promise((r) => setTimeout(r, 20));
484
- expect(receiver.connected).toBe(false);
485
- expect(sqs.receiveCalls).toBe(0);
486
- await receiver.dispose();
487
- });
488
440
  });
489
441
 
490
442
  describe("US-009: SqsPushReceiver — dispose drain", () => {
@@ -506,7 +458,6 @@ describe("US-009: SqsPushReceiver — dispose drain", () => {
506
458
  queueUrl: QUEUE_URL,
507
459
  sqs,
508
460
  syncFn,
509
- enabled: true,
510
461
  sleep: fastSleep,
511
462
  disposeDrainMs: 200,
512
463
  });
@@ -524,7 +475,6 @@ describe("US-009: SqsPushReceiver — dispose drain", () => {
524
475
  queueUrl: QUEUE_URL,
525
476
  sqs,
526
477
  syncFn: async () => undefined,
527
- enabled: true,
528
478
  sleep: fastSleep,
529
479
  });
530
480
  await receiver.start();
@@ -542,7 +492,6 @@ describe("US-009: InMemoryPushReceiver — disconnect buffering + reconnect drai
542
492
  const receiver = new InMemoryPushReceiver({
543
493
  tenantId: TENANT,
544
494
  fanout,
545
- enabled: true,
546
495
  syncFn: async (ctx) => {
547
496
  processed.push(ctx.event.sequenceNumber);
548
497
  },
@@ -568,13 +517,12 @@ describe("US-009: InMemoryPushReceiver — disconnect buffering + reconnect drai
568
517
  expect(receiver.dedupedCount).toBe(1);
569
518
  });
570
519
 
571
- it("is dormant when the flag is OFF", async () => {
520
+ it("does not derive activation from a tenant flag", async () => {
572
521
  const fanout = new InMemoryFanout();
573
522
  let calls = 0;
574
523
  const receiver = new InMemoryPushReceiver({
575
524
  tenantId: TENANT,
576
525
  fanout,
577
- flagProvider: new StaticFlagProvider([]),
578
526
  syncFn: async () => {
579
527
  calls += 1;
580
528
  },
@@ -582,8 +530,9 @@ describe("US-009: InMemoryPushReceiver — disconnect buffering + reconnect drai
582
530
  await receiver.start();
583
531
  fanout.publish(encodePushEvent(makeEvent()));
584
532
  await new Promise((r) => setTimeout(r, 20));
585
- expect(receiver.connected).toBe(false);
586
- expect(calls).toBe(0);
533
+ await waitFor(() => calls === 1);
534
+ expect(receiver.connected).toBe(true);
535
+ expect(calls).toBe(1);
587
536
  await receiver.dispose();
588
537
  });
589
538
  });
@@ -602,7 +551,6 @@ describe("US-009: createPushReceiver factory", () => {
602
551
  queueUrl: QUEUE_URL,
603
552
  sqs: new FakeSqs(),
604
553
  syncFn: async () => undefined,
605
- enabled: true,
606
554
  });
607
555
  expect(r).toBeInstanceOf(SqsPushReceiver);
608
556
  });
@@ -71,13 +71,11 @@
71
71
  * events are absorbed by the dedupe path. The in-memory fake's
72
72
  * `simulateDisconnect()` / `simulateReconnect()` model exactly this buffering.
73
73
  *
74
- * Feature flag (AC#4)
75
- * ───────────────────
76
- * Gated by the per-tenant {@link EventDrivenPushFlagProvider} (US-008's
77
- * feature-flags.ts) honors `HQ_SYNC_EVENT_DRIVEN_PUSH_ENABLED_TENANTS`
78
- * (per-tenant) + the legacy global `HQ_SYNC_EVENT_DRIVEN_PUSH_ENABLED=true`.
79
- * Default DISABLED. Resolution precedence: explicit `enabled` > injected
80
- * `flagProvider.isEnabled(tenantId)` > default env-driven provider.
74
+ * Authority
75
+ * ─────────
76
+ * This receiver is created only after the event-sync wiring obtains
77
+ * authenticated server inventory/credentials. It has no tenant, environment,
78
+ * or queued-hint-derived positive gate.
81
79
  *
82
80
  * Cross-tenant isolation (US-010)
83
81
  * ───────────────────────────────
@@ -98,10 +96,6 @@
98
96
  */
99
97
 
100
98
  import { decodePushEvent, type PushEvent } from "./push-event.js";
101
- import {
102
- defaultFlagProvider,
103
- type EventDrivenPushFlagProvider,
104
- } from "./feature-flags.js";
105
99
  import {
106
100
  publishSyncLatencyMetric,
107
101
  type SyncLatencyMetric,
@@ -290,27 +284,6 @@ export class NoopPushReceiver implements PushReceiver {
290
284
  }
291
285
  }
292
286
 
293
- // ─── Feature-flag resolution ─────────────────────────────────────────────────
294
-
295
- /**
296
- * Resolve the enabled boolean from layered config. Precedence (highest first):
297
- * 1. `explicit` — `enabled: true|false` wins outright (test contract).
298
- * 2. `flagProvider.isEnabled(tenantId)` — injected per-tenant provider.
299
- * 3. default {@link EnvTenantListFlagProvider} from `env ?? process.env`.
300
- */
301
- function resolveEnabled(args: {
302
- explicit: boolean | undefined;
303
- flagProvider: EventDrivenPushFlagProvider | undefined;
304
- tenantId: string;
305
- env: Record<string, string | undefined> | undefined;
306
- }): boolean {
307
- if (args.explicit !== undefined) return args.explicit;
308
- if (args.flagProvider !== undefined) {
309
- return args.flagProvider.isEnabled(args.tenantId);
310
- }
311
- return defaultFlagProvider(args.env ?? process.env).isEnabled(args.tenantId);
312
- }
313
-
314
287
  // ─── SQS receiver ─────────────────────────────────────────────────────────
315
288
 
316
289
  /**
@@ -339,15 +312,10 @@ export interface SqsPushReceiverOptions {
339
312
  /** Structured logger. Default: a no-op (quiet). */
340
313
  logger?: ReceiverLogger;
341
314
  /**
342
- * Feature-flag override wins over provider + env when set explicitly.
343
- * When the resolved value is false, `start()` is a no-op, `connected` stays
344
- * false, and NO queue is polled. (AC#4)
315
+ * Deprecated compatibility field. It is intentionally ignored: it can no
316
+ * longer enable or disable a receiver after server authorization.
345
317
  */
346
318
  enabled?: boolean;
347
- /** Per-tenant flag provider (US-008). Consulted when `enabled` is unset. */
348
- flagProvider?: EventDrivenPushFlagProvider;
349
- /** Env snapshot for flag resolution. Default: `process.env`. */
350
- env?: Record<string, string | undefined>;
351
319
  /** SQS long-poll wait seconds. Default {@link DEFAULT_WAIT_TIME_SECONDS}. */
352
320
  waitTimeSeconds?: number;
353
321
  /** Max messages per receive. Default {@link DEFAULT_MAX_MESSAGES}. */
@@ -394,7 +362,6 @@ export class SqsPushReceiver implements PushReceiver {
394
362
  private readonly sqs: SqsClientLike;
395
363
  private readonly syncFn: SyncEngineFn;
396
364
  private readonly logger: ReceiverLogger;
397
- private readonly enabled: boolean;
398
365
  private readonly waitTimeSeconds: number;
399
366
  private readonly maxMessages: number;
400
367
  private readonly disposeDrainMs: number;
@@ -438,12 +405,6 @@ export class SqsPushReceiver implements PushReceiver {
438
405
  this.sqs = opts.sqs;
439
406
  this.syncFn = opts.syncFn;
440
407
  this.logger = opts.logger ?? NOOP_LOGGER;
441
- this.enabled = resolveEnabled({
442
- explicit: opts.enabled,
443
- flagProvider: opts.flagProvider,
444
- tenantId: opts.tenantId,
445
- env: opts.env,
446
- });
447
408
  this.waitTimeSeconds = opts.waitTimeSeconds ?? DEFAULT_WAIT_TIME_SECONDS;
448
409
  this.maxMessages = opts.maxMessages ?? DEFAULT_MAX_MESSAGES;
449
410
  this.disposeDrainMs =
@@ -467,17 +428,6 @@ export class SqsPushReceiver implements PushReceiver {
467
428
 
468
429
  async start(): Promise<void> {
469
430
  if (this.disposed) return;
470
- if (!this.enabled) {
471
- this.logger.info(
472
- {
473
- event: "receiver.start.disabled",
474
- tenantId: this.tenantId,
475
- reason: "feature flag off",
476
- },
477
- "push receiver disabled by feature flag",
478
- );
479
- return;
480
- }
481
431
  if (this.loopAbort !== null) {
482
432
  // Double-start is a no-op — matches PushTransport idempotency posture.
483
433
  this.logger.debug(
@@ -853,9 +803,8 @@ export interface InMemoryPushReceiverOptions {
853
803
  fanout: InMemoryFanout;
854
804
  syncFn: SyncEngineFn;
855
805
  logger?: ReceiverLogger;
806
+ /** Deprecated compatibility field; intentionally ignored. */
856
807
  enabled?: boolean;
857
- flagProvider?: EventDrivenPushFlagProvider;
858
- env?: Record<string, string | undefined>;
859
808
  disposeDrainMs?: number;
860
809
  /** Maximum distinct paths retained in dedupe state. */
861
810
  dedupeMaxPaths?: number;
@@ -873,7 +822,6 @@ export class InMemoryPushReceiver implements PushReceiver {
873
822
  private readonly fanout: InMemoryFanout;
874
823
  private readonly syncFn: SyncEngineFn;
875
824
  private readonly logger: ReceiverLogger;
876
- private readonly enabled: boolean;
877
825
  private readonly disposeDrainMs: number;
878
826
 
879
827
  private _connected = false;
@@ -901,12 +849,6 @@ export class InMemoryPushReceiver implements PushReceiver {
901
849
  this.disposeDrainMs =
902
850
  opts.disposeDrainMs ?? DEFAULT_RECEIVER_DISPOSE_DRAIN_MS;
903
851
  this.seenSequencePerPath = new BoundedSequenceDedupe(opts.dedupeMaxPaths);
904
- this.enabled = resolveEnabled({
905
- explicit: opts.enabled,
906
- flagProvider: opts.flagProvider,
907
- tenantId: opts.tenantId,
908
- env: opts.env,
909
- });
910
852
  }
911
853
 
912
854
  get connected(): boolean {
@@ -915,13 +857,6 @@ export class InMemoryPushReceiver implements PushReceiver {
915
857
 
916
858
  async start(): Promise<void> {
917
859
  if (this.disposed) return;
918
- if (!this.enabled) {
919
- this.logger.info(
920
- { event: "receiver.start.disabled", tenantId: this.tenantId },
921
- "push receiver disabled by feature flag",
922
- );
923
- return;
924
- }
925
860
  if (this.unsubscribe !== null) return; // double-start no-op
926
861
 
927
862
  this.unsubscribe = this.fanout.subscribe((raw) => {
@@ -1013,7 +948,7 @@ export class InMemoryPushReceiver implements PushReceiver {
1013
948
 
1014
949
  /** Emulate a network blip — events buffer instead of dispatching. */
1015
950
  simulateDisconnect(): void {
1016
- if (!this.enabled || this.unsubscribe === null) return;
951
+ if (this.unsubscribe === null) return;
1017
952
  this.disconnectedFlag = true;
1018
953
  this._connected = false;
1019
954
  }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ MAX_REALTIME_ROLLOUT_CACHE_MS,
5
+ RealtimeRolloutClient,
6
+ parseRealtimeInventory,
7
+ type RealtimeCapabilityVector,
8
+ } from "./realtime-rollout.js";
9
+
10
+ const capability: RealtimeCapabilityVector = {
11
+ hqCloud: "6.14.49",
12
+ hqDesktop: "none",
13
+ protocol: 2,
14
+ journal: 3,
15
+ durableApply: 1,
16
+ };
17
+
18
+ function jsonResponse(body: unknown, status = 200) {
19
+ return { ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(body) };
20
+ }
21
+
22
+ function request(overrides: Partial<Parameters<RealtimeRolloutClient["inventory"]>[0]> = {}) {
23
+ return {
24
+ bearer: "token-a",
25
+ deviceId: "machine_a",
26
+ capabilityVector: capability,
27
+ configurationFingerprint: "config-a",
28
+ scopeFingerprint: "personal:prs_1",
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ const readyInventory = {
34
+ scopes: [{ scopeUid: "prs_1", scopeHandle: "handle_1", scopeRevision: "rev_1", state: "v2-ready", cursor: "cursor_1" }],
35
+ };
36
+
37
+ describe("RealtimeRolloutClient", () => {
38
+ it("never reuses a cached positive across bearer, configuration, or scope changes", async () => {
39
+ let now = 1_000;
40
+ const requests: Array<{ headers?: Record<string, string> }> = [];
41
+ const fetchImpl = async (_url: string, init?: { headers?: Record<string, string> }) => {
42
+ requests.push({ headers: init?.headers });
43
+ return jsonResponse(readyInventory);
44
+ };
45
+ const client = new RealtimeRolloutClient({ apiUrl: "https://api.example/", fetchImpl, now: () => now });
46
+
47
+ await client.inventory(request());
48
+ await client.inventory(request());
49
+ await client.inventory(request({ bearer: "token-b" }));
50
+ await client.inventory(request({ configurationFingerprint: "config-b" }));
51
+ await client.inventory(request({ scopeFingerprint: "company:cmp_1" }));
52
+ now += MAX_REALTIME_ROLLOUT_CACHE_MS;
53
+ await client.inventory(request());
54
+
55
+ expect(requests).toHaveLength(5);
56
+ expect(requests[0]?.headers).toMatchObject({
57
+ Authorization: "Bearer token-a",
58
+ "X-HQ-Sync-Contract": "2",
59
+ "X-HQ-Device-ID": "machine_a",
60
+ "X-HQ-Cloud-Version": "6.14.49",
61
+ "X-HQ-Desktop-Version": "none",
62
+ "X-HQ-Sync-Journal": "3",
63
+ "X-HQ-Sync-Durable-Apply": "1",
64
+ });
65
+ });
66
+
67
+ it("fails closed for malformed or non-explicit legacy inventory", () => {
68
+ expect(() => parseRealtimeInventory({ scopes: [{ scopeUid: "prs_1", scopeHandle: "h", scopeRevision: "r", state: "legacy", v1Allowed: false }] })).toThrow("legacy must explicitly allow v1");
69
+ expect(() => parseRealtimeInventory({ scopes: [{ scopeUid: "prs_1", scopeHandle: "h", scopeRevision: "r", state: "v2-ready", cursor: "c", surprise: true }] })).toThrow("unknown field");
70
+ expect(() => parseRealtimeInventory({ scopes: [{ scopeUid: "prs_1", scopeHandle: "h", scopeRevision: "r", state: "unknown" }] })).toThrow("unsupported scope state");
71
+ });
72
+
73
+ it("rejects cache durations greater than the protocol maximum", () => {
74
+ expect(() => new RealtimeRolloutClient({ apiUrl: "https://api.example", maxCacheAgeMs: MAX_REALTIME_ROLLOUT_CACHE_MS + 1 })).toThrow("maxCacheAgeMs");
75
+ });
76
+
77
+ it("honors transitional retryAfterMs instead of holding a stale negative state", async () => {
78
+ const fetchImpl = vi.fn(async () => jsonResponse({ scopes: [
79
+ { scopeUid: "prs_1", scopeHandle: "handle_1", scopeRevision: "rev_1", state: "restoring", retryAfterMs: 0 },
80
+ ] }));
81
+ const client = new RealtimeRolloutClient({ apiUrl: "https://api.example", fetchImpl });
82
+ await client.inventory(request());
83
+ await client.inventory(request());
84
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
85
+ });
86
+ });