@indigoai-us/hq-cloud 6.14.50 → 6.15.1

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 (340) hide show
  1. package/dist/bin/sync-mutation.d.ts +14 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +64 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/reindex-knowledge.d.ts +7 -0
  34. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  35. package/dist/cli/reindex-knowledge.js +389 -0
  36. package/dist/cli/reindex-knowledge.js.map +1 -0
  37. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  38. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  39. package/dist/cli/reindex-knowledge.test.js +260 -0
  40. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  41. package/dist/cli/reindex.d.ts.map +1 -1
  42. package/dist/cli/reindex.js +8 -0
  43. package/dist/cli/reindex.js.map +1 -1
  44. package/dist/cli/rescue-settings-reconcile.test.js +47 -0
  45. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -1
  46. package/dist/cli/sync.d.ts +27 -0
  47. package/dist/cli/sync.d.ts.map +1 -1
  48. package/dist/cli/sync.js +52 -0
  49. package/dist/cli/sync.js.map +1 -1
  50. package/dist/cli/sync.test.js +31 -1
  51. package/dist/cli/sync.test.js.map +1 -1
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +1 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/skill-telemetry.d.ts +6 -0
  57. package/dist/skill-telemetry.d.ts.map +1 -1
  58. package/dist/skill-telemetry.js +14 -2
  59. package/dist/skill-telemetry.js.map +1 -1
  60. package/dist/skill-telemetry.test.js +79 -0
  61. package/dist/skill-telemetry.test.js.map +1 -1
  62. package/dist/sync/candidate-uploader.d.ts +88 -0
  63. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  64. package/dist/sync/candidate-uploader.js +212 -0
  65. package/dist/sync/candidate-uploader.js.map +1 -0
  66. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  67. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  68. package/dist/sync/candidate-uploader.test.js +132 -0
  69. package/dist/sync/candidate-uploader.test.js.map +1 -0
  70. package/dist/sync/delta-client.d.ts +73 -0
  71. package/dist/sync/delta-client.d.ts.map +1 -0
  72. package/dist/sync/delta-client.js +201 -0
  73. package/dist/sync/delta-client.js.map +1 -0
  74. package/dist/sync/delta-client.test.d.ts +2 -0
  75. package/dist/sync/delta-client.test.d.ts.map +1 -0
  76. package/dist/sync/delta-client.test.js +97 -0
  77. package/dist/sync/delta-client.test.js.map +1 -0
  78. package/dist/sync/durable-apply.d.ts +76 -0
  79. package/dist/sync/durable-apply.d.ts.map +1 -0
  80. package/dist/sync/durable-apply.js +530 -0
  81. package/dist/sync/durable-apply.js.map +1 -0
  82. package/dist/sync/durable-apply.test.d.ts +2 -0
  83. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  84. package/dist/sync/durable-apply.test.js +180 -0
  85. package/dist/sync/durable-apply.test.js.map +1 -0
  86. package/dist/sync/event-sync.d.ts +33 -1
  87. package/dist/sync/event-sync.d.ts.map +1 -1
  88. package/dist/sync/event-sync.js +149 -1
  89. package/dist/sync/event-sync.js.map +1 -1
  90. package/dist/sync/event-sync.test.js +142 -1
  91. package/dist/sync/event-sync.test.js.map +1 -1
  92. package/dist/sync/index.d.ts +2 -0
  93. package/dist/sync/index.d.ts.map +1 -1
  94. package/dist/sync/index.js +1 -0
  95. package/dist/sync/index.js.map +1 -1
  96. package/dist/sync/multipart-uploader.d.ts +99 -0
  97. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  98. package/dist/sync/multipart-uploader.js +447 -0
  99. package/dist/sync/multipart-uploader.js.map +1 -0
  100. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  101. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  102. package/dist/sync/multipart-uploader.test.js +119 -0
  103. package/dist/sync/multipart-uploader.test.js.map +1 -0
  104. package/dist/sync/mutation-client.d.ts +82 -0
  105. package/dist/sync/mutation-client.d.ts.map +1 -0
  106. package/dist/sync/mutation-client.js +221 -0
  107. package/dist/sync/mutation-client.js.map +1 -0
  108. package/dist/sync/mutation-client.test.d.ts +2 -0
  109. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  110. package/dist/sync/mutation-client.test.js +51 -0
  111. package/dist/sync/mutation-client.test.js.map +1 -0
  112. package/dist/sync/push-receiver.d.ts +45 -0
  113. package/dist/sync/push-receiver.d.ts.map +1 -1
  114. package/dist/sync/push-receiver.js +101 -0
  115. package/dist/sync/push-receiver.js.map +1 -1
  116. package/dist/sync/push-receiver.test.js +54 -2
  117. package/dist/sync/push-receiver.test.js.map +1 -1
  118. package/dist/sync/scope-inventory-client.d.ts +69 -0
  119. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  120. package/dist/sync/scope-inventory-client.js +210 -0
  121. package/dist/sync/scope-inventory-client.js.map +1 -0
  122. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  123. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  124. package/dist/sync/scope-inventory-client.test.js +94 -0
  125. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  126. package/dist/sync/snapshot-client.d.ts +98 -0
  127. package/dist/sync/snapshot-client.d.ts.map +1 -0
  128. package/dist/sync/snapshot-client.js +402 -0
  129. package/dist/sync/snapshot-client.js.map +1 -0
  130. package/dist/sync/snapshot-client.test.d.ts +2 -0
  131. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  132. package/dist/sync/snapshot-client.test.js +169 -0
  133. package/dist/sync/snapshot-client.test.js.map +1 -0
  134. package/dist/sync/uploader-finalization.d.ts +97 -0
  135. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  136. package/dist/sync/uploader-finalization.js +273 -0
  137. package/dist/sync/uploader-finalization.js.map +1 -0
  138. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  139. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  140. package/dist/sync/uploader-finalization.test.js +92 -0
  141. package/dist/sync/uploader-finalization.test.js.map +1 -0
  142. package/dist/telemetry.d.ts +11 -1
  143. package/dist/telemetry.d.ts.map +1 -1
  144. package/dist/telemetry.js +21 -2
  145. package/dist/telemetry.js.map +1 -1
  146. package/dist/telemetry.test.js +80 -0
  147. package/dist/telemetry.test.js.map +1 -1
  148. package/package.json +6 -1
  149. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  150. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  151. package/.github/workflows/ci.yml +0 -84
  152. package/.github/workflows/publish.yml +0 -56
  153. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  154. package/eslint.config.js +0 -67
  155. package/pnpm-workspace.yaml +0 -2
  156. package/scripts/presign-transport-e2e.mjs +0 -250
  157. package/scripts/vault-rebaseline.sh +0 -323
  158. package/scripts/vault-rescue.sh +0 -332
  159. package/src/active-company.test.ts +0 -188
  160. package/src/active-company.ts +0 -168
  161. package/src/agent-codex-instructions.test.ts +0 -332
  162. package/src/agent-codex-instructions.ts +0 -309
  163. package/src/auth.ts +0 -146
  164. package/src/backup-prune.test.ts +0 -98
  165. package/src/backup-prune.ts +0 -182
  166. package/src/bin/backup-prune-runner.ts +0 -33
  167. package/src/bin/rescue-runner.ts +0 -25
  168. package/src/bin/sync-runner-company.ts +0 -695
  169. package/src/bin/sync-runner-events.test.ts +0 -143
  170. package/src/bin/sync-runner-events.ts +0 -55
  171. package/src/bin/sync-runner-planning.test.ts +0 -311
  172. package/src/bin/sync-runner-planning.ts +0 -258
  173. package/src/bin/sync-runner-rollup.test.ts +0 -37
  174. package/src/bin/sync-runner-rollup.ts +0 -97
  175. package/src/bin/sync-runner-telemetry.ts +0 -15
  176. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  177. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  178. package/src/bin/sync-runner-watch-routes.ts +0 -184
  179. package/src/bin/sync-runner.test.ts +0 -8767
  180. package/src/bin/sync-runner.ts +0 -2190
  181. package/src/cli/accept.ts +0 -124
  182. package/src/cli/conflict.ts +0 -119
  183. package/src/cli/doctor.test.ts +0 -581
  184. package/src/cli/doctor.ts +0 -642
  185. package/src/cli/index.ts +0 -49
  186. package/src/cli/invite.test.ts +0 -250
  187. package/src/cli/invite.ts +0 -214
  188. package/src/cli/promote.ts +0 -157
  189. package/src/cli/reindex.test.ts +0 -957
  190. package/src/cli/reindex.ts +0 -970
  191. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  192. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  193. package/src/cli/rescue-core.ts +0 -3011
  194. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  195. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  196. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  197. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  198. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  199. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  200. package/src/cli/rescue-settings-reconcile.test.ts +0 -583
  201. package/src/cli/rescue-snapshot.test.ts +0 -57
  202. package/src/cli/rescue-snapshot.ts +0 -51
  203. package/src/cli/rescue.reindex.test.ts +0 -63
  204. package/src/cli/rescue.test.ts +0 -131
  205. package/src/cli/rescue.ts +0 -182
  206. package/src/cli/share.test.ts +0 -7843
  207. package/src/cli/share.ts +0 -3663
  208. package/src/cli/sync-scope.test.ts +0 -652
  209. package/src/cli/sync.test.ts +0 -5207
  210. package/src/cli/sync.ts +0 -3470
  211. package/src/cli/tombstones.ts +0 -106
  212. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  213. package/src/client-info.test.ts +0 -214
  214. package/src/client-info.ts +0 -121
  215. package/src/cognito-auth.test.ts +0 -712
  216. package/src/cognito-auth.ts +0 -1422
  217. package/src/company-resolver.test.ts +0 -618
  218. package/src/company-resolver.ts +0 -521
  219. package/src/context.test.ts +0 -583
  220. package/src/context.ts +0 -378
  221. package/src/daemon-worker.ts +0 -26
  222. package/src/daemon.ts +0 -99
  223. package/src/entity-resolver.test.ts +0 -315
  224. package/src/entity-resolver.ts +0 -180
  225. package/src/ignore.test.ts +0 -466
  226. package/src/ignore.ts +0 -469
  227. package/src/index.ts +0 -439
  228. package/src/journal.test.ts +0 -968
  229. package/src/journal.ts +0 -765
  230. package/src/lib/cloud-authoritative.test.ts +0 -45
  231. package/src/lib/cloud-authoritative.ts +0 -59
  232. package/src/lib/conflict-file.ts +0 -86
  233. package/src/lib/conflict-index.ts +0 -289
  234. package/src/lib/conflict.test.ts +0 -348
  235. package/src/lib/describe-error.test.ts +0 -100
  236. package/src/lib/describe-error.ts +0 -58
  237. package/src/lib/exit-codes.ts +0 -24
  238. package/src/lib/machine-id.test.ts +0 -231
  239. package/src/lib/machine-id.ts +0 -175
  240. package/src/lib/net-errors.test.ts +0 -65
  241. package/src/lib/net-errors.ts +0 -86
  242. package/src/lib/readlink-safe.test.ts +0 -43
  243. package/src/lib/readlink-safe.ts +0 -29
  244. package/src/local-path-codec.test.ts +0 -138
  245. package/src/local-path-codec.ts +0 -161
  246. package/src/machine-auth.test.ts +0 -1323
  247. package/src/manifest-reconcile.test.ts +0 -1123
  248. package/src/manifest-reconcile.ts +0 -518
  249. package/src/object-io.test.ts +0 -1221
  250. package/src/object-io.ts +0 -1306
  251. package/src/operation-lock.test.ts +0 -484
  252. package/src/operation-lock.ts +0 -680
  253. package/src/outcome-telemetry.test.ts +0 -498
  254. package/src/outcome-telemetry.ts +0 -639
  255. package/src/personal-vault-exclusions.test.ts +0 -308
  256. package/src/personal-vault-exclusions.ts +0 -354
  257. package/src/personal-vault.test.ts +0 -756
  258. package/src/personal-vault.ts +0 -496
  259. package/src/prefix-coalesce.test.ts +0 -240
  260. package/src/prefix-coalesce.ts +0 -273
  261. package/src/public-surface.test.ts +0 -117
  262. package/src/qmd-reindex.test.ts +0 -877
  263. package/src/qmd-reindex.ts +0 -842
  264. package/src/read-only-state-dir.test.ts +0 -188
  265. package/src/remote-pull.test.ts +0 -1130
  266. package/src/remote-pull.ts +0 -618
  267. package/src/s3.symlink-materialize.test.ts +0 -492
  268. package/src/s3.test.ts +0 -1789
  269. package/src/s3.ts +0 -1532
  270. package/src/schemas/signal-types.test.ts +0 -82
  271. package/src/schemas/signal-types.ts +0 -38
  272. package/src/schemas/source-channels.test.ts +0 -82
  273. package/src/schemas/source-channels.ts +0 -53
  274. package/src/scope-shrink.test.ts +0 -633
  275. package/src/scope-shrink.ts +0 -481
  276. package/src/signals/get.test.ts +0 -310
  277. package/src/signals/get.ts +0 -75
  278. package/src/signals/internals.ts +0 -195
  279. package/src/signals/list.test.ts +0 -420
  280. package/src/signals/list.ts +0 -79
  281. package/src/signals/parse.ts +0 -8
  282. package/src/signals/types.ts +0 -91
  283. package/src/skill-telemetry.test.ts +0 -1825
  284. package/src/skill-telemetry.ts +0 -1439
  285. package/src/sources/get.test.ts +0 -293
  286. package/src/sources/get.ts +0 -66
  287. package/src/sources/internals.ts +0 -198
  288. package/src/sources/list.test.ts +0 -402
  289. package/src/sources/list.ts +0 -84
  290. package/src/sources/parse.ts +0 -43
  291. package/src/sources/types.ts +0 -84
  292. package/src/sync/event-sync.test.ts +0 -594
  293. package/src/sync/event-sync.ts +0 -545
  294. package/src/sync/feature-flags.test.ts +0 -378
  295. package/src/sync/feature-flags.ts +0 -62
  296. package/src/sync/index.ts +0 -76
  297. package/src/sync/lease-client.test.ts +0 -128
  298. package/src/sync/lease-client.ts +0 -207
  299. package/src/sync/logger.test.ts +0 -242
  300. package/src/sync/logger.ts +0 -79
  301. package/src/sync/metrics.test.ts +0 -462
  302. package/src/sync/metrics.ts +0 -213
  303. package/src/sync/pull-scope.ts +0 -265
  304. package/src/sync/push-event.test.ts +0 -266
  305. package/src/sync/push-event.ts +0 -224
  306. package/src/sync/push-receiver.test.ts +0 -566
  307. package/src/sync/push-receiver.ts +0 -1048
  308. package/src/sync/push-transport.ts +0 -231
  309. package/src/sync/realtime-rollout.test.ts +0 -86
  310. package/src/sync/realtime-rollout.ts +0 -262
  311. package/src/sync/state-store.test.ts +0 -194
  312. package/src/sync/state-store.ts +0 -727
  313. package/src/sync-core.ts +0 -58
  314. package/src/sync-progress.test.ts +0 -94
  315. package/src/sync-progress.ts +0 -140
  316. package/src/telemetry-events.test.ts +0 -88
  317. package/src/telemetry-events.ts +0 -205
  318. package/src/telemetry.test.ts +0 -1280
  319. package/src/telemetry.ts +0 -1109
  320. package/src/types.ts +0 -314
  321. package/src/vault-client.test.ts +0 -1380
  322. package/src/vault-client.ts +0 -1694
  323. package/src/version.ts +0 -24
  324. package/src/watch-roots.test.ts +0 -278
  325. package/src/watch-roots.ts +0 -162
  326. package/src/watcher-event-gate.test.ts +0 -212
  327. package/src/watcher.test.ts +0 -1079
  328. package/src/watcher.ts +0 -1741
  329. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  330. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  331. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  332. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  333. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  334. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  335. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  336. package/test/invite-flow.integration.test.ts +0 -244
  337. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  338. package/test/share-sync.integration.test.ts +0 -213
  339. package/tsconfig.json +0 -19
  340. package/vitest.config.ts +0 -22
@@ -1,1048 +0,0 @@
1
- /**
2
- * PushReceiver — inbound subscription seam for the hq-cloud watcher daemon
3
- * (project event-driven-sync-menubar US-009).
4
- *
5
- * Mirrors {@link PushTransport} (`./push-transport.ts`) but for the opposite
6
- * direction of travel: where the transport SHIPS local file changes out to the
7
- * cloud, the receiver SUBSCRIBES to the tenant fanout and triggers an
8
- * immediate, TARGETED local pull the moment a peer device of the same tenant
9
- * publishes a change. Together they form the event-driven primary path; the
10
- * existing `--poll-remote-ms` poll in `runRunnerWithLoop` is the safety net
11
- * behind both.
12
- *
13
- * Transport: SNS → per-client SQS (US-000 decision)
14
- * ─────────────────────────────────────────────────
15
- * Per the US-000 transport investigation (companies/indigo/projects/
16
- * event-driven-sync-menubar/references.md): reuse PR #112's SNS publish +
17
- * DynamoDB catch-up log, and build the client RECEIVE side as a per-client
18
- * SQS queue subscribed to `sync-push-{tenantId}`. The receiver long-polls its
19
- * own queue, decodes each message body as a {@link PushEvent}, dedupes by
20
- * `sequenceNumber` per `relativePath`, and bridges into the existing sync
21
- * engine via an injected {@link SyncEngineFn} (→ targeted `runRunner` pull).
22
- *
23
- * The live queue is NOT provisioned yet (the server SQS-provisioning Lambda is
24
- * an unbuilt follow-up — see references.md "Open items handed to the plan").
25
- * So this module ships:
26
- * - {@link SqsClientLike} — the narrow SQS surface the receiver depends on
27
- * (`receiveMessage` / `deleteMessage`). Production callers pass an
28
- * `@aws-sdk/client-sqs` `SQSClient` adapted to this shape; unit tests inject
29
- * a fake. NO real AWS is required to exercise the receiver.
30
- * - {@link SqsPushReceiver} — the real receiver. Long-polls the queue,
31
- * dispatches each event through the shared dedupe path, deletes the message
32
- * on successful handoff, and reconnects on transient `receiveMessage`
33
- * failures with exponential backoff. SQS's own 14-day retention buffers
34
- * messages while the device is offline → reconnect-replay is "free": on
35
- * reconnect the poll loop simply resumes and the retained messages are
36
- * redelivered, then dedupe skips anything already processed.
37
- * - {@link NoopPushReceiver} — the dormant default. Flips `connected` on
38
- * start, opens no subscription. Wired when the daemon runs without a real
39
- * queue (or when the feature flag is OFF).
40
- * - {@link createPushReceiver} — factory the daemon uses; returns the noop
41
- * unless an SQS client + queue URL are supplied.
42
- *
43
- * Lifecycle (mirrors PushTransport)
44
- * ─────────────────────────────────
45
- * - `start()` opens the subscription (begins the long-poll loop). Awaited
46
- * AFTER the watcher starts so a synthetic event can't race a half-built
47
- * daemon. When the feature flag is OFF, `start()` is a no-op and `connected`
48
- * stays false — NO queue is polled (dormant; AC#4).
49
- * - On each received message: validate with {@link decodePushEvent} (defense
50
- * in depth at the wire boundary), dedupe by `relativePath` against the
51
- * highest `sequenceNumber` seen for that path, then call the injected
52
- * {@link SyncEngineFn}. The sync engine is an injected seam — this story
53
- * does NOT re-implement download logic; it bridges to `runRunner` pull.
54
- * - `dispose()` aborts in-flight via AbortController, stops the poll loop,
55
- * awaits the in-flight sync up to a drain deadline, then disconnects.
56
- *
57
- * Dedupe (AC#3)
58
- * ─────────────
59
- * A per-`relativePath` map of the highest `sequenceNumber` already passed to
60
- * `syncFn`. An incoming event with `sequenceNumber <= seen` is skipped. SQS
61
- * at-least-once delivery + reconnect-replay means the SAME event can arrive
62
- * twice; dedupe makes that idempotent.
63
- *
64
- * Disconnect / reconnect with catch-up replay (AC#3/#4)
65
- * ─────────────────────────────────────────────────────
66
- * `receiveMessage` failures (network blip, throttling) are caught; the loop
67
- * backs off (exponential + jitter, capped) and resumes. Because the per-client
68
- * SQS queue retains undelivered messages for 14 days, anything published while
69
- * the device was offline/disconnected is redelivered when the poll resumes —
70
- * catch-up replay with no server round-trip. Redelivered-but-already-processed
71
- * events are absorbed by the dedupe path. The in-memory fake's
72
- * `simulateDisconnect()` / `simulateReconnect()` model exactly this buffering.
73
- *
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.
79
- *
80
- * Cross-tenant isolation (US-010)
81
- * ───────────────────────────────
82
- * Each receiver instance binds exactly ONE `tenantId` and polls exactly ONE
83
- * queue URL (its own tenant's per-client queue). Isolation is enforced at the
84
- * subscription boundary — the receiver never reads another tenant's queue, and
85
- * never filters cross-tenant data post-hoc.
86
- *
87
- * @see ./push-transport.ts (the outbound seam this mirrors)
88
- * @see ./feature-flags.ts (the per-tenant flag provider — US-008)
89
- * @see ../bin/sync-runner.ts (the wiring site — runRunnerWithLoop)
90
- * @see companies/indigo/projects/event-driven-sync-menubar/references.md (US-000)
91
- *
92
- * Adapted from indigoai-us/hq-pro PR #112 (src/sync/push-receiver.ts) into
93
- * @indigoai-us/hq-cloud (Path B). The hq-pro source shipped only Noop +
94
- * InMemory receivers (the production SQS path was deferred there); this module
95
- * builds the real SQS receiver behind the same lifecycle/dedupe/flag seam.
96
- */
97
-
98
- import { decodePushEvent, type PushEvent } from "./push-event.js";
99
- import {
100
- publishSyncLatencyMetric,
101
- type SyncLatencyMetric,
102
- } from "./metrics.js";
103
-
104
- // ─── Constants ─────────────────────────────────────────────────────────────
105
-
106
- /**
107
- * How long `dispose()` awaits an in-flight `syncFn` after aborting its signal,
108
- * before abandoning it (the poll/cadence safety net re-pulls on the next tick).
109
- */
110
- export const DEFAULT_RECEIVER_DISPOSE_DRAIN_MS = 5_000;
111
-
112
- /** Default SQS long-poll wait (seconds). 20 is the SQS max — true long-poll. */
113
- export const DEFAULT_WAIT_TIME_SECONDS = 20;
114
-
115
- /** Default max messages pulled per `receiveMessage` call (SQS max is 10). */
116
- export const DEFAULT_MAX_MESSAGES = 10;
117
-
118
- /** Reconnect backoff defaults. */
119
- export const DEFAULT_RECONNECT_INITIAL_MS = 250;
120
- export const DEFAULT_RECONNECT_MAX_MS = 30_000;
121
-
122
- /** Maximum distinct paths retained in receiver dedupe state. */
123
- export const DEFAULT_RECEIVER_DEDUPE_MAX_PATHS = 50_000;
124
-
125
- class BoundedSequenceDedupe {
126
- private readonly maxPaths: number;
127
- private readonly seen = new Map<string, number>();
128
-
129
- constructor(maxPaths: number | undefined) {
130
- this.maxPaths = Math.max(
131
- 1,
132
- Math.floor(maxPaths ?? DEFAULT_RECEIVER_DEDUPE_MAX_PATHS),
133
- );
134
- }
135
-
136
- get(relativePath: string): number | undefined {
137
- const sequenceNumber = this.seen.get(relativePath);
138
- if (sequenceNumber === undefined) return undefined;
139
- this.seen.delete(relativePath);
140
- this.seen.set(relativePath, sequenceNumber);
141
- return sequenceNumber;
142
- }
143
-
144
- set(relativePath: string, sequenceNumber: number): void {
145
- if (this.seen.has(relativePath)) this.seen.delete(relativePath);
146
- this.seen.set(relativePath, sequenceNumber);
147
- while (this.seen.size > this.maxPaths) {
148
- const oldest = this.seen.keys().next().value;
149
- if (oldest === undefined) return;
150
- this.seen.delete(oldest);
151
- }
152
- }
153
- }
154
-
155
- // ─── Narrow SQS surface (the injectable transport seam) ──────────────────────
156
-
157
- /**
158
- * One SQS message as the receiver consumes it. A structural subset of the AWS
159
- * SDK `Message` so a real `SQSClient` response satisfies it without adaptation
160
- * and tests can build literals.
161
- */
162
- export interface SqsMessageLike {
163
- /** The message payload — a JSON-encoded {@link PushEvent}. */
164
- readonly Body?: string;
165
- /** Opaque handle used to delete the message after successful handoff. */
166
- readonly ReceiptHandle?: string;
167
- /** Optional SQS message id (logged for diagnostics). */
168
- readonly MessageId?: string;
169
- }
170
-
171
- /**
172
- * The narrow SQS client surface the receiver depends on. The AWS SDK
173
- * `SQSClient` does NOT match this shape directly (it exposes a single
174
- * `send(command)`); production callers adapt it with a thin wrapper (see
175
- * {@link sqsClientFromAwsSdk} in the wiring site / tests). Keeping the seam
176
- * this narrow means unit tests inject a hand-written fake with zero AWS deps.
177
- */
178
- export interface SqsClientLike {
179
- /**
180
- * Long-poll the queue. Resolves with zero or more messages. MUST honor the
181
- * abort signal (resolve/reject promptly on abort) so `dispose()` doesn't
182
- * block on an in-flight 20s long-poll.
183
- */
184
- receiveMessage(args: {
185
- queueUrl: string;
186
- maxMessages: number;
187
- waitTimeSeconds: number;
188
- signal: AbortSignal;
189
- }): Promise<{ messages: SqsMessageLike[] }>;
190
-
191
- /** Delete a successfully-handled message so it isn't redelivered. */
192
- deleteMessage(args: {
193
- queueUrl: string;
194
- receiptHandle: string;
195
- }): Promise<void>;
196
- }
197
-
198
- // ─── Public types ──────────────────────────────────────────────────────────
199
-
200
- /**
201
- * Context handed to {@link SyncEngineFn} on every received event.
202
- *
203
- * - `event` — the validated, deduped PushEvent. `relativePath` is what the
204
- * sync engine pulls; `sequenceNumber` is for observability.
205
- * - `signal` — aborts when `dispose()` runs past its drain deadline. A
206
- * well-behaved sync fn checks `signal.aborted` between stages and returns
207
- * early.
208
- */
209
- export interface PushReceiverContext {
210
- readonly event: PushEvent;
211
- readonly signal: AbortSignal;
212
- }
213
-
214
- /**
215
- * The injected sync function. The receiver does NOT perform the actual fetch —
216
- * it hands off the relativePath to whatever the deployment supplies. In
217
- * production this bridges to a targeted `runRunner` pull for the affected
218
- * company/path; in tests it's a fake recording invocations.
219
- *
220
- * Errors from `syncFn` are CAUGHT by the receiver — they log and the loop
221
- * continues. A failed sync is left unacknowledged in SQS so the queue can
222
- * redeliver it after the visibility timeout.
223
- */
224
- export type SyncEngineFn = (ctx: PushReceiverContext) => Promise<void>;
225
-
226
- /**
227
- * Best-effort CloudWatch metric publish seam (US-011). Invoked on the
228
- * receive-SUCCESS path with the measured save-on-A → visible-on-B latency.
229
- * Defaults to {@link publishSyncLatencyMetric} (the module singleton client);
230
- * tests inject a spy so no real AWS is touched. The receiver awaits it inside a
231
- * try/catch — a metric failure can never crash the loop (it's also best-effort
232
- * inside the default impl).
233
- */
234
- export type PublishMetricFn = (metric: SyncLatencyMetric) => Promise<void>;
235
-
236
- /** Minimal structured logger. Defaults to a no-op (quiet daemon). */
237
- export interface ReceiverLogger {
238
- info(obj: Record<string, unknown>, msg?: string): void;
239
- warn(obj: Record<string, unknown>, msg?: string): void;
240
- error(obj: Record<string, unknown>, msg?: string): void;
241
- debug(obj: Record<string, unknown>, msg?: string): void;
242
- }
243
-
244
- const NOOP_LOGGER: ReceiverLogger = {
245
- info: () => undefined,
246
- warn: () => undefined,
247
- error: () => undefined,
248
- debug: () => undefined,
249
- };
250
-
251
- /**
252
- * Lifecycle handle. Mirrors {@link PushTransport} so daemon wiring is
253
- * mechanically identical on both sides.
254
- */
255
- export interface PushReceiver {
256
- /** Open the subscription / poll loop. No-op when the feature flag is OFF. */
257
- start(): Promise<void>;
258
- /** Idempotent teardown — stop polling, abort + drain in-flight, disconnect. */
259
- dispose(): Promise<void>;
260
- /** Advisory: is the subscription currently believed to be open? */
261
- readonly connected: boolean;
262
- }
263
-
264
- // ─── Noop default ─────────────────────────────────────────────────────────
265
-
266
- /**
267
- * Default `PushReceiver` used when no real queue is wired (or the flag is OFF
268
- * at the factory). `start()` flips `connected` true; `dispose()` flips it
269
- * false. No subscription work, no events. Mirrors {@link NoopPushTransport}.
270
- */
271
- export class NoopPushReceiver implements PushReceiver {
272
- private _connected = false;
273
-
274
- get connected(): boolean {
275
- return this._connected;
276
- }
277
-
278
- async start(): Promise<void> {
279
- this._connected = true;
280
- }
281
-
282
- async dispose(): Promise<void> {
283
- this._connected = false;
284
- }
285
- }
286
-
287
- // ─── SQS receiver ─────────────────────────────────────────────────────────
288
-
289
- /**
290
- * Configuration for {@link SqsPushReceiver}.
291
- */
292
- export interface SqsPushReceiverOptions {
293
- /**
294
- * Tenant id this receiver subscribes to. Each instance binds exactly one
295
- * tenant — cross-tenant isolation is enforced by the subscription boundary
296
- * (this queue belongs to this tenant), not post-hoc filtering. (US-010)
297
- */
298
- tenantId: string;
299
- /**
300
- * The caller's own per-tenant SQS queue URL (minted server-side by the
301
- * provisioning Lambda and subscribed to `sync-push-{tenantId}`). The
302
- * receiver polls ONLY this URL.
303
- */
304
- queueUrl: string;
305
- /** The injected SQS client. Tests pass a fake; production an SDK adapter. */
306
- sqs: SqsClientLike;
307
- /**
308
- * The sync engine that performs the actual targeted pull. The receiver only
309
- * invokes this; errors are logged + isolated from the loop.
310
- */
311
- syncFn: SyncEngineFn;
312
- /** Structured logger. Default: a no-op (quiet). */
313
- logger?: ReceiverLogger;
314
- /**
315
- * Deprecated compatibility field. It is intentionally ignored: it can no
316
- * longer enable or disable a receiver after server authorization.
317
- */
318
- enabled?: boolean;
319
- /** SQS long-poll wait seconds. Default {@link DEFAULT_WAIT_TIME_SECONDS}. */
320
- waitTimeSeconds?: number;
321
- /** Max messages per receive. Default {@link DEFAULT_MAX_MESSAGES}. */
322
- maxMessages?: number;
323
- /** Max time `dispose()` waits for an in-flight syncFn after abort. */
324
- disposeDrainMs?: number;
325
- /** Maximum distinct paths retained in dedupe state. */
326
- dedupeMaxPaths?: number;
327
- /** Reconnect backoff config. */
328
- reconnect?: {
329
- initialMs?: number;
330
- maxMs?: number;
331
- jitter?: boolean;
332
- };
333
- /**
334
- * Sleep seam for backoff (tests inject a fast/abortable sleep). Default:
335
- * host `setTimeout` that resolves early on abort.
336
- */
337
- sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
338
- /**
339
- * Best-effort latency-metric publish (US-011). Called on the receive-success
340
- * path with the measured save→visible latency. Default:
341
- * {@link publishSyncLatencyMetric}; tests inject a spy. (AC#1/#3)
342
- */
343
- publishMetric?: PublishMetricFn;
344
- /**
345
- * Clock for latency measurement + metric timestamps. Default
346
- * `() => Date.now()`. Tests inject a fake clock to assert the latency value.
347
- */
348
- now?: () => number;
349
- }
350
-
351
- /**
352
- * Real client `PushReceiver` backed by a per-tenant SQS queue.
353
- *
354
- * Poll loop: long-poll `receiveMessage` → for each message, decode + dedupe +
355
- * dispatch through `syncFn`, then `deleteMessage` on successful handoff. A
356
- * `receiveMessage` rejection is treated as a transient disconnect: log, back
357
- * off, resume. SQS retention covers offline catch-up; dedupe covers redelivery.
358
- */
359
- export class SqsPushReceiver implements PushReceiver {
360
- private readonly tenantId: string;
361
- private readonly queueUrl: string;
362
- private readonly sqs: SqsClientLike;
363
- private readonly syncFn: SyncEngineFn;
364
- private readonly logger: ReceiverLogger;
365
- private readonly waitTimeSeconds: number;
366
- private readonly maxMessages: number;
367
- private readonly disposeDrainMs: number;
368
- private readonly reconnectInitialMs: number;
369
- private readonly reconnectMaxMs: number;
370
- private readonly reconnectJitter: boolean;
371
- private readonly sleep: (ms: number, signal: AbortSignal) => Promise<void>;
372
- private readonly publishMetric: PublishMetricFn;
373
- private readonly now: () => number;
374
-
375
- private _connected = false;
376
- private disposed = false;
377
- private disposing = false;
378
- private disposePromise: Promise<void> | null = null;
379
-
380
- /** Abort signal shared by the poll loop + in-flight sync; fired on dispose. */
381
- private loopAbort: AbortController | null = null;
382
- /** The running poll loop promise; awaited (best-effort) during dispose. */
383
- private loopPromise: Promise<void> | null = null;
384
- /** AbortController for the in-flight syncFn; refreshed each dispatch. */
385
- private inFlightAbort: AbortController | null = null;
386
- private inFlightSync: Promise<void> | null = null;
387
-
388
- /** Per-path highest sequence number already PROCESSED by syncFn. */
389
- private readonly seenSequencePerPath: BoundedSequenceDedupe;
390
-
391
- private _processedCount = 0;
392
- private _dedupedCount = 0;
393
- private _decodeFailureCount = 0;
394
- private _receiveErrorCount = 0;
395
-
396
- constructor(opts: SqsPushReceiverOptions) {
397
- if (!opts.tenantId || opts.tenantId.trim() === "") {
398
- throw new Error("SqsPushReceiver: tenantId is required");
399
- }
400
- if (!opts.queueUrl || opts.queueUrl.trim() === "") {
401
- throw new Error("SqsPushReceiver: queueUrl is required");
402
- }
403
- this.tenantId = opts.tenantId;
404
- this.queueUrl = opts.queueUrl;
405
- this.sqs = opts.sqs;
406
- this.syncFn = opts.syncFn;
407
- this.logger = opts.logger ?? NOOP_LOGGER;
408
- this.waitTimeSeconds = opts.waitTimeSeconds ?? DEFAULT_WAIT_TIME_SECONDS;
409
- this.maxMessages = opts.maxMessages ?? DEFAULT_MAX_MESSAGES;
410
- this.disposeDrainMs =
411
- opts.disposeDrainMs ?? DEFAULT_RECEIVER_DISPOSE_DRAIN_MS;
412
- this.seenSequencePerPath = new BoundedSequenceDedupe(opts.dedupeMaxPaths);
413
- this.reconnectInitialMs =
414
- opts.reconnect?.initialMs ?? DEFAULT_RECONNECT_INITIAL_MS;
415
- this.reconnectMaxMs = opts.reconnect?.maxMs ?? DEFAULT_RECONNECT_MAX_MS;
416
- this.reconnectJitter = opts.reconnect?.jitter ?? true;
417
- this.sleep = opts.sleep ?? defaultSleep;
418
- this.publishMetric =
419
- opts.publishMetric ?? ((m) => publishSyncLatencyMetric(m, { logger: undefined }));
420
- this.now = opts.now ?? (() => Date.now());
421
- }
422
-
423
- // ─── PushReceiver surface ────────────────────────────────────────────────
424
-
425
- get connected(): boolean {
426
- return this._connected;
427
- }
428
-
429
- async start(): Promise<void> {
430
- if (this.disposed) return;
431
- if (this.loopAbort !== null) {
432
- // Double-start is a no-op — matches PushTransport idempotency posture.
433
- this.logger.debug(
434
- { event: "receiver.start.noop", tenantId: this.tenantId },
435
- "push receiver already started",
436
- );
437
- return;
438
- }
439
-
440
- this.loopAbort = new AbortController();
441
- this._connected = true;
442
- this.logger.info(
443
- { event: "receiver.start", tenantId: this.tenantId, queueUrl: this.queueUrl },
444
- "push receiver subscribed (sqs long-poll)",
445
- );
446
- // Kick the loop off; do NOT await — start() returns once subscribed.
447
- this.loopPromise = this.pollLoop(this.loopAbort.signal);
448
- }
449
-
450
- async dispose(): Promise<void> {
451
- if (this.disposed) return;
452
- if (this.disposePromise !== null) return this.disposePromise;
453
- this.disposing = true;
454
-
455
- this.disposePromise = (async () => {
456
- // Stop the poll loop + abort any in-flight long-poll / syncFn.
457
- try {
458
- this.loopAbort?.abort();
459
- } catch {
460
- /* AbortController.abort never throws on Node; defensive. */
461
- }
462
- try {
463
- this.inFlightAbort?.abort();
464
- } catch {
465
- /* defensive */
466
- }
467
-
468
- if (this.inFlightSync !== null) {
469
- const drainDeadline = new Promise<void>((resolve) => {
470
- const t = setTimeout(resolve, this.disposeDrainMs);
471
- // Unref so a hung syncFn can't keep the loop alive past process exit.
472
- (t as { unref?: () => void }).unref?.();
473
- });
474
- await Promise.race([
475
- this.inFlightSync.catch(() => undefined),
476
- drainDeadline,
477
- ]);
478
- }
479
-
480
- // Best-effort: let the poll loop observe the abort and exit.
481
- if (this.loopPromise !== null) {
482
- await this.loopPromise.catch(() => undefined);
483
- }
484
-
485
- this._connected = false;
486
- this.disposed = true;
487
- this.logger.info(
488
- {
489
- event: "receiver.stop",
490
- tenantId: this.tenantId,
491
- processed: this._processedCount,
492
- deduped: this._dedupedCount,
493
- },
494
- "push receiver stopped",
495
- );
496
- })();
497
-
498
- return this.disposePromise;
499
- }
500
-
501
- // ─── Observability ─────────────────────────────────────────────────────────
502
-
503
- /** Events that passed dedupe AND completed `syncFn` successfully. */
504
- get processedCount(): number {
505
- return this._processedCount;
506
- }
507
- /** Events skipped by dedupe. */
508
- get dedupedCount(): number {
509
- return this._dedupedCount;
510
- }
511
- /** Events dropped at the wire-boundary decode step. */
512
- get decodeFailureCount(): number {
513
- return this._decodeFailureCount;
514
- }
515
- /** `receiveMessage` failures (transient disconnects) the loop recovered from. */
516
- get receiveErrorCount(): number {
517
- return this._receiveErrorCount;
518
- }
519
-
520
- // ─── Internals ───────────────────────────────────────────────────────────
521
-
522
- /**
523
- * The long-poll loop. Runs until the loop abort signal fires (dispose). A
524
- * `receiveMessage` rejection is a transient disconnect — log, back off,
525
- * resume. Because the SQS queue retains messages, resuming after a blip
526
- * replays the gap (catch-up). The loop never throws past this method; it
527
- * is fire-and-forgotten by `start()` and awaited best-effort by `dispose()`.
528
- */
529
- private async pollLoop(signal: AbortSignal): Promise<void> {
530
- let attempt = 0;
531
- while (!signal.aborted) {
532
- let received: { messages: SqsMessageLike[] };
533
- try {
534
- received = await this.sqs.receiveMessage({
535
- queueUrl: this.queueUrl,
536
- maxMessages: this.maxMessages,
537
- waitTimeSeconds: this.waitTimeSeconds,
538
- signal,
539
- });
540
- attempt = 0; // success → reset backoff
541
- if (received.messages.length > 0) {
542
- this._connected = true;
543
- }
544
- } catch (err) {
545
- if (signal.aborted) return; // dispose-driven abort — clean exit
546
- this._receiveErrorCount += 1;
547
- this._connected = false;
548
- const backoff = this.computeBackoff(attempt);
549
- attempt += 1;
550
- this.logger.warn(
551
- {
552
- event: "receiver.receive.failed",
553
- tenantId: this.tenantId,
554
- attempt,
555
- backoffMs: backoff,
556
- err: { message: (err as Error)?.message },
557
- },
558
- "push receiver receiveMessage failed; backing off (catch-up replay on resume)",
559
- );
560
- await this.sleep(backoff, signal);
561
- continue;
562
- }
563
-
564
- for (const msg of received.messages) {
565
- if (signal.aborted) return;
566
- await this.handleMessage(msg);
567
- }
568
- }
569
- }
570
-
571
- /**
572
- * Decode → dedupe → dispatch → delete a single SQS message. Decode failures
573
- * and syncFn throws are logged + absorbed (never crash the loop). The message
574
- * is deleted only after a successful handoff so an unprocessed message stays
575
- * on the queue for redelivery (the dedupe path makes redelivery idempotent).
576
- */
577
- private async handleMessage(msg: SqsMessageLike): Promise<void> {
578
- if (this.disposing || this.disposed) return;
579
-
580
- let validated: PushEvent;
581
- try {
582
- validated = decodePushEvent(msg.Body ?? "");
583
- } catch (err) {
584
- this._decodeFailureCount += 1;
585
- this.logger.warn(
586
- {
587
- event: "receiver.decode.failed",
588
- tenantId: this.tenantId,
589
- messageId: msg.MessageId,
590
- err: { message: (err as Error).message },
591
- },
592
- "push receiver dropped event: decode failed",
593
- );
594
- // A poison message we can't decode is deleted so it doesn't redeliver
595
- // forever — it carries no actionable path. (Defense in depth.)
596
- await this.safeDelete(msg);
597
- return;
598
- }
599
-
600
- const handled = await this.dispatch(validated);
601
- if (handled) {
602
- // Delete only after a successful handoff or dedupe-skip. A syncFn throw
603
- // leaves the message undeleted so SQS redelivery can retry the targeted
604
- // pull.
605
- await this.safeDelete(msg);
606
- }
607
- }
608
-
609
- /**
610
- * Dedupe + invoke `syncFn`. Returns true only once the event has been
611
- * accounted for successfully (deduped, or syncFn completed) so the caller can
612
- * delete the message. Stores the in-flight promise so `dispose()` can drain
613
- * it.
614
- */
615
- private async dispatch(event: PushEvent): Promise<boolean> {
616
- if (this.disposing || this.disposed) return false;
617
-
618
- const seen = this.seenSequencePerPath.get(event.relativePath);
619
- if (seen !== undefined && event.sequenceNumber <= seen) {
620
- this._dedupedCount += 1;
621
- this.logger.debug(
622
- {
623
- event: "receiver.event.deduped",
624
- tenantId: this.tenantId,
625
- relativePath: event.relativePath,
626
- sequenceNumber: event.sequenceNumber,
627
- seen,
628
- },
629
- "push receiver deduped event",
630
- );
631
- return true;
632
- }
633
-
634
- const controller = new AbortController();
635
- this.inFlightAbort = controller;
636
- const ctx: PushReceiverContext = { event, signal: controller.signal };
637
-
638
- const holder: { p: Promise<void> | null } = { p: null };
639
- const startMs = this.now();
640
- let handled = false;
641
- const p: Promise<void> = (async () => {
642
- try {
643
- this.logger.debug(
644
- {
645
- event: "receiver.sync.start",
646
- tenantId: this.tenantId,
647
- relativePath: event.relativePath,
648
- sequenceNumber: event.sequenceNumber,
649
- },
650
- "push receiver invoking sync engine",
651
- );
652
- await this.syncFn(ctx);
653
- this.seenSequencePerPath.set(event.relativePath, event.sequenceNumber);
654
- this._processedCount += 1;
655
- handled = true;
656
- this.logger.debug(
657
- {
658
- event: "receiver.sync.completed",
659
- tenantId: this.tenantId,
660
- relativePath: event.relativePath,
661
- sequenceNumber: event.sequenceNumber,
662
- },
663
- "push receiver sync completed",
664
- );
665
- // ── US-011: 3-log chain (3rd link) + p95 latency metric ──────────
666
- // Latency = save-on-A (event.eventTimestamp) → visible-on-B (now),
667
- // falling back to the local syncFn duration if the event timestamp is
668
- // unparseable. Emitted ONLY on the success path so failed syncs don't
669
- // skew p95 toward infinity.
670
- const endMs = this.now();
671
- const savedAtMs = Date.parse(event.eventTimestamp);
672
- const latencyMs = Number.isFinite(savedAtMs)
673
- ? Math.max(0, endMs - savedAtMs)
674
- : Math.max(0, endMs - startMs);
675
- const latencySeconds = latencyMs / 1000;
676
- // The 3rd correlated log line — shares `sequenceNumber` with
677
- // watcher.emit (client push) and push.receive (server).
678
- this.logger.info(
679
- {
680
- event: "fanout.receive",
681
- tenantId: this.tenantId,
682
- relativePath: event.relativePath,
683
- sequenceNumber: event.sequenceNumber,
684
- latencySeconds,
685
- },
686
- "push receiver fanout-receive (round-trip complete)",
687
- );
688
- // Best-effort metric. Fire-and-forget so observability never sits on
689
- // the dispatch critical path (which gates message deletion) — a slow or
690
- // hung CloudWatch call must not delay the delete/next-poll. Errors are
691
- // swallowed; publishMetric is itself best-effort.
692
- void this.emitLatencyMetric({
693
- tenantId: this.tenantId,
694
- relativePath: event.relativePath,
695
- sequenceNumber: event.sequenceNumber,
696
- latencySeconds,
697
- timestamp: new Date(endMs),
698
- });
699
- } catch (err) {
700
- // Critical: catch, log, return. A misbehaving sync engine must never
701
- // crash the receiver loop. Leaving the SQS message undeleted lets
702
- // redelivery retry the failed targeted pull.
703
- const e = err as NodeJS.ErrnoException;
704
- this.logger.error(
705
- {
706
- event: "receiver.sync.failed",
707
- tenantId: this.tenantId,
708
- relativePath: event.relativePath,
709
- sequenceNumber: event.sequenceNumber,
710
- err: { message: e?.message, code: e?.code },
711
- },
712
- "push receiver sync engine threw",
713
- );
714
- } finally {
715
- if (this.inFlightAbort === controller) this.inFlightAbort = null;
716
- if (this.inFlightSync === holder.p) this.inFlightSync = null;
717
- }
718
- })();
719
- holder.p = p;
720
- this.inFlightSync = p;
721
- await p;
722
- return handled;
723
- }
724
-
725
- /** Delete a message, swallowing transport errors (redelivery is harmless). */
726
- private async safeDelete(msg: SqsMessageLike): Promise<void> {
727
- if (!msg.ReceiptHandle) return;
728
- try {
729
- await this.sqs.deleteMessage({
730
- queueUrl: this.queueUrl,
731
- receiptHandle: msg.ReceiptHandle,
732
- });
733
- } catch (err) {
734
- this.logger.warn(
735
- {
736
- event: "receiver.delete.failed",
737
- tenantId: this.tenantId,
738
- messageId: msg.MessageId,
739
- err: { message: (err as Error)?.message },
740
- },
741
- "push receiver failed to delete message (will redeliver; dedupe absorbs)",
742
- );
743
- }
744
- }
745
-
746
- /**
747
- * Publish one best-effort latency datum (US-011). Awaits `publishMetric` and
748
- * swallows any rejection so a metric-backend outage can never reach the poll
749
- * loop. Called fire-and-forget (`void`) off the dispatch critical path.
750
- */
751
- private async emitLatencyMetric(metric: SyncLatencyMetric): Promise<void> {
752
- try {
753
- await this.publishMetric(metric);
754
- } catch (metricErr) {
755
- this.logger.warn(
756
- {
757
- event: "receiver.metric.failed",
758
- tenantId: metric.tenantId,
759
- sequenceNumber: metric.sequenceNumber,
760
- err: { message: (metricErr as Error)?.message },
761
- },
762
- "push receiver failed to publish latency metric (ignored)",
763
- );
764
- }
765
- }
766
-
767
- /** Exponential backoff (capped) with optional full-jitter. */
768
- private computeBackoff(attempt: number): number {
769
- const exp = Math.min(
770
- this.reconnectMaxMs,
771
- this.reconnectInitialMs * 2 ** attempt,
772
- );
773
- if (!this.reconnectJitter) return exp;
774
- return Math.floor(Math.random() * exp);
775
- }
776
- }
777
-
778
- // ─── In-memory receiver (unit-test transport analogue) ───────────────────────
779
-
780
- /**
781
- * A tiny in-process fanout the {@link InMemoryPushReceiver} subscribes against.
782
- * Models SNS publish + the per-client SQS queue's disconnect buffering so unit
783
- * tests can drive the receive path without AWS. `publish` raw strings (the
784
- * wire form) so decode-failure paths are testable too.
785
- */
786
- export class InMemoryFanout {
787
- private readonly subscribers = new Set<(raw: string) => void>();
788
-
789
- subscribe(handler: (raw: string) => void): () => void {
790
- this.subscribers.add(handler);
791
- return () => this.subscribers.delete(handler);
792
- }
793
-
794
- /** Publish a raw (already-encoded) message body to all subscribers. */
795
- publish(raw: string): void {
796
- for (const h of [...this.subscribers]) h(raw);
797
- }
798
- }
799
-
800
- /** Options for {@link InMemoryPushReceiver}. */
801
- export interface InMemoryPushReceiverOptions {
802
- tenantId: string;
803
- fanout: InMemoryFanout;
804
- syncFn: SyncEngineFn;
805
- logger?: ReceiverLogger;
806
- /** Deprecated compatibility field; intentionally ignored. */
807
- enabled?: boolean;
808
- disposeDrainMs?: number;
809
- /** Maximum distinct paths retained in dedupe state. */
810
- dedupeMaxPaths?: number;
811
- }
812
-
813
- /**
814
- * In-memory receiver paired with {@link InMemoryFanout}. Powers the unit
815
- * tests for dedupe, reconnect-replay, flag gating, and dispose-drain WITHOUT
816
- * any AWS SDK. The dedupe / dispatch / dispose semantics are identical to
817
- * {@link SqsPushReceiver} (shared design); the disconnect buffer is the
818
- * in-process analogue of the per-client SQS queue's 14-day retention.
819
- */
820
- export class InMemoryPushReceiver implements PushReceiver {
821
- private readonly tenantId: string;
822
- private readonly fanout: InMemoryFanout;
823
- private readonly syncFn: SyncEngineFn;
824
- private readonly logger: ReceiverLogger;
825
- private readonly disposeDrainMs: number;
826
-
827
- private _connected = false;
828
- private disposed = false;
829
- private disposing = false;
830
- private disposePromise: Promise<void> | null = null;
831
- private unsubscribe: (() => void) | null = null;
832
-
833
- private disconnectedFlag = false;
834
- private readonly pendingDuringDisconnect: PushEvent[] = [];
835
- private readonly seenSequencePerPath: BoundedSequenceDedupe;
836
-
837
- private inFlightAbort: AbortController | null = null;
838
- private inFlightSync: Promise<void> | null = null;
839
-
840
- private _processedCount = 0;
841
- private _dedupedCount = 0;
842
- private _decodeFailureCount = 0;
843
-
844
- constructor(opts: InMemoryPushReceiverOptions) {
845
- this.tenantId = opts.tenantId;
846
- this.fanout = opts.fanout;
847
- this.syncFn = opts.syncFn;
848
- this.logger = opts.logger ?? NOOP_LOGGER;
849
- this.disposeDrainMs =
850
- opts.disposeDrainMs ?? DEFAULT_RECEIVER_DISPOSE_DRAIN_MS;
851
- this.seenSequencePerPath = new BoundedSequenceDedupe(opts.dedupeMaxPaths);
852
- }
853
-
854
- get connected(): boolean {
855
- return this._connected;
856
- }
857
-
858
- async start(): Promise<void> {
859
- if (this.disposed) return;
860
- if (this.unsubscribe !== null) return; // double-start no-op
861
-
862
- this.unsubscribe = this.fanout.subscribe((raw) => {
863
- let validated: PushEvent;
864
- try {
865
- validated = decodePushEvent(raw);
866
- } catch (err) {
867
- this._decodeFailureCount += 1;
868
- this.logger.warn(
869
- {
870
- event: "receiver.decode.failed",
871
- tenantId: this.tenantId,
872
- err: { message: (err as Error).message },
873
- },
874
- "push receiver dropped event: decode failed",
875
- );
876
- return;
877
- }
878
- if (this.disconnectedFlag) {
879
- this.pendingDuringDisconnect.push(validated);
880
- return;
881
- }
882
- void this.dispatch(validated);
883
- });
884
- this._connected = true;
885
- this.logger.info(
886
- { event: "receiver.start", tenantId: this.tenantId },
887
- "push receiver subscribed (in-memory)",
888
- );
889
- }
890
-
891
- async dispose(): Promise<void> {
892
- if (this.disposed) return;
893
- if (this.disposePromise !== null) return this.disposePromise;
894
- this.disposing = true;
895
- this.disposePromise = (async () => {
896
- try {
897
- this.inFlightAbort?.abort();
898
- } catch {
899
- /* defensive */
900
- }
901
- if (this.inFlightSync !== null) {
902
- const drainDeadline = new Promise<void>((resolve) => {
903
- const t = setTimeout(resolve, this.disposeDrainMs);
904
- (t as { unref?: () => void }).unref?.();
905
- });
906
- await Promise.race([
907
- this.inFlightSync.catch(() => undefined),
908
- drainDeadline,
909
- ]);
910
- }
911
- if (this.unsubscribe !== null) {
912
- try {
913
- this.unsubscribe();
914
- } catch {
915
- /* defensive */
916
- }
917
- this.unsubscribe = null;
918
- }
919
- this._connected = false;
920
- this.disposed = true;
921
- this.logger.info(
922
- {
923
- event: "receiver.stop",
924
- tenantId: this.tenantId,
925
- processed: this._processedCount,
926
- deduped: this._dedupedCount,
927
- },
928
- "push receiver stopped",
929
- );
930
- })();
931
- return this.disposePromise;
932
- }
933
-
934
- // ─── Test hooks (model the SQS retention buffer) ────────────────────────────
935
-
936
- get processedCount(): number {
937
- return this._processedCount;
938
- }
939
- get dedupedCount(): number {
940
- return this._dedupedCount;
941
- }
942
- get decodeFailureCount(): number {
943
- return this._decodeFailureCount;
944
- }
945
- get bufferedCount(): number {
946
- return this.pendingDuringDisconnect.length;
947
- }
948
-
949
- /** Emulate a network blip — events buffer instead of dispatching. */
950
- simulateDisconnect(): void {
951
- if (this.unsubscribe === null) return;
952
- this.disconnectedFlag = true;
953
- this._connected = false;
954
- }
955
-
956
- /** Emulate reconnect — drain the buffer through the same dedupe path. */
957
- simulateReconnect(): void {
958
- if (!this.disconnectedFlag) return;
959
- this.disconnectedFlag = false;
960
- this._connected = true;
961
- const queued = this.pendingDuringDisconnect.splice(0);
962
- for (const evt of queued) void this.dispatch(evt);
963
- }
964
-
965
- private dispatch(event: PushEvent): Promise<void> {
966
- if (this.disposing || this.disposed) return Promise.resolve();
967
-
968
- const seen = this.seenSequencePerPath.get(event.relativePath);
969
- if (seen !== undefined && event.sequenceNumber <= seen) {
970
- this._dedupedCount += 1;
971
- return Promise.resolve();
972
- }
973
- this.seenSequencePerPath.set(event.relativePath, event.sequenceNumber);
974
-
975
- const controller = new AbortController();
976
- this.inFlightAbort = controller;
977
- const ctx: PushReceiverContext = { event, signal: controller.signal };
978
- const holder: { p: Promise<void> | null } = { p: null };
979
- const p: Promise<void> = (async () => {
980
- try {
981
- await this.syncFn(ctx);
982
- this._processedCount += 1;
983
- } catch (err) {
984
- this.logger.error(
985
- {
986
- event: "receiver.sync.failed",
987
- tenantId: this.tenantId,
988
- relativePath: event.relativePath,
989
- err: { message: (err as Error)?.message },
990
- },
991
- "push receiver sync engine threw",
992
- );
993
- } finally {
994
- if (this.inFlightAbort === controller) this.inFlightAbort = null;
995
- if (this.inFlightSync === holder.p) this.inFlightSync = null;
996
- }
997
- })();
998
- holder.p = p;
999
- this.inFlightSync = p;
1000
- return p;
1001
- }
1002
- }
1003
-
1004
- // ─── Factory ───────────────────────────────────────────────────────────────
1005
-
1006
- /**
1007
- * Build a PushReceiver. The daemon defaults to the noop so wiring is
1008
- * regression-safe — production deployments wire the SQS impl explicitly once
1009
- * the server provisioning Lambda mints a queue URL. Mirrors PushTransport's
1010
- * noop-default opt-in posture.
1011
- */
1012
- export type CreatePushReceiverOptions =
1013
- | (SqsPushReceiverOptions & { kind?: "sqs" })
1014
- | { kind: "noop" };
1015
-
1016
- export function createPushReceiver(
1017
- opts: CreatePushReceiverOptions,
1018
- ): PushReceiver {
1019
- if ("kind" in opts && opts.kind === "noop") {
1020
- return new NoopPushReceiver();
1021
- }
1022
- if ("queueUrl" in opts && "sqs" in opts) {
1023
- return new SqsPushReceiver(opts);
1024
- }
1025
- return new NoopPushReceiver();
1026
- }
1027
-
1028
- // ─── Helpers ───────────────────────────────────────────────────────────────
1029
-
1030
- /**
1031
- * Default sleep that resolves after `ms` OR promptly on abort (so a dispose
1032
- * during a backoff wait doesn't block). Never rejects.
1033
- */
1034
- function defaultSleep(ms: number, signal: AbortSignal): Promise<void> {
1035
- return new Promise<void>((resolve) => {
1036
- if (signal.aborted) return resolve();
1037
- const t = setTimeout(() => {
1038
- signal.removeEventListener("abort", onAbort);
1039
- resolve();
1040
- }, ms);
1041
- (t as { unref?: () => void }).unref?.();
1042
- const onAbort = (): void => {
1043
- clearTimeout(t);
1044
- resolve();
1045
- };
1046
- signal.addEventListener("abort", onAbort, { once: true });
1047
- });
1048
- }