deepline 0.3.479 → 0.3.481

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 (152) hide show
  1. package/dist/bundling-sources/sdk/src/client-core.ts +35 -1
  2. package/dist/bundling-sources/sdk/src/http-core.ts +2 -0
  3. package/dist/bundling-sources/sdk/src/plays/run-request.ts +54 -31
  4. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  5. package/dist/bundling-sources/shared_libs/monitors/legacy-radar-forward-routes.ts +569 -0
  6. package/dist/bundling-sources/shared_libs/monitors/legacy-radar-retirement.ts +451 -0
  7. package/dist/bundling-sources/shared_libs/monitors/package.json +5 -1
  8. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +18 -0
  9. package/dist/bundling-sources/shared_libs/play-runtime/runtime-array-input-receipts.ts +13 -6
  10. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +2 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +125 -22
  12. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +16 -53
  13. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +34 -48
  14. package/dist/bundling-sources/shared_libs/security/receipt-admission-token.ts +44 -0
  15. package/dist/cli/auth-EQWYBNIE.js +43 -0
  16. package/dist/cli/{auth-D7DYB57R.mjs → auth-FYY65LYD.mjs} +7 -5
  17. package/dist/cli/{billing-DT4JT4QN.mjs → billing-3MELYUTI.mjs} +7 -7
  18. package/dist/cli/{billing-DHGSNM6E.js → billing-TROHWBSE.js} +80 -80
  19. package/dist/cli/{chunk-GQCH6Y7W.js → chunk-27BYXQU4.js} +1 -1
  20. package/dist/cli/{chunk-CK2Q6AWR.js → chunk-4CIVXXSU.js} +26 -26
  21. package/dist/cli/{chunk-G6V34EQ5.js → chunk-5ONCPLWC.js} +16 -16
  22. package/dist/cli/{chunk-27GSBTWD.js → chunk-6VKDKATO.js} +96 -65
  23. package/dist/cli/{chunk-LXYDN5FE.js → chunk-6WWBVWFQ.js} +7 -7
  24. package/dist/cli/{chunk-JNFNNJ75.mjs → chunk-6ZMUAASO.mjs} +4 -4
  25. package/dist/cli/{chunk-6C3QY5JS.mjs → chunk-75LUK4E5.mjs} +1 -1
  26. package/dist/cli/{chunk-JKUFOP7M.mjs → chunk-7VVC4RZX.mjs} +1 -1
  27. package/dist/cli/{chunk-N7E25EU2.mjs → chunk-ASQJUUGB.mjs} +5 -5
  28. package/dist/cli/{chunk-FKEZ5DIJ.mjs → chunk-BJ5UFTWL.mjs} +28 -7
  29. package/dist/cli/{chunk-W4HIOBQQ.js → chunk-BJMEXKZY.js} +2 -2
  30. package/dist/cli/{chunk-YC2QBF2W.mjs → chunk-C7UIUKW6.mjs} +2 -2
  31. package/dist/cli/{chunk-AKVNUNL7.js → chunk-CM3KC74A.js} +3 -3
  32. package/dist/cli/{chunk-JFGOMQW6.mjs → chunk-CS2P3EQS.mjs} +2 -2
  33. package/dist/cli/{chunk-VKBLXJCK.js → chunk-CUSWZTS7.js} +8 -8
  34. package/dist/cli/{chunk-GSULE54L.js → chunk-D3UHG54V.js} +199 -178
  35. package/dist/cli/{chunk-3PHOAUU4.mjs → chunk-EJTU6X6C.mjs} +41 -10
  36. package/dist/cli/{chunk-AFHFZU5V.js → chunk-EPEAQA6O.js} +37 -37
  37. package/dist/cli/{chunk-K5RRY7DJ.mjs → chunk-EPZSX7FZ.mjs} +4 -4
  38. package/dist/cli/{chunk-27PXHD2P.mjs → chunk-FXLMDAAY.mjs} +1 -1
  39. package/dist/cli/chunk-GBAT5NMI.mjs +789 -0
  40. package/dist/cli/{chunk-Y2MVDYV6.js → chunk-GJOKFJ2Y.js} +10 -10
  41. package/dist/cli/{chunk-JDMR33HS.js → chunk-GVAENVIG.js} +28 -19
  42. package/dist/cli/{chunk-NELMCOWK.mjs → chunk-HNRELVCS.mjs} +27 -18
  43. package/dist/cli/{chunk-GICSDK72.mjs → chunk-HXG7TYRS.mjs} +2 -2
  44. package/dist/cli/{chunk-EMVWC5LP.js → chunk-J4D3PCQ2.js} +5 -5
  45. package/dist/cli/{chunk-6QIXWFPO.mjs → chunk-JE27DTQM.mjs} +3 -3
  46. package/dist/cli/{chunk-3GIM7XRH.js → chunk-KDU6TSGE.js} +2 -2
  47. package/dist/cli/{chunk-JMCTNS6S.js → chunk-KPXP2DIY.js} +7 -9
  48. package/dist/cli/chunk-KQSTV4MW.js +789 -0
  49. package/dist/cli/{chunk-RJVUILW4.mjs → chunk-LISZBUA7.mjs} +2 -2
  50. package/dist/cli/{chunk-D6MLXRMQ.mjs → chunk-LTOY2GUJ.mjs} +10 -10
  51. package/dist/cli/{chunk-FBEXJVQL.js → chunk-MKDWU3ZI.js} +93 -93
  52. package/dist/cli/{chunk-UOYA35OT.mjs → chunk-N5267HEH.mjs} +1 -1
  53. package/dist/cli/{chunk-XDFARN7U.mjs → chunk-PDJS6LFI.mjs} +2 -1
  54. package/dist/cli/{chunk-J5VDE42G.js → chunk-R4EFW2I2.js} +3 -3
  55. package/dist/cli/{chunk-GONL4EFT.mjs → chunk-RV2GB7KW.mjs} +2 -2
  56. package/dist/cli/{chunk-GZ43YXHT.mjs → chunk-S5BM6NPJ.mjs} +3 -5
  57. package/dist/cli/{chunk-PUOKABQF.mjs → chunk-S5WXSC3P.mjs} +5 -5
  58. package/dist/cli/{chunk-ZJRVHQSZ.mjs → chunk-SOGLBLMH.mjs} +4 -4
  59. package/dist/cli/{chunk-FV7WGK5X.js → chunk-SVPM2E2U.js} +5 -5
  60. package/dist/cli/{chunk-XBYW5EHY.mjs → chunk-UUGSZZZH.mjs} +1 -1
  61. package/dist/cli/{chunk-3SGA53DX.js → chunk-VBDR2DP6.js} +3 -3
  62. package/dist/cli/{chunk-O5BUFQ2P.mjs → chunk-VRAOWHXJ.mjs} +1 -1
  63. package/dist/cli/{chunk-TZCHXF4R.mjs → chunk-VTKAIGW6.mjs} +3 -3
  64. package/dist/cli/{chunk-33O2JGHE.js → chunk-WF5QE2IW.js} +17 -16
  65. package/dist/cli/{chunk-OVFCPV24.js → chunk-WICQQ7GY.js} +53 -53
  66. package/dist/cli/{chunk-W6N2S5N7.js → chunk-WTWRZ5ZI.js} +8 -8
  67. package/dist/cli/{chunk-N4M6CO5H.js → chunk-X4XZFIK6.js} +14 -14
  68. package/dist/cli/{chunk-VRZXGJXH.js → chunk-X6MEQBAT.js} +21 -21
  69. package/dist/cli/{csv-JXFOD7DJ.mjs → csv-2EG7JW7V.mjs} +4 -4
  70. package/dist/cli/{csv-DQWOZ2BL.js → csv-OUALOZBF.js} +13 -13
  71. package/dist/cli/{db-Q2OAAXRX.js → db-7BTOI6YB.js} +28 -28
  72. package/dist/cli/{db-NOGMZAQE.mjs → db-ECDQWI2Y.mjs} +7 -7
  73. package/dist/cli/describe-EXSH6OZD.js +48 -0
  74. package/dist/cli/{describe-VKZTZLX6.mjs → describe-KO6WKQIB.mjs} +18 -18
  75. package/dist/cli/describe-core.d.mts +2 -2
  76. package/dist/cli/describe-core.d.ts +2 -2
  77. package/dist/cli/{executions-XU3JMNI3.mjs → executions-3VMFBZT4.mjs} +7 -7
  78. package/dist/cli/{executions-B3GDZ4GQ.js → executions-VZSG3F3E.js} +9 -9
  79. package/dist/cli/{feedback-5IW3ZKRP.js → feedback-ASKBWRXR.js} +8 -8
  80. package/dist/cli/{feedback-R5UQXINE.mjs → feedback-SN7AVALN.mjs} +4 -4
  81. package/dist/cli/{git-7B4H7VEL.mjs → git-W2DZXEAY.mjs} +5 -5
  82. package/dist/cli/git-X2XUKYYX.js +25 -0
  83. package/dist/cli/index.js +167 -144
  84. package/dist/cli/index.mjs +73 -50
  85. package/dist/cli/{legacy-noops-AVT6T5QI.js → legacy-noops-6KOC6LV7.js} +8 -8
  86. package/dist/cli/{legacy-noops-KY7JAALE.mjs → legacy-noops-R2SML7ED.mjs} +5 -5
  87. package/dist/cli/{monitors-G7L7AFXR.mjs → monitors-4VGZGJBT.mjs} +8 -8
  88. package/dist/cli/{monitors-GZB6CWK2.js → monitors-OJXDYU4G.js} +92 -92
  89. package/dist/cli/{org-ZHHKAPCE.js → org-AELMZFUT.js} +73 -73
  90. package/dist/cli/{org-QDUBOQTH.mjs → org-UGN5ZG2Y.mjs} +7 -7
  91. package/dist/cli/{pages-5P3SPMN5.js → pages-L26N7MK3.js} +18 -18
  92. package/dist/cli/{pages-Q4WGQCST.mjs → pages-ZYPAAYSS.mjs} +7 -7
  93. package/dist/cli/{play-WRSHD4RE.mjs → play-ENYB7X4S.mjs} +125 -53
  94. package/dist/cli/{play-IRLKESZB.js → play-VTZWB3N5.js} +620 -548
  95. package/dist/cli/{providers-7CQUHS57.mjs → providers-BZQTVAQG.mjs} +7 -7
  96. package/dist/cli/{providers-LKEYLN6D.js → providers-HWBY6MGQ.js} +10 -10
  97. package/dist/cli/{quickstart-KS45M5SR.js → quickstart-D54IJ3PX.js} +6 -6
  98. package/dist/cli/{quickstart-I6NYGMEE.mjs → quickstart-SSQJDWPB.mjs} +4 -4
  99. package/dist/cli/{run-BLMOBNAN.js → run-45NKE7UY.js} +37 -37
  100. package/dist/cli/{run-AZETCL7R.mjs → run-FR3TPYT3.mjs} +23 -23
  101. package/dist/cli/{search-EEKEP67Y.mjs → search-CZCWSR5L.mjs} +8 -8
  102. package/dist/cli/search-V2GJKQQQ.js +32 -0
  103. package/dist/cli/{secrets-M6NU3TD2.js → secrets-KJ5ZEDWT.js} +16 -16
  104. package/dist/cli/{secrets-HAULG2EW.mjs → secrets-KKK55B4Y.mjs} +7 -7
  105. package/dist/cli/{sessions-5MB4XKOK.mjs → sessions-2YKA54PH.mjs} +5 -5
  106. package/dist/cli/{sessions-DSR5AHIA.js → sessions-QCNPUTDZ.js} +6 -6
  107. package/dist/cli/{settings-JUQP4HYQ.mjs → settings-4NI2AS5V.mjs} +12 -12
  108. package/dist/cli/{settings-6US327NQ.js → settings-KM3LCPWD.js} +79 -79
  109. package/dist/cli/{setup-JRH4UC47.mjs → setup-BZF3M54I.mjs} +33 -18
  110. package/dist/cli/{setup-5ZRSEQUD.js → setup-V5MTS2AA.js} +67 -52
  111. package/dist/cli/skills-CRFYW767.js +33 -0
  112. package/dist/cli/{skills-HFOZ5WDH.mjs → skills-NXYNB3LP.mjs} +7 -7
  113. package/dist/cli/{tools-CJEBZNPB.mjs → tools-57MWFRIZ.mjs} +17 -17
  114. package/dist/cli/tools-Q662HPVJ.js +55 -0
  115. package/dist/cli/{update-VQJEPAAK.mjs → update-QM6H677U.mjs} +9 -9
  116. package/dist/cli/update-TYHKF7F5.js +33 -0
  117. package/dist/cli/{workflow-EYWE3T75.mjs → workflow-NHYDIUWY.mjs} +8 -8
  118. package/dist/cli/{workflow-NKPDRZOP.js → workflow-TGEC4ITO.js} +18 -18
  119. package/dist/{client-B9N1bcGc.d.mts → client-DJ3r8VQJ.d.mts} +1 -1
  120. package/dist/{client-Btw075Hq.d.ts → client-JPSNljtj.d.ts} +1 -1
  121. package/dist/{client-core-TulbIeRB.d.mts → client-core-3SJlNsKU.d.mts} +21 -2
  122. package/dist/{client-core-DvGWlwtX.d.ts → client-core-rCoQ5Xz3.d.ts} +21 -2
  123. package/dist/index.d.mts +3 -3
  124. package/dist/index.d.ts +3 -3
  125. package/dist/index.js +10 -10
  126. package/dist/index.mjs +7 -7
  127. package/dist/install-integrity.json +106 -104
  128. package/dist/plays/bundle-play-file.mjs +5426 -12
  129. package/dist/plays/play-description.d.mts +1 -1
  130. package/dist/plays/play-description.d.ts +1 -1
  131. package/dist/plays/run-request.d.mts +1 -1
  132. package/dist/plays/run-request.d.ts +1 -1
  133. package/dist/plays/run-request.js +4 -2
  134. package/dist/plays/run-request.mjs +3 -1
  135. package/dist/release.d.mts +1 -1
  136. package/dist/release.d.ts +1 -1
  137. package/dist/release.js +2 -2
  138. package/dist/release.mjs +1 -1
  139. package/dist/server.d.mts +2 -2
  140. package/dist/server.d.ts +2 -2
  141. package/dist/server.js +6 -6
  142. package/dist/server.mjs +4 -4
  143. package/package.json +1 -1
  144. package/dist/cli/auth-MZDFNRPG.js +0 -41
  145. package/dist/cli/chunk-7UFFWPQ2.js +0 -6188
  146. package/dist/cli/chunk-ZBSUXXZS.mjs +0 -6188
  147. package/dist/cli/describe-THHHNUEA.js +0 -48
  148. package/dist/cli/git-BX27JAW4.js +0 -25
  149. package/dist/cli/search-PEQXXY2B.js +0 -32
  150. package/dist/cli/skills-P67KCVDR.js +0 -33
  151. package/dist/cli/tools-PDBEAI22.js +0 -55
  152. package/dist/cli/update-G6DI3BQW.js +0 -33
@@ -0,0 +1,569 @@
1
+ // Forward-route provisioning for a customer forwarder Play (W3 of the legacy
2
+ // radar retirement): pure planning and SQL text.
3
+ //
4
+ // A forwarder delivers a finding only when a route row matches the finding's
5
+ // `monitor_id`, `binding_version` and `account_id`. A re-bound monitor
6
+ // therefore stops forwarding silently until its route pins the new binding.
7
+ // This planner only ever inserts a missing (monitor_id, account_id) route or
8
+ // refreshes the binding pin of an existing one. It never deletes, re-enables,
9
+ // or changes an account, and it leaves every other column of an existing row
10
+ // untouched (the upsert updates `binding_version` alone).
11
+
12
+ import {
13
+ isLegacyContactRadarType,
14
+ LEGACY_RETIREMENT_ROUTE_TABLE_PATTERN,
15
+ type LegacyRequiredMonitorFacts,
16
+ } from './legacy-radar-retirement';
17
+
18
+ export const FORWARD_ROUTE_WRITE_BATCH = 200;
19
+ /**
20
+ * The platform-admin Customer DB SQL endpoint refuses longer statements
21
+ * (`ADMIN_SQL_MAX_LENGTH` in `apps/deepline-api/src/lib/admin/customer-db-sql.ts`).
22
+ */
23
+ export const ADMIN_CUSTOMER_DB_SQL_MAX_LENGTH = 100_000;
24
+ export const FORWARD_ROUTE_READ_PAGE = 200;
25
+
26
+ export const FORWARD_ROUTE_COLUMNS = [
27
+ 'monitor_id',
28
+ 'binding_version',
29
+ 'radar_type',
30
+ 'domain',
31
+ 'profile_url',
32
+ 'account_id',
33
+ 'destination',
34
+ 'deactivated_at',
35
+ 'disposition',
36
+ 'custom_fields',
37
+ 'enabled',
38
+ ] as const;
39
+
40
+ export type ForwardRouteRow = {
41
+ monitor_id: string;
42
+ binding_version: string;
43
+ radar_type: string;
44
+ domain: string;
45
+ profile_url: string | null;
46
+ account_id: string;
47
+ destination: string;
48
+ deactivated_at: string;
49
+ disposition: string;
50
+ custom_fields: Record<string, unknown>;
51
+ enabled: boolean;
52
+ };
53
+
54
+ export type ForwardRouteKey = { monitor_id: string; account_id: string };
55
+
56
+ export function forwardRouteKey(route: ForwardRouteKey): string {
57
+ return JSON.stringify([route.monitor_id, route.account_id]);
58
+ }
59
+
60
+ export function assertForwardRouteTable(table: string): string {
61
+ if (!LEGACY_RETIREMENT_ROUTE_TABLE_PATTERN.test(table)) {
62
+ throw new Error(
63
+ `Route table ${JSON.stringify(table)} must match ${String(LEGACY_RETIREMENT_ROUTE_TABLE_PATTERN)}.`,
64
+ );
65
+ }
66
+ return table;
67
+ }
68
+
69
+ const SALESFORCE_CHECKSUM_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ012345';
70
+
71
+ /** The three-character case checksum Salesforce appends to a 15-character id. */
72
+ export function salesforceIdChecksum(id15: string): string {
73
+ let suffix = '';
74
+ for (let chunk = 0; chunk < 3; chunk += 1) {
75
+ let flags = 0;
76
+ for (let index = 0; index < 5; index += 1) {
77
+ const char = id15[chunk * 5 + index] ?? '';
78
+ if (char >= 'A' && char <= 'Z') flags |= 1 << index;
79
+ }
80
+ suffix += SALESFORCE_CHECKSUM_ALPHABET[flags];
81
+ }
82
+ return suffix;
83
+ }
84
+
85
+ /** An 18-character Salesforce id whose case checksum is consistent. */
86
+ export function isSalesforceId18(value: string): boolean {
87
+ if (!/^[a-zA-Z0-9]{18}$/.test(value)) return false;
88
+ return (
89
+ salesforceIdChecksum(value.slice(0, 15)) === value.slice(15).toUpperCase()
90
+ );
91
+ }
92
+
93
+ /**
94
+ * E-string literal, correct whatever `standard_conforming_strings` says:
95
+ * backslashes and quotes are doubled. NUL cannot be stored in text and is
96
+ * refused rather than truncated.
97
+ */
98
+ export function sqlTextLiteral(value: string): string {
99
+ if (value.includes('\u0000')) {
100
+ throw new Error('SQL text values cannot contain NUL characters.');
101
+ }
102
+ return `E'${value.replace(/\\/g, '\\\\').replace(/'/g, "''")}'`;
103
+ }
104
+
105
+ function sqlNullableText(value: string | null): string {
106
+ return value === null ? 'NULL' : sqlTextLiteral(value);
107
+ }
108
+
109
+ function stableJson(value: unknown): string {
110
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
111
+ if (value && typeof value === 'object') {
112
+ return `{${Object.entries(value as Record<string, unknown>)
113
+ .filter(([, entry]) => entry !== undefined)
114
+ .sort(([left], [right]) => left.localeCompare(right))
115
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
116
+ .join(',')}}`;
117
+ }
118
+ return JSON.stringify(value) ?? 'null';
119
+ }
120
+
121
+ function text(value: unknown): string | null {
122
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
123
+ }
124
+
125
+ /**
126
+ * The route subject a monitor's own payload names; `domain` is '' when the
127
+ * payload names no company (the plan's route domain then fills it in).
128
+ */
129
+ export function forwardRouteSubject(payload: unknown): {
130
+ radar_type: string;
131
+ domain: string;
132
+ profile_url: string | null;
133
+ } | null {
134
+ const value =
135
+ payload && typeof payload === 'object' && !Array.isArray(payload)
136
+ ? (payload as Record<string, unknown>)
137
+ : null;
138
+ const radarType = text(value?.radar_type);
139
+ if (!value || !radarType) return null;
140
+ return {
141
+ radar_type: radarType,
142
+ domain: text(value.domain) ?? '',
143
+ profile_url: isLegacyContactRadarType(radarType)
144
+ ? text(value.profile_url)
145
+ : null,
146
+ };
147
+ }
148
+
149
+ function normalizeRouteDomain(domain: string): string {
150
+ return domain
151
+ .trim()
152
+ .toLowerCase()
153
+ .replace(/^[a-z]+:\/\//, '')
154
+ .split('/')[0]!
155
+ .replace(/^www\d?\./, '');
156
+ }
157
+
158
+ function monitorRefusal(
159
+ facts: LegacyRequiredMonitorFacts | null,
160
+ ): string | null {
161
+ if (!facts) return 'monitor_missing';
162
+ if (facts.status !== 'active') return 'monitor_not_active';
163
+ if (facts.desired !== 'active') return 'monitor_not_desired_active';
164
+ if (facts.observed !== 'active') return 'monitor_not_observed_active';
165
+ if (!facts.bindingVersion) return 'monitor_unbound';
166
+ return null;
167
+ }
168
+
169
+ export type ForwardRouteRequest = {
170
+ monitorKey: string;
171
+ accountId: string;
172
+ /**
173
+ * The account's company domain. Every existing route carries one, and the
174
+ * forwarder compares findings against it, so it is required even for a
175
+ * contact monitor whose payload names no company.
176
+ */
177
+ routeDomain: string;
178
+ customFields: Record<string, unknown>;
179
+ deactivatedAt: string;
180
+ };
181
+
182
+ export type ForwardRouteRefusal = {
183
+ monitorKey: string;
184
+ accountId: string | null;
185
+ reason: string;
186
+ };
187
+
188
+ export type ForwardRoutePlan = {
189
+ inserts: ForwardRouteRow[];
190
+ /** Existing rows, unchanged except for `binding_version`. */
191
+ refreshes: Array<ForwardRouteRow & { previous_binding_version: string }>;
192
+ unchanged: number;
193
+ refused: ForwardRouteRefusal[];
194
+ };
195
+
196
+ /** Insert missing routes and refresh stale pins for the given plan rows. */
197
+ export function planForwardRouteProvisioning(input: {
198
+ requests: readonly ForwardRouteRequest[];
199
+ monitorFacts: ReadonlyMap<string, LegacyRequiredMonitorFacts | null>;
200
+ existing: readonly ForwardRouteRow[];
201
+ destination: string;
202
+ disposition: string;
203
+ }): ForwardRoutePlan {
204
+ const plan: ForwardRoutePlan = {
205
+ inserts: [],
206
+ refreshes: [],
207
+ unchanged: 0,
208
+ refused: [],
209
+ };
210
+ const existingByKey = new Map(
211
+ input.existing.map((route) => [forwardRouteKey(route), route]),
212
+ );
213
+ const accountsByMonitor = new Map<string, Set<string>>();
214
+ for (const route of input.existing) {
215
+ const accounts = accountsByMonitor.get(route.monitor_id) ?? new Set();
216
+ accounts.add(route.account_id);
217
+ accountsByMonitor.set(route.monitor_id, accounts);
218
+ }
219
+ // The same unrouted monitor planned for two accounts is the same conflict
220
+ // as one already routed elsewhere: refuse every such row, never pick one.
221
+ const plannedAccountsByMonitor = new Map<string, Set<string>>();
222
+ for (const request of input.requests) {
223
+ const accounts =
224
+ plannedAccountsByMonitor.get(request.monitorKey) ?? new Set();
225
+ accounts.add(request.accountId);
226
+ plannedAccountsByMonitor.set(request.monitorKey, accounts);
227
+ }
228
+ const seen = new Set<string>();
229
+ for (const request of input.requests) {
230
+ const refuse = (reason: string) =>
231
+ plan.refused.push({
232
+ monitorKey: request.monitorKey,
233
+ accountId: request.accountId,
234
+ reason,
235
+ });
236
+ if (!isSalesforceId18(request.accountId)) {
237
+ refuse('account_id_not_salesforce_18');
238
+ continue;
239
+ }
240
+ const key = forwardRouteKey({
241
+ monitor_id: request.monitorKey,
242
+ account_id: request.accountId,
243
+ });
244
+ if (seen.has(key)) {
245
+ refuse('duplicate_plan_row');
246
+ continue;
247
+ }
248
+ seen.add(key);
249
+ const facts = input.monitorFacts.get(request.monitorKey) ?? null;
250
+ const refusal = monitorRefusal(facts);
251
+ if (refusal) {
252
+ refuse(refusal);
253
+ continue;
254
+ }
255
+ const bindingVersion = facts!.bindingVersion!;
256
+ const existing = existingByKey.get(key);
257
+ if (existing) {
258
+ if (existing.binding_version === bindingVersion) plan.unchanged += 1;
259
+ else
260
+ plan.refreshes.push({
261
+ ...existing,
262
+ binding_version: bindingVersion,
263
+ previous_binding_version: existing.binding_version,
264
+ });
265
+ continue;
266
+ }
267
+ // A monitor already forwarding to another account is a mapping conflict
268
+ // for a human, never a second destination added silently.
269
+ if ((accountsByMonitor.get(request.monitorKey)?.size ?? 0) > 0) {
270
+ refuse('monitor_routed_to_other_account');
271
+ continue;
272
+ }
273
+ if ((plannedAccountsByMonitor.get(request.monitorKey)?.size ?? 0) > 1) {
274
+ refuse('monitor_planned_for_multiple_accounts');
275
+ continue;
276
+ }
277
+ const subject = forwardRouteSubject(facts!.payload);
278
+ if (!subject) {
279
+ refuse('monitor_payload_has_no_radar_type');
280
+ continue;
281
+ }
282
+ const routeDomain = normalizeRouteDomain(request.routeDomain);
283
+ if (!routeDomain) {
284
+ refuse('route_domain_missing');
285
+ continue;
286
+ }
287
+ if (
288
+ subject.domain &&
289
+ normalizeRouteDomain(subject.domain) !== routeDomain
290
+ ) {
291
+ refuse('route_domain_differs_from_monitor');
292
+ continue;
293
+ }
294
+ // The forwarder holds every finding whose route's custom fields do not
295
+ // attribute it to the route's own account.
296
+ if (request.customFields['Account Long ID'] !== request.accountId) {
297
+ refuse('custom_fields_account_long_id_mismatch');
298
+ continue;
299
+ }
300
+ plan.inserts.push({
301
+ monitor_id: request.monitorKey,
302
+ binding_version: bindingVersion,
303
+ ...subject,
304
+ domain: subject.domain || routeDomain,
305
+ account_id: request.accountId,
306
+ destination: input.destination,
307
+ deactivated_at: request.deactivatedAt,
308
+ disposition: input.disposition,
309
+ custom_fields: request.customFields,
310
+ enabled: true,
311
+ });
312
+ }
313
+ return plan;
314
+ }
315
+
316
+ /** Refresh the pin of every enabled route whose monitor was re-bound. */
317
+ export function planStaleForwardRoutePins(input: {
318
+ existing: readonly ForwardRouteRow[];
319
+ monitorFacts: ReadonlyMap<string, LegacyRequiredMonitorFacts | null>;
320
+ }): ForwardRoutePlan {
321
+ const plan: ForwardRoutePlan = {
322
+ inserts: [],
323
+ refreshes: [],
324
+ unchanged: 0,
325
+ refused: [],
326
+ };
327
+ for (const route of input.existing) {
328
+ if (!route.enabled) continue;
329
+ const facts = input.monitorFacts.get(route.monitor_id) ?? null;
330
+ const refusal = monitorRefusal(facts);
331
+ if (refusal) {
332
+ plan.refused.push({
333
+ monitorKey: route.monitor_id,
334
+ accountId: route.account_id,
335
+ reason: refusal,
336
+ });
337
+ continue;
338
+ }
339
+ if (route.binding_version === facts!.bindingVersion) {
340
+ plan.unchanged += 1;
341
+ continue;
342
+ }
343
+ plan.refreshes.push({
344
+ ...route,
345
+ binding_version: facts!.bindingVersion!,
346
+ previous_binding_version: route.binding_version,
347
+ });
348
+ }
349
+ return plan;
350
+ }
351
+
352
+ function rowValues(row: ForwardRouteRow): string {
353
+ return `(${[
354
+ sqlTextLiteral(row.monitor_id),
355
+ sqlTextLiteral(row.binding_version),
356
+ sqlTextLiteral(row.radar_type),
357
+ sqlTextLiteral(row.domain),
358
+ sqlNullableText(row.profile_url),
359
+ sqlTextLiteral(row.account_id),
360
+ sqlTextLiteral(row.destination),
361
+ sqlTextLiteral(row.deactivated_at),
362
+ sqlTextLiteral(row.disposition),
363
+ `${sqlTextLiteral(JSON.stringify(row.custom_fields))}::jsonb`,
364
+ row.enabled ? 'true' : 'false',
365
+ ].join(', ')})`;
366
+ }
367
+
368
+ /**
369
+ * One transaction per batch. A conflicting row only ever gets its
370
+ * `binding_version` replaced, and only when it differs.
371
+ */
372
+ export function buildForwardRouteUpsertSql(
373
+ table: string,
374
+ rows: readonly ForwardRouteRow[],
375
+ ): string {
376
+ assertForwardRouteTable(table);
377
+ if (rows.length === 0 || rows.length > FORWARD_ROUTE_WRITE_BATCH) {
378
+ throw new Error(
379
+ `A route write batch holds 1..${FORWARD_ROUTE_WRITE_BATCH} rows.`,
380
+ );
381
+ }
382
+ return [
383
+ 'BEGIN;',
384
+ `INSERT INTO ${table} (${FORWARD_ROUTE_COLUMNS.join(', ')}) VALUES`,
385
+ rows.map(rowValues).join(',\n'),
386
+ 'ON CONFLICT (monitor_id, account_id) DO UPDATE',
387
+ 'SET binding_version = EXCLUDED.binding_version',
388
+ `WHERE ${table}.binding_version <> EXCLUDED.binding_version;`,
389
+ 'COMMIT;',
390
+ ].join('\n');
391
+ }
392
+
393
+ /**
394
+ * Every write statement for `rows`, each within both the row cap and the SQL
395
+ * endpoint's length limit. Built in full before any write, so an unwritable
396
+ * plan fails before its first batch commits; a single row too long to send
397
+ * alone is an error.
398
+ */
399
+ export function buildForwardRouteUpsertBatches(
400
+ table: string,
401
+ rows: readonly ForwardRouteRow[],
402
+ maxLength: number = ADMIN_CUSTOMER_DB_SQL_MAX_LENGTH,
403
+ ): string[] {
404
+ const batches: string[] = [];
405
+ let batch: ForwardRouteRow[] = [];
406
+ const flush = () => {
407
+ if (batch.length) batches.push(buildForwardRouteUpsertSql(table, batch));
408
+ batch = [];
409
+ };
410
+ for (const row of rows) {
411
+ if (buildForwardRouteUpsertSql(table, [row]).length > maxLength) {
412
+ throw new Error(
413
+ `Route ${forwardRouteKey(row)} alone exceeds the ${maxLength}-character SQL limit.`,
414
+ );
415
+ }
416
+ const next = [...batch, row];
417
+ if (
418
+ next.length > FORWARD_ROUTE_WRITE_BATCH ||
419
+ buildForwardRouteUpsertSql(table, next).length > maxLength
420
+ ) {
421
+ flush();
422
+ batch = [row];
423
+ } else {
424
+ batch = next;
425
+ }
426
+ }
427
+ flush();
428
+ return batches;
429
+ }
430
+
431
+ /** One keyset page of the whole table, inside a READ ONLY transaction. */
432
+ export function buildForwardRoutePageSql(
433
+ table: string,
434
+ after: ForwardRouteKey | null,
435
+ ): string {
436
+ assertForwardRouteTable(table);
437
+ const where = after
438
+ ? `WHERE (monitor_id, account_id) > (${sqlTextLiteral(after.monitor_id)}, ${sqlTextLiteral(after.account_id)})`
439
+ : '';
440
+ return [
441
+ 'BEGIN TRANSACTION READ ONLY;',
442
+ `SELECT ${FORWARD_ROUTE_COLUMNS.join(', ')} FROM ${table} ${where}`.trim(),
443
+ `ORDER BY monitor_id, account_id LIMIT ${FORWARD_ROUTE_READ_PAGE};`,
444
+ 'COMMIT;',
445
+ ].join('\n');
446
+ }
447
+
448
+ /** Parse one SELECT row; a malformed row is an error, never skipped. */
449
+ export function parseForwardRouteRow(raw: unknown): ForwardRouteRow {
450
+ const row =
451
+ raw && typeof raw === 'object' && !Array.isArray(raw)
452
+ ? (raw as Record<string, unknown>)
453
+ : null;
454
+ const required = (name: string) => {
455
+ const value = row?.[name];
456
+ if (typeof value !== 'string') {
457
+ throw new Error(`Route row column ${name} is not text.`);
458
+ }
459
+ return value;
460
+ };
461
+ let customFields = row?.custom_fields;
462
+ if (typeof customFields === 'string') customFields = JSON.parse(customFields);
463
+ if (
464
+ !customFields ||
465
+ typeof customFields !== 'object' ||
466
+ Array.isArray(customFields)
467
+ ) {
468
+ throw new Error('Route row column custom_fields is not a JSON object.');
469
+ }
470
+ if (typeof row?.enabled !== 'boolean') {
471
+ throw new Error('Route row column enabled is not boolean.');
472
+ }
473
+ const profileUrl = row.profile_url;
474
+ if (profileUrl !== null && typeof profileUrl !== 'string') {
475
+ throw new Error('Route row column profile_url is not text.');
476
+ }
477
+ return {
478
+ monitor_id: required('monitor_id'),
479
+ binding_version: required('binding_version'),
480
+ radar_type: required('radar_type'),
481
+ domain: required('domain'),
482
+ profile_url: profileUrl,
483
+ account_id: required('account_id'),
484
+ destination: required('destination'),
485
+ deactivated_at: required('deactivated_at'),
486
+ disposition: required('disposition'),
487
+ custom_fields: customFields as Record<string, unknown>,
488
+ enabled: row.enabled,
489
+ };
490
+ }
491
+
492
+ /** Every planned row must read back exactly as planned. */
493
+ export function forwardRouteReadbackMismatches(input: {
494
+ planned: readonly ForwardRouteRow[];
495
+ actual: readonly ForwardRouteRow[];
496
+ }): string[] {
497
+ const actual = new Map(
498
+ input.actual.map((row) => [forwardRouteKey(row), row]),
499
+ );
500
+ const mismatches: string[] = [];
501
+ for (const planned of input.planned) {
502
+ const key = forwardRouteKey(planned);
503
+ const row = actual.get(key);
504
+ if (!row) {
505
+ mismatches.push(`${key}: missing`);
506
+ continue;
507
+ }
508
+ for (const column of FORWARD_ROUTE_COLUMNS) {
509
+ const left =
510
+ column === 'custom_fields'
511
+ ? stableJson(planned.custom_fields)
512
+ : planned[column];
513
+ const right =
514
+ column === 'custom_fields'
515
+ ? stableJson(row.custom_fields)
516
+ : row[column];
517
+ if (left !== right) mismatches.push(`${key}: ${column} differs`);
518
+ }
519
+ }
520
+ return mismatches;
521
+ }
522
+
523
+ /**
524
+ * Plan and, unless `dryRun`, write forward routes. The dry run and the real
525
+ * run share every step up to the first write: the same plan, the same
526
+ * refusals and the same write batches (built even when dry, so a plan that
527
+ * cannot be written fails there). The real run then requires every planned
528
+ * row to read back exactly.
529
+ */
530
+ export async function runForwardRoutePlan(input: {
531
+ table: string;
532
+ requests: readonly ForwardRouteRequest[] | null;
533
+ monitorFacts: ReadonlyMap<string, LegacyRequiredMonitorFacts | null>;
534
+ existing: readonly ForwardRouteRow[];
535
+ destination: string;
536
+ disposition: string;
537
+ dryRun: boolean;
538
+ runSql: (sql: string) => Promise<unknown>;
539
+ readRoutes: () => Promise<readonly ForwardRouteRow[]>;
540
+ }): Promise<{ plan: ForwardRoutePlan; writeBatches: number; written: number }> {
541
+ const plan = input.requests
542
+ ? planForwardRouteProvisioning({
543
+ requests: input.requests,
544
+ monitorFacts: input.monitorFacts,
545
+ existing: input.existing,
546
+ destination: input.destination,
547
+ disposition: input.disposition,
548
+ })
549
+ : planStaleForwardRoutePins({
550
+ existing: input.existing,
551
+ monitorFacts: input.monitorFacts,
552
+ });
553
+ const planned = [...plan.inserts, ...plan.refreshes];
554
+ const batches = buildForwardRouteUpsertBatches(input.table, planned);
555
+ if (input.dryRun || planned.length === 0) {
556
+ return { plan, writeBatches: batches.length, written: 0 };
557
+ }
558
+ for (const sql of batches) await input.runSql(sql);
559
+ const mismatches = forwardRouteReadbackMismatches({
560
+ planned,
561
+ actual: [...(await input.readRoutes())],
562
+ });
563
+ if (mismatches.length) {
564
+ throw new Error(
565
+ `Forward-route readback did not match ${mismatches.length} planned row(s): ${mismatches.slice(0, 20).join('; ')}`,
566
+ );
567
+ }
568
+ return { plan, writeBatches: batches.length, written: planned.length };
569
+ }