@crowdsource.you/core 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (339) hide show
  1. package/README.md +571 -0
  2. package/dist/cases.d.ts +66 -0
  3. package/dist/cases.d.ts.map +1 -0
  4. package/dist/cases.js +79 -0
  5. package/dist/cases.js.map +1 -0
  6. package/dist/client.d.ts +90 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +125 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/communityNotes.d.ts +49 -0
  11. package/dist/communityNotes.d.ts.map +1 -0
  12. package/dist/communityNotes.js +104 -0
  13. package/dist/communityNotes.js.map +1 -0
  14. package/dist/credential.d.ts +58 -0
  15. package/dist/credential.d.ts.map +1 -0
  16. package/dist/credential.js +71 -0
  17. package/dist/credential.js.map +1 -0
  18. package/dist/defaults.d.ts +61 -0
  19. package/dist/defaults.d.ts.map +1 -0
  20. package/dist/defaults.js +82 -0
  21. package/dist/defaults.js.map +1 -0
  22. package/dist/digest.d.ts +44 -0
  23. package/dist/digest.d.ts.map +1 -0
  24. package/dist/digest.js +79 -0
  25. package/dist/digest.js.map +1 -0
  26. package/dist/envelope.d.ts +143 -0
  27. package/dist/envelope.d.ts.map +1 -0
  28. package/dist/envelope.js +258 -0
  29. package/dist/envelope.js.map +1 -0
  30. package/dist/errors.d.ts +90 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +151 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/esm/cases.js +74 -0
  35. package/dist/esm/cases.js.map +1 -0
  36. package/dist/esm/client.js +121 -0
  37. package/dist/esm/client.js.map +1 -0
  38. package/dist/esm/communityNotes.js +100 -0
  39. package/dist/esm/communityNotes.js.map +1 -0
  40. package/dist/esm/credential.js +67 -0
  41. package/dist/esm/credential.js.map +1 -0
  42. package/dist/esm/defaults.js +77 -0
  43. package/dist/esm/defaults.js.map +1 -0
  44. package/dist/esm/digest.js +74 -0
  45. package/dist/esm/digest.js.map +1 -0
  46. package/dist/esm/envelope.js +252 -0
  47. package/dist/esm/envelope.js.map +1 -0
  48. package/dist/esm/errors.js +140 -0
  49. package/dist/esm/errors.js.map +1 -0
  50. package/dist/esm/express/index.js +29 -0
  51. package/dist/esm/express/index.js.map +1 -0
  52. package/dist/esm/express/middleware.js +293 -0
  53. package/dist/esm/express/middleware.js.map +1 -0
  54. package/dist/esm/express/store.js +69 -0
  55. package/dist/esm/express/store.js.map +1 -0
  56. package/dist/esm/express/verify.js +133 -0
  57. package/dist/esm/express/verify.js.map +1 -0
  58. package/dist/esm/index.js +57 -0
  59. package/dist/esm/index.js.map +1 -0
  60. package/dist/esm/outbox/client.js +43 -0
  61. package/dist/esm/outbox/client.js.map +1 -0
  62. package/dist/esm/outbox/decision.js +153 -0
  63. package/dist/esm/outbox/decision.js.map +1 -0
  64. package/dist/esm/outbox/delivery.js +129 -0
  65. package/dist/esm/outbox/delivery.js.map +1 -0
  66. package/dist/esm/outbox/enforcement/executor.js +219 -0
  67. package/dist/esm/outbox/enforcement/executor.js.map +1 -0
  68. package/dist/esm/outbox/enforcement/planner.js +326 -0
  69. package/dist/esm/outbox/enforcement/planner.js.map +1 -0
  70. package/dist/esm/outbox/evidence.js +173 -0
  71. package/dist/esm/outbox/evidence.js.map +1 -0
  72. package/dist/esm/outbox/inbound.js +87 -0
  73. package/dist/esm/outbox/inbound.js.map +1 -0
  74. package/dist/esm/outbox/index.js +71 -0
  75. package/dist/esm/outbox/index.js.map +1 -0
  76. package/dist/esm/outbox/intake.js +140 -0
  77. package/dist/esm/outbox/intake.js.map +1 -0
  78. package/dist/esm/outbox/integration.js +116 -0
  79. package/dist/esm/outbox/integration.js.map +1 -0
  80. package/dist/esm/outbox/outbox/dispatcher.js +114 -0
  81. package/dist/esm/outbox/outbox/dispatcher.js.map +1 -0
  82. package/dist/esm/outbox/outbox/service.js +271 -0
  83. package/dist/esm/outbox/outbox/service.js.map +1 -0
  84. package/dist/esm/outbox/postgres/index.js +41 -0
  85. package/dist/esm/outbox/postgres/index.js.map +1 -0
  86. package/dist/esm/outbox/postgres/registries.js +155 -0
  87. package/dist/esm/outbox/postgres/registries.js.map +1 -0
  88. package/dist/esm/outbox/postgres/reportColumns.js +191 -0
  89. package/dist/esm/outbox/postgres/reportColumns.js.map +1 -0
  90. package/dist/esm/outbox/postgres/store/enforcement.js +158 -0
  91. package/dist/esm/outbox/postgres/store/enforcement.js.map +1 -0
  92. package/dist/esm/outbox/postgres/store/events.js +101 -0
  93. package/dist/esm/outbox/postgres/store/events.js.map +1 -0
  94. package/dist/esm/outbox/postgres/store/index.js +59 -0
  95. package/dist/esm/outbox/postgres/store/index.js.map +1 -0
  96. package/dist/esm/outbox/postgres/store/outbox.js +207 -0
  97. package/dist/esm/outbox/postgres/store/outbox.js.map +1 -0
  98. package/dist/esm/outbox/postgres/store/reports.js +231 -0
  99. package/dist/esm/outbox/postgres/store/reports.js.map +1 -0
  100. package/dist/esm/outbox/postgres/store/transaction.js +29 -0
  101. package/dist/esm/outbox/postgres/store/transaction.js.map +1 -0
  102. package/dist/esm/outbox/postgres/tables.js +270 -0
  103. package/dist/esm/outbox/postgres/tables.js.map +1 -0
  104. package/dist/esm/outbox/reconciliation.js +157 -0
  105. package/dist/esm/outbox/reconciliation.js.map +1 -0
  106. package/dist/esm/outbox/reportStatus.js +22 -0
  107. package/dist/esm/outbox/reportStatus.js.map +1 -0
  108. package/dist/esm/outbox/retention.js +33 -0
  109. package/dist/esm/outbox/retention.js.map +1 -0
  110. package/dist/esm/outbox/store/types.js +30 -0
  111. package/dist/esm/outbox/store/types.js.map +1 -0
  112. package/dist/esm/outbox/types.js +23 -0
  113. package/dist/esm/outbox/types.js.map +1 -0
  114. package/dist/esm/outbox/webhook.js +145 -0
  115. package/dist/esm/outbox/webhook.js.map +1 -0
  116. package/dist/esm/package.json +1 -0
  117. package/dist/esm/reports.js +74 -0
  118. package/dist/esm/reports.js.map +1 -0
  119. package/dist/esm/testing/fixtures.js +132 -0
  120. package/dist/esm/testing/fixtures.js.map +1 -0
  121. package/dist/esm/testing/index.js +33 -0
  122. package/dist/esm/testing/index.js.map +1 -0
  123. package/dist/esm/testing/sandbox.js +337 -0
  124. package/dist/esm/testing/sandbox.js.map +1 -0
  125. package/dist/esm/testing/webhook-simulator.js +93 -0
  126. package/dist/esm/testing/webhook-simulator.js.map +1 -0
  127. package/dist/esm/transport.js +177 -0
  128. package/dist/esm/transport.js.map +1 -0
  129. package/dist/esm/webhookEndpoints.js +139 -0
  130. package/dist/esm/webhookEndpoints.js.map +1 -0
  131. package/dist/express/index.d.ts +32 -0
  132. package/dist/express/index.d.ts.map +1 -0
  133. package/dist/express/index.js +40 -0
  134. package/dist/express/index.js.map +1 -0
  135. package/dist/express/middleware.d.ts +97 -0
  136. package/dist/express/middleware.d.ts.map +1 -0
  137. package/dist/express/middleware.js +298 -0
  138. package/dist/express/middleware.js.map +1 -0
  139. package/dist/express/store.d.ts +45 -0
  140. package/dist/express/store.d.ts.map +1 -0
  141. package/dist/express/store.js +72 -0
  142. package/dist/express/store.js.map +1 -0
  143. package/dist/express/verify.d.ts +86 -0
  144. package/dist/express/verify.d.ts.map +1 -0
  145. package/dist/express/verify.js +138 -0
  146. package/dist/express/verify.js.map +1 -0
  147. package/dist/index.d.ts +67 -0
  148. package/dist/index.d.ts.map +1 -0
  149. package/dist/index.js +89 -0
  150. package/dist/index.js.map +1 -0
  151. package/dist/outbox/client.d.ts +35 -0
  152. package/dist/outbox/client.d.ts.map +1 -0
  153. package/dist/outbox/client.js +46 -0
  154. package/dist/outbox/client.js.map +1 -0
  155. package/dist/outbox/decision.d.ts +36 -0
  156. package/dist/outbox/decision.d.ts.map +1 -0
  157. package/dist/outbox/decision.js +159 -0
  158. package/dist/outbox/decision.js.map +1 -0
  159. package/dist/outbox/delivery.d.ts +50 -0
  160. package/dist/outbox/delivery.d.ts.map +1 -0
  161. package/dist/outbox/delivery.js +135 -0
  162. package/dist/outbox/delivery.js.map +1 -0
  163. package/dist/outbox/enforcement/executor.d.ts +26 -0
  164. package/dist/outbox/enforcement/executor.d.ts.map +1 -0
  165. package/dist/outbox/enforcement/executor.js +222 -0
  166. package/dist/outbox/enforcement/executor.js.map +1 -0
  167. package/dist/outbox/enforcement/planner.d.ts +50 -0
  168. package/dist/outbox/enforcement/planner.d.ts.map +1 -0
  169. package/dist/outbox/enforcement/planner.js +333 -0
  170. package/dist/outbox/enforcement/planner.js.map +1 -0
  171. package/dist/outbox/evidence.d.ts +114 -0
  172. package/dist/outbox/evidence.d.ts.map +1 -0
  173. package/dist/outbox/evidence.js +181 -0
  174. package/dist/outbox/evidence.js.map +1 -0
  175. package/dist/outbox/inbound.d.ts +78 -0
  176. package/dist/outbox/inbound.d.ts.map +1 -0
  177. package/dist/outbox/inbound.js +91 -0
  178. package/dist/outbox/inbound.js.map +1 -0
  179. package/dist/outbox/index.d.ts +79 -0
  180. package/dist/outbox/index.d.ts.map +1 -0
  181. package/dist/outbox/index.js +98 -0
  182. package/dist/outbox/index.js.map +1 -0
  183. package/dist/outbox/intake.d.ts +62 -0
  184. package/dist/outbox/intake.d.ts.map +1 -0
  185. package/dist/outbox/intake.js +145 -0
  186. package/dist/outbox/intake.js.map +1 -0
  187. package/dist/outbox/integration.d.ts +62 -0
  188. package/dist/outbox/integration.d.ts.map +1 -0
  189. package/dist/outbox/integration.js +119 -0
  190. package/dist/outbox/integration.js.map +1 -0
  191. package/dist/outbox/outbox/dispatcher.d.ts +26 -0
  192. package/dist/outbox/outbox/dispatcher.d.ts.map +1 -0
  193. package/dist/outbox/outbox/dispatcher.js +119 -0
  194. package/dist/outbox/outbox/dispatcher.js.map +1 -0
  195. package/dist/outbox/outbox/service.d.ts +124 -0
  196. package/dist/outbox/outbox/service.d.ts.map +1 -0
  197. package/dist/outbox/outbox/service.js +279 -0
  198. package/dist/outbox/outbox/service.js.map +1 -0
  199. package/dist/outbox/postgres/index.d.ts +44 -0
  200. package/dist/outbox/postgres/index.d.ts.map +1 -0
  201. package/dist/outbox/postgres/index.js +51 -0
  202. package/dist/outbox/postgres/index.js.map +1 -0
  203. package/dist/outbox/postgres/registries.d.ts +69 -0
  204. package/dist/outbox/postgres/registries.d.ts.map +1 -0
  205. package/dist/outbox/postgres/registries.js +159 -0
  206. package/dist/outbox/postgres/registries.js.map +1 -0
  207. package/dist/outbox/postgres/reportColumns.d.ts +154 -0
  208. package/dist/outbox/postgres/reportColumns.d.ts.map +1 -0
  209. package/dist/outbox/postgres/reportColumns.js +195 -0
  210. package/dist/outbox/postgres/reportColumns.js.map +1 -0
  211. package/dist/outbox/postgres/store/enforcement.d.ts +33 -0
  212. package/dist/outbox/postgres/store/enforcement.d.ts.map +1 -0
  213. package/dist/outbox/postgres/store/enforcement.js +161 -0
  214. package/dist/outbox/postgres/store/enforcement.js.map +1 -0
  215. package/dist/outbox/postgres/store/events.d.ts +46 -0
  216. package/dist/outbox/postgres/store/events.d.ts.map +1 -0
  217. package/dist/outbox/postgres/store/events.js +104 -0
  218. package/dist/outbox/postgres/store/events.js.map +1 -0
  219. package/dist/outbox/postgres/store/index.d.ts +27 -0
  220. package/dist/outbox/postgres/store/index.d.ts.map +1 -0
  221. package/dist/outbox/postgres/store/index.js +62 -0
  222. package/dist/outbox/postgres/store/index.js.map +1 -0
  223. package/dist/outbox/postgres/store/outbox.d.ts +8 -0
  224. package/dist/outbox/postgres/store/outbox.d.ts.map +1 -0
  225. package/dist/outbox/postgres/store/outbox.js +210 -0
  226. package/dist/outbox/postgres/store/outbox.js.map +1 -0
  227. package/dist/outbox/postgres/store/reports.d.ts +69 -0
  228. package/dist/outbox/postgres/store/reports.d.ts.map +1 -0
  229. package/dist/outbox/postgres/store/reports.js +234 -0
  230. package/dist/outbox/postgres/store/reports.js.map +1 -0
  231. package/dist/outbox/postgres/store/transaction.d.ts +49 -0
  232. package/dist/outbox/postgres/store/transaction.d.ts.map +1 -0
  233. package/dist/outbox/postgres/store/transaction.js +32 -0
  234. package/dist/outbox/postgres/store/transaction.js.map +1 -0
  235. package/dist/outbox/postgres/tables.d.ts +737 -0
  236. package/dist/outbox/postgres/tables.d.ts.map +1 -0
  237. package/dist/outbox/postgres/tables.js +273 -0
  238. package/dist/outbox/postgres/tables.js.map +1 -0
  239. package/dist/outbox/reconciliation.d.ts +49 -0
  240. package/dist/outbox/reconciliation.d.ts.map +1 -0
  241. package/dist/outbox/reconciliation.js +162 -0
  242. package/dist/outbox/reconciliation.js.map +1 -0
  243. package/dist/outbox/reportStatus.d.ts +12 -0
  244. package/dist/outbox/reportStatus.d.ts.map +1 -0
  245. package/dist/outbox/reportStatus.js +25 -0
  246. package/dist/outbox/reportStatus.js.map +1 -0
  247. package/dist/outbox/retention.d.ts +33 -0
  248. package/dist/outbox/retention.d.ts.map +1 -0
  249. package/dist/outbox/retention.js +36 -0
  250. package/dist/outbox/retention.js.map +1 -0
  251. package/dist/outbox/store/types.d.ts +466 -0
  252. package/dist/outbox/store/types.d.ts.map +1 -0
  253. package/dist/outbox/store/types.js +31 -0
  254. package/dist/outbox/store/types.js.map +1 -0
  255. package/dist/outbox/types.d.ts +660 -0
  256. package/dist/outbox/types.d.ts.map +1 -0
  257. package/dist/outbox/types.js +24 -0
  258. package/dist/outbox/types.js.map +1 -0
  259. package/dist/outbox/webhook.d.ts +15 -0
  260. package/dist/outbox/webhook.d.ts.map +1 -0
  261. package/dist/outbox/webhook.js +148 -0
  262. package/dist/outbox/webhook.js.map +1 -0
  263. package/dist/reports.d.ts +73 -0
  264. package/dist/reports.d.ts.map +1 -0
  265. package/dist/reports.js +78 -0
  266. package/dist/reports.js.map +1 -0
  267. package/dist/testing/fixtures.d.ts +62 -0
  268. package/dist/testing/fixtures.d.ts.map +1 -0
  269. package/dist/testing/fixtures.js +137 -0
  270. package/dist/testing/fixtures.js.map +1 -0
  271. package/dist/testing/index.d.ts +36 -0
  272. package/dist/testing/index.d.ts.map +1 -0
  273. package/dist/testing/index.js +43 -0
  274. package/dist/testing/index.js.map +1 -0
  275. package/dist/testing/sandbox.d.ts +109 -0
  276. package/dist/testing/sandbox.d.ts.map +1 -0
  277. package/dist/testing/sandbox.js +342 -0
  278. package/dist/testing/sandbox.js.map +1 -0
  279. package/dist/testing/webhook-simulator.d.ts +78 -0
  280. package/dist/testing/webhook-simulator.d.ts.map +1 -0
  281. package/dist/testing/webhook-simulator.js +98 -0
  282. package/dist/testing/webhook-simulator.js.map +1 -0
  283. package/dist/transport.d.ts +59 -0
  284. package/dist/transport.d.ts.map +1 -0
  285. package/dist/transport.js +181 -0
  286. package/dist/transport.js.map +1 -0
  287. package/dist/webhookEndpoints.d.ts +147 -0
  288. package/dist/webhookEndpoints.d.ts.map +1 -0
  289. package/dist/webhookEndpoints.js +143 -0
  290. package/dist/webhookEndpoints.js.map +1 -0
  291. package/package.json +127 -0
  292. package/src/cases.ts +119 -0
  293. package/src/client.ts +202 -0
  294. package/src/communityNotes.ts +194 -0
  295. package/src/credential.ts +100 -0
  296. package/src/defaults.ts +87 -0
  297. package/src/digest.ts +91 -0
  298. package/src/envelope.ts +432 -0
  299. package/src/errors.ts +165 -0
  300. package/src/express/index.ts +43 -0
  301. package/src/express/middleware.ts +387 -0
  302. package/src/express/store.ts +85 -0
  303. package/src/express/verify.ts +166 -0
  304. package/src/index.ts +107 -0
  305. package/src/outbox/client.ts +75 -0
  306. package/src/outbox/decision.ts +209 -0
  307. package/src/outbox/delivery.ts +163 -0
  308. package/src/outbox/enforcement/executor.ts +288 -0
  309. package/src/outbox/enforcement/planner.ts +377 -0
  310. package/src/outbox/evidence.ts +231 -0
  311. package/src/outbox/inbound.ts +140 -0
  312. package/src/outbox/index.ts +160 -0
  313. package/src/outbox/intake.ts +175 -0
  314. package/src/outbox/integration.ts +205 -0
  315. package/src/outbox/outbox/dispatcher.ts +131 -0
  316. package/src/outbox/outbox/service.ts +444 -0
  317. package/src/outbox/postgres/index.ts +59 -0
  318. package/src/outbox/postgres/registries.ts +177 -0
  319. package/src/outbox/postgres/reportColumns.ts +281 -0
  320. package/src/outbox/postgres/store/enforcement.ts +186 -0
  321. package/src/outbox/postgres/store/events.ts +112 -0
  322. package/src/outbox/postgres/store/index.ts +72 -0
  323. package/src/outbox/postgres/store/outbox.ts +273 -0
  324. package/src/outbox/postgres/store/reports.ts +349 -0
  325. package/src/outbox/postgres/store/transaction.ts +64 -0
  326. package/src/outbox/postgres/tables.ts +339 -0
  327. package/src/outbox/reconciliation.ts +199 -0
  328. package/src/outbox/reportStatus.ts +24 -0
  329. package/src/outbox/retention.ts +34 -0
  330. package/src/outbox/store/types.ts +528 -0
  331. package/src/outbox/types.ts +753 -0
  332. package/src/outbox/webhook.ts +165 -0
  333. package/src/reports.ts +138 -0
  334. package/src/testing/fixtures.ts +183 -0
  335. package/src/testing/index.ts +57 -0
  336. package/src/testing/sandbox.ts +469 -0
  337. package/src/testing/webhook-simulator.ts +166 -0
  338. package/src/transport.ts +250 -0
  339. package/src/webhookEndpoints.ts +256 -0
@@ -0,0 +1,444 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { MODERATION_OUTBOX_RETENTION_SECONDS } from '../retention.js';
3
+ import type { ModerationOutboxStore } from '../store/types.js';
4
+ import type {
5
+ ModerationDispatchResult,
6
+ ModerationLogger,
7
+ ModerationOutboxEvent,
8
+ ModerationOutboxKind,
9
+ ModerationOutboxPayload,
10
+ ModerationOutboxStatus,
11
+ } from '../types.js';
12
+
13
+ /**
14
+ * Claiming, completing and failing moderation outbox events.
15
+ *
16
+ * The contract is at-least-once: handlers MUST make every downstream effect
17
+ * idempotent using the event `id`, because an expired lease is reclaimable and
18
+ * a worker can die mid-delivery.
19
+ *
20
+ * What differs from an ordinary work queue is where retrying stops. A delivery
21
+ * failure the SDK marks as not retryable is a defect in the payload, not a blip
22
+ * — see {@link OutboxService.fail}.
23
+ *
24
+ * Every decision in this file is a POLICY decision — the retry ceiling, the
25
+ * backoff curve, the lease-length floor, what counts as retryable, how long a
26
+ * row is kept. The queries that carry them out belong to a
27
+ * {@link ModerationOutboxStore}, outside the PostgreSQL query implementation.
28
+ */
29
+
30
+ const DEFAULT_LEASE_MS = 60_000;
31
+ const DEFAULT_BATCH_SIZE = 50;
32
+ const MAX_BATCH_SIZE = 500;
33
+ const MAX_BACKOFF_MS = 6 * 60 * 60 * 1_000;
34
+ const MIN_LEASE_RENEW_INTERVAL_MS = 250;
35
+
36
+ /**
37
+ * Attempts after which a retryable failure is treated as permanent.
38
+ *
39
+ * Generous: a retryable failure means CrowdSource might still accept this exact
40
+ * payload, and with `MAX_BACKOFF_MS` capped at six hours this is several days of
41
+ * trying. A report that has not landed by then needs a human, not another
42
+ * attempt.
43
+ */
44
+ const MAX_RETRYABLE_ATTEMPTS = 25;
45
+
46
+ /**
47
+ * The event id for delivering a report.
48
+ *
49
+ * Derived from the report, not from the request: a transaction retry or two
50
+ * concurrent duplicate submissions upsert the SAME event rather than queueing
51
+ * two deliveries. There is exactly one delivery event per report for the life of
52
+ * the report, which is also what makes the CrowdSource-side idempotency key
53
+ * stable.
54
+ */
55
+ export function reportSubmitEventId(reportId: string): string {
56
+ return `moderation:report.submit:${reportId}`;
57
+ }
58
+
59
+ /**
60
+ * The event id for applying an inbound decision.
61
+ *
62
+ * The webhook event id is the key, so a redelivery of the same event can never
63
+ * queue the work twice even if the dedupe claim were somehow released.
64
+ */
65
+ export function decisionApplyEventId(eventId: string): string {
66
+ return `moderation:decision.apply:${eventId}`;
67
+ }
68
+
69
+ /**
70
+ * Raised when an outbox event is written outside a transaction.
71
+ *
72
+ * Never expected at runtime. It exists so the invariant is ENFORCED rather than
73
+ * reviewed. It is thrown by the STORE — only a backend knows what an open
74
+ * transaction looks like — and it lives here so that every backend throws the
75
+ * same error for the same mistake. See {@link ModerationOutboxStore.enqueue}.
76
+ */
77
+ export class ModerationOutboxTransactionError extends Error {
78
+ constructor(eventId: string) {
79
+ super(
80
+ `Refusing to enqueue moderation outbox event '${eventId}' outside a transaction: ` +
81
+ 'the domain write and this row must commit together, or a report is answered 201 ' +
82
+ 'and never delivered.',
83
+ );
84
+ this.name = 'ModerationOutboxTransactionError';
85
+ }
86
+ }
87
+
88
+ /**
89
+ * A failure that says whether trying the same payload again could ever work.
90
+ *
91
+ * Every error `@crowdsource.you/core` throws carries `retryable`, which is the only
92
+ * thing a delivery worker needs from it. Anything else — a bug in this code, a
93
+ * Mongo error — is treated as retryable, because assuming a defect is permanent
94
+ * is how a recoverable outage becomes lost moderation work.
95
+ */
96
+ export function isRetryableDeliveryError(error: unknown): boolean {
97
+ if (typeof error === 'object' && error !== null && 'retryable' in error) {
98
+ const retryable: unknown = (error as { retryable: unknown }).retryable;
99
+ if (typeof retryable === 'boolean') return retryable;
100
+ }
101
+ return true;
102
+ }
103
+
104
+ export interface ModerationOutboxFailure {
105
+ released: boolean;
106
+ deadLettered: boolean;
107
+ }
108
+
109
+ export type ModerationOutboxHandler = (event: ModerationOutboxEvent) => Promise<void>;
110
+
111
+ interface LeaseHeartbeatResult {
112
+ lost: boolean;
113
+ error?: unknown;
114
+ }
115
+
116
+ function nextAttemptAt(attempts: number, now: Date): Date {
117
+ const exponent = Math.max(0, Math.min(attempts - 1, 20));
118
+ return new Date(now.getTime() + Math.min(1_000 * 2 ** exponent, MAX_BACKOFF_MS));
119
+ }
120
+
121
+ /**
122
+ * Draining the outbox: the one operation that never touches a transaction.
123
+ *
124
+ * Split out so {@link ModerationOutboxDispatcher} — which only ever asks for a
125
+ * batch — does not carry a backend's transaction type through a class that has
126
+ * no use for it.
127
+ */
128
+ export interface OutboxDrain {
129
+ /** Drain up to `batchSize` due events. Bounded, at-least-once, lease-protected. */
130
+ dispatch(options: {
131
+ handler: ModerationOutboxHandler;
132
+ leaseOwner?: string;
133
+ batchSize?: number;
134
+ leaseMs?: number;
135
+ signal?: AbortSignal;
136
+ }): Promise<ModerationDispatchResult>;
137
+ }
138
+
139
+ export interface OutboxService<TTx> extends OutboxDrain {
140
+ /**
141
+ * Write the event in the CALLER's transaction.
142
+ *
143
+ * The transaction is required, not optional. This is the whole point of the
144
+ * collection: the domain write and this row commit together or not at all. An
145
+ * overload that let a caller enqueue outside a transaction would be the one
146
+ * line that quietly reintroduces "the report was answered 201 and then
147
+ * vanished".
148
+ *
149
+ * The type makes the transaction mandatory; the STORE makes it mandatory that
150
+ * the transaction is ACTUALLY OPEN, by throwing
151
+ * {@link ModerationOutboxTransactionError}. A required parameter is satisfied
152
+ * by any handle — including a bare Mongo `startSession()` nobody opened a
153
+ * transaction on, which type-checks perfectly and commits the row on its own.
154
+ * That is the shape of the mistake worth catching: it looks exactly like
155
+ * correct code, it passes any test that only asserts the row exists, and it
156
+ * fails as lost moderation work with no trace on the day something restarts
157
+ * between the two writes.
158
+ *
159
+ * This function is also the ONLY writer of that table — the dispatcher claims
160
+ * existing rows and never creates one — so there is no second queue that can
161
+ * drift out of sync with the outbox. A job here is never the only evidence
162
+ * that work exists, because the row IS the job.
163
+ */
164
+ enqueue(
165
+ input: {
166
+ eventId: string;
167
+ kind: ModerationOutboxKind;
168
+ payload: ModerationOutboxPayload;
169
+ },
170
+ tx: TTx,
171
+ ): Promise<string>;
172
+
173
+ /**
174
+ * Atomically claim one due event. An expired `processing` lease is
175
+ * reclaimable, so a dead worker cannot strand moderation work forever.
176
+ */
177
+ claim(options: {
178
+ leaseOwner: string;
179
+ eventId?: string;
180
+ now?: Date;
181
+ leaseMs?: number;
182
+ }): Promise<ModerationOutboxEvent | null>;
183
+
184
+ /** Complete only the lease this dispatcher currently owns. */
185
+ complete(eventId: string, leaseOwner: string, now?: Date): Promise<boolean>;
186
+
187
+ /** Extend only a live lease still owned by this dispatcher. */
188
+ renew(
189
+ eventId: string,
190
+ leaseOwner: string,
191
+ leaseMs: number,
192
+ now?: Date,
193
+ ): Promise<ModerationOutboxFailure['released']>;
194
+
195
+ /**
196
+ * Release a failed claim, with backoff — or stop.
197
+ *
198
+ * Stopping is not an optimisation. A 409 means this `externalReportId` already
199
+ * exists at CrowdSource with a different body, and no number of retries turns
200
+ * two payloads into one report; a 422 means the envelope is not processable.
201
+ * Both need the payload to change, so they become `dead_letter` immediately
202
+ * and stay visible with their error rather than accumulating attempts nobody
203
+ * reads.
204
+ */
205
+ fail(
206
+ event: Pick<ModerationOutboxEvent, 'id' | 'attempts'>,
207
+ leaseOwner: string,
208
+ error: unknown,
209
+ now?: Date,
210
+ ): Promise<ModerationOutboxFailure>;
211
+
212
+ /** The status of one event, or `null`. Read by reconciliation. */
213
+ statusOf(eventId: string): Promise<ModerationOutboxStatus | null>;
214
+ }
215
+
216
+ export function createOutboxService<TTx>(input: {
217
+ store: ModerationOutboxStore<TTx>;
218
+ logger: ModerationLogger;
219
+ }): OutboxService<TTx> {
220
+ const { store, logger } = input;
221
+
222
+ const renew: OutboxService<TTx>['renew'] = async (
223
+ eventId,
224
+ leaseOwner,
225
+ leaseMs,
226
+ now = new Date(),
227
+ ) =>
228
+ await store.renew({
229
+ eventId,
230
+ leaseOwner,
231
+ // The floor is policy, not storage: a lease shorter than a second is
232
+ // reclaimable before the delivery it protects has begun.
233
+ leaseUntil: new Date(now.getTime() + Math.max(1_000, leaseMs)),
234
+ now,
235
+ });
236
+
237
+ function startLeaseHeartbeat(options: {
238
+ eventId: string;
239
+ leaseOwner: string;
240
+ leaseMs: number;
241
+ }): { stop: () => Promise<LeaseHeartbeatResult> } {
242
+ const renewIntervalMs = Math.max(
243
+ MIN_LEASE_RENEW_INTERVAL_MS,
244
+ Math.floor(options.leaseMs / 3),
245
+ );
246
+ let stopped = false;
247
+ let lost = false;
248
+ let renewalError: unknown;
249
+ let renewalInFlight: Promise<void> | null = null;
250
+
251
+ const tick = (): void => {
252
+ if (stopped || lost || renewalInFlight) return;
253
+ const renewal = renew(options.eventId, options.leaseOwner, options.leaseMs)
254
+ .then((stillOwner) => {
255
+ if (!stillOwner) lost = true;
256
+ })
257
+ .catch((error: unknown) => {
258
+ lost = true;
259
+ renewalError = error;
260
+ })
261
+ .finally(() => {
262
+ if (renewalInFlight === renewal) renewalInFlight = null;
263
+ });
264
+ renewalInFlight = renewal;
265
+ };
266
+
267
+ const timer = setInterval(tick, renewIntervalMs);
268
+ timer.unref?.();
269
+
270
+ return {
271
+ async stop(): Promise<LeaseHeartbeatResult> {
272
+ stopped = true;
273
+ clearInterval(timer);
274
+ await renewalInFlight;
275
+ return { lost, error: renewalError };
276
+ },
277
+ };
278
+ }
279
+
280
+ const claim: OutboxService<TTx>['claim'] = async (options) => {
281
+ const now = options.now ?? new Date();
282
+ const leaseMs = Math.max(1_000, options.leaseMs ?? DEFAULT_LEASE_MS);
283
+ return await store.claim({
284
+ leaseOwner: options.leaseOwner,
285
+ leaseUntil: new Date(now.getTime() + leaseMs),
286
+ now,
287
+ ...(options.eventId === undefined ? {} : { eventId: options.eventId }),
288
+ });
289
+ };
290
+
291
+ const complete: OutboxService<TTx>['complete'] = async (
292
+ eventId,
293
+ leaseOwner,
294
+ now = new Date(),
295
+ ) => await store.complete({ eventId, leaseOwner, now });
296
+
297
+ const fail: OutboxService<TTx>['fail'] = async (
298
+ event,
299
+ leaseOwner,
300
+ error,
301
+ now = new Date(),
302
+ ) => {
303
+ const message = error instanceof Error ? error.message : String(error);
304
+ const retryable = isRetryableDeliveryError(error);
305
+ const deadLettered = !retryable || event.attempts >= MAX_RETRYABLE_ATTEMPTS;
306
+
307
+ const released = await store.fail({
308
+ eventId: event.id,
309
+ leaseOwner,
310
+ status: deadLettered ? 'dead_letter' : 'pending',
311
+ availableAt: deadLettered ? now : nextAttemptAt(event.attempts, now),
312
+ // Bounded here rather than by a column width, so both dialects agree: a
313
+ // Mongoose validator throws on overflow and Postgres errors 22001, and
314
+ // neither is what a failed delivery should turn into.
315
+ lastError: message.slice(0, 2_000),
316
+ now,
317
+ });
318
+ return { released, deadLettered };
319
+ };
320
+
321
+ return {
322
+ async enqueue(enqueueInput, tx) {
323
+ const now = new Date();
324
+ await store.enqueue(
325
+ {
326
+ eventId: enqueueInput.eventId,
327
+ kind: enqueueInput.kind,
328
+ payload: enqueueInput.payload,
329
+ availableAt: now,
330
+ expiresAt: new Date(
331
+ now.getTime() + MODERATION_OUTBOX_RETENTION_SECONDS * 1_000,
332
+ ),
333
+ now,
334
+ },
335
+ tx,
336
+ );
337
+ return enqueueInput.eventId;
338
+ },
339
+
340
+ claim,
341
+ complete,
342
+ renew,
343
+ fail,
344
+
345
+ async statusOf(eventId) {
346
+ return await store.statusOf(eventId);
347
+ },
348
+
349
+ async dispatch(options) {
350
+ const leaseOwner = options.leaseOwner ?? `moderation:${process.pid}:${randomUUID()}`;
351
+ const batchSize = Math.min(
352
+ Math.max(1, options.batchSize ?? DEFAULT_BATCH_SIZE),
353
+ MAX_BATCH_SIZE,
354
+ );
355
+ const leaseMs = Math.max(1_000, options.leaseMs ?? DEFAULT_LEASE_MS);
356
+ let processed = 0;
357
+ let failed = 0;
358
+ let deadLettered = 0;
359
+
360
+ for (let index = 0; index < batchSize; index += 1) {
361
+ // Shutdown stops claiming new work but lets the event already in flight
362
+ // reach a durable state.
363
+ if (options.signal?.aborted) break;
364
+
365
+ const event = await claim({ leaseOwner, leaseMs });
366
+ if (!event) break;
367
+
368
+ const heartbeat = startLeaseHeartbeat({
369
+ eventId: event.id,
370
+ leaseOwner,
371
+ leaseMs,
372
+ });
373
+ let deliveryError: unknown;
374
+ try {
375
+ await options.handler(event);
376
+ } catch (error: unknown) {
377
+ deliveryError = error;
378
+ }
379
+
380
+ // No completion/failure transition may race an owner-checked renewal.
381
+ const heartbeatResult = await heartbeat.stop();
382
+ if (heartbeatResult.lost) {
383
+ failed += 1;
384
+ logger.warn('[ModerationOutbox] event lease lost during delivery', {
385
+ eventId: event.id,
386
+ kind: event.kind,
387
+ attempts: event.attempts,
388
+ error:
389
+ heartbeatResult.error instanceof Error
390
+ ? heartbeatResult.error.message
391
+ : heartbeatResult.error
392
+ ? String(heartbeatResult.error)
393
+ : 'owner or lease expiry changed',
394
+ });
395
+ continue;
396
+ }
397
+
398
+ if (deliveryError) {
399
+ failed += 1;
400
+ const outcome = await fail(event, leaseOwner, deliveryError);
401
+ const context = {
402
+ eventId: event.id,
403
+ kind: event.kind,
404
+ attempts: event.attempts,
405
+ error:
406
+ deliveryError instanceof Error
407
+ ? deliveryError.message
408
+ : String(deliveryError),
409
+ };
410
+ // A dead letter is moderation work that will not happen without a
411
+ // human, so it must not be discoverable only by reading a warn-level
412
+ // log line.
413
+ if (outcome.deadLettered) {
414
+ deadLettered += 1;
415
+ logger.error('[ModerationOutbox] event dead-lettered', context);
416
+ } else {
417
+ logger.warn('[ModerationOutbox] event delivery failed, will retry', context);
418
+ }
419
+ if (!outcome.released) {
420
+ logger.warn('[ModerationOutbox] lease lost before failure release', {
421
+ eventId: event.id,
422
+ kind: event.kind,
423
+ });
424
+ }
425
+ continue;
426
+ }
427
+
428
+ const completed = await complete(event.id, leaseOwner);
429
+ if (!completed) {
430
+ failed += 1;
431
+ logger.warn('[ModerationOutbox] lease lost before completion', {
432
+ eventId: event.id,
433
+ kind: event.kind,
434
+ attempts: event.attempts,
435
+ });
436
+ continue;
437
+ }
438
+ processed += 1;
439
+ }
440
+
441
+ return { processed, failed, deadLettered };
442
+ },
443
+ };
444
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `@crowdsource.you/core/outbox/postgres` — the PostgreSQL half.
3
+ *
4
+ * An adopting application chooses its storage by which subpath it imports, and
5
+ * gets the same moderation pipeline either way. Everything drizzle-shaped lives
6
+ * behind this entry: the three tables this package owns, the columns to spread
7
+ * into your own report table, the two registry fragments to merge into your own
8
+ * gates, and the store the integration is wired with.
9
+ *
10
+ * ```ts
11
+ * import { createModerationIntegration } from '@crowdsource.you/core/outbox';
12
+ * import {
13
+ * moderationTables,
14
+ * moderationReportColumns,
15
+ * moderationReportTableExtras,
16
+ * postgresModerationStore,
17
+ * } from '@crowdsource.you/core/outbox/postgres';
18
+ * ```
19
+ *
20
+ * `drizzle-orm`, `postgres` and `@oxy.so/db` are OPTIONAL peers, which is what this
21
+ * split buys: a deployment on Mongo never installs them, and a bundler never has
22
+ * to resolve them. Importing this subpath without them fails at the import, by
23
+ * name — which is the failure you want, rather than a driver quietly missing at
24
+ * the first write.
25
+ *
26
+ * **This package ships NO migrations.** It ships table DEFINITIONS; your own
27
+ * `drizzle-kit generate` produces the SQL, in your journal. Two journals against
28
+ * one `drizzle.__drizzle_migrations` table interleave, and the loser is skipped
29
+ * silently with exit 0 — so a migrations folder in a library is a way to lose your
30
+ * migration rather than a convenience.
31
+ *
32
+ * The MODERATION_*_RETENTION_SECONDS windows are NOT here. They are policy both
33
+ * backends share, so they stay on the root entry — and `moderationExpirySweepTargets`
34
+ * is what turns them into deletions, because Postgres has no TTL index.
35
+ */
36
+
37
+ export { postgresModerationStore } from './store/index.js';
38
+ export { postgresTransactionRunner } from './store/transaction.js';
39
+ export type { ModerationPgHandle } from './store/transaction.js';
40
+
41
+ export { moderationTables } from './tables.js';
42
+ export type { ModerationTables } from './tables.js';
43
+
44
+ export {
45
+ moderationReportColumns,
46
+ moderationReportTableExtras,
47
+ } from './reportColumns.js';
48
+ export type {
49
+ ModerationReportBuiltColumns,
50
+ ModerationReportColumnName,
51
+ ModerationReportColumnOptions,
52
+ ModerationReportColumns,
53
+ ModerationReportTable,
54
+ } from './reportColumns.js';
55
+
56
+ export {
57
+ moderationExpirySweepTargets,
58
+ moderationIdColumnsWithoutForeignKey,
59
+ } from './registries.js';
@@ -0,0 +1,177 @@
1
+ import { getTableName } from 'drizzle-orm';
2
+ import type { Column } from 'drizzle-orm';
3
+ import type { PgTable } from 'drizzle-orm/pg-core';
4
+ import { sqlColumnName } from '@oxy.so/db';
5
+ import type { ExpirySweepTarget } from '@oxy.so/db/expiry';
6
+ import {
7
+ MODERATION_EVENT_RETENTION_SECONDS,
8
+ MODERATION_OUTBOX_RETENTION_SECONDS,
9
+ } from '../retention.js';
10
+ import type { ModerationReportTable } from './reportColumns.js';
11
+ import type { ModerationTables } from './tables.js';
12
+
13
+ /**
14
+ * The two registry FRAGMENTS an adopter merges into its own gates.
15
+ *
16
+ * `@oxy.so/db` holds the mechanisms — the expiry sweep, the id-column
17
+ * classification gate — and the REGISTRIES belong to the consumer, because they
18
+ * name the consumer's own tables. This package's three tables are in the
19
+ * adopter's database, so the entries for them are the adopter's to declare and
20
+ * this package's to KNOW: it is the only place that can say why
21
+ * `crowdsource_case_id` will never carry a foreign key.
22
+ *
23
+ * So each of these returns a fragment to spread, never a whole registry:
24
+ *
25
+ * ```ts
26
+ * const targets = [...myOwnTargets, ...moderationExpirySweepTargets(moderation)];
27
+ * const violations = findIdColumnViolations({
28
+ * tables: [...myTables, moderation.outbox, moderation.events, moderation.enforcements],
29
+ * deferred: myDeferred,
30
+ * withoutForeignKey: [
31
+ * ...myOwnUnclassified,
32
+ * ...moderationIdColumnsWithoutForeignKey({ tables: moderation, reportTable: reports }),
33
+ * ],
34
+ * minimumTables: …,
35
+ * });
36
+ * ```
37
+ *
38
+ * Shipping them is what stops the first adopter writing these reasons by
39
+ * guessing — and every one of the eight id columns below would otherwise fail
40
+ * that adopter's inherited gate as `unclassified_id_column` on the day it adopts,
41
+ * with nothing to tell it whether that is a missing constraint or a decision.
42
+ */
43
+
44
+ /** `<table>.<column>`, in the SQL names the gate compares against. */
45
+ function describe(table: PgTable, column: Column): string {
46
+ return `${getTableName(table)}.${sqlColumnName(column)}`;
47
+ }
48
+
49
+ /**
50
+ * The two tables that carried a Mongo TTL index, as sweep targets.
51
+ *
52
+ * **A TTL index is a behaviour of the SOURCE that does not survive the port.**
53
+ * Mongo reaps; Postgres does not. Both tables here were declared
54
+ * `expireAfterSeconds: 0` on an `expiresAt` the WRITER computes, so
55
+ * `retentionSeconds` is 0: the column already IS the deadline, and the retention
56
+ * window lives in `src/retention.ts` where both backends read it.
57
+ *
58
+ * Without an entry, a table grows forever — no error, no failing test, no
59
+ * symptom of any kind until disk. Nothing about it is visible in a diff, because
60
+ * the thing doing the work was never in the consumer's code to be missed.
61
+ *
62
+ * ## What sweeping these two costs, stated because a registry entry with no such
63
+ * note reads as "unconditionally safe"
64
+ *
65
+ * The outbox holds unprocessed WORK. A stalled dispatcher plus this sweep
66
+ * discards moderation work that was never delivered — which is why the retention
67
+ * window is ninety days rather than hours, and why a `dead_letter` row is
68
+ * evidence somebody still has to look at rather than a row to reap quickly.
69
+ * Operational alerts must fire long before this deadline; the sweep is the
70
+ * backstop for a table nobody drained, not a queue policy.
71
+ *
72
+ * The event log holds the audit trail of what a third party told this deployment
73
+ * to do, and the dedupe claim. CrowdSource's retry schedule ends at 24 hours, so
74
+ * deleting a row after ninety days cannot resurrect a duplicate delivery; what it
75
+ * does cost is the answer to "did CrowdSource tell us about this case, and when",
76
+ * which is the first question asked when a report looks stuck.
77
+ *
78
+ * Neither read path depends on a swept row already being gone: the dispatcher
79
+ * claims by `available_at` and the receiver claims by primary key, so an
80
+ * unswept row is stale at worst and never unsafe.
81
+ */
82
+ export function moderationExpirySweepTargets(
83
+ tables: ModerationTables,
84
+ ): readonly ExpirySweepTarget[] {
85
+ return [
86
+ {
87
+ table: tables.outbox,
88
+ column: tables.outbox.expiresAt,
89
+ retentionSeconds: 0,
90
+ reason:
91
+ 'Deletes a delivered or dead-lettered moderation outbox event past its ' +
92
+ `${MODERATION_OUTBOX_RETENTION_SECONDS}s retention. A row still pending is ` +
93
+ 'moderation work that was never delivered — alert long before this.',
94
+ },
95
+ {
96
+ table: tables.events,
97
+ column: tables.events.expiresAt,
98
+ retentionSeconds: 0,
99
+ reason:
100
+ 'Deletes the audit row and dedupe claim for an inbound decision past its ' +
101
+ `${MODERATION_EVENT_RETENTION_SECONDS}s retention, long after CrowdSource's ` +
102
+ '24-hour retry schedule can redeliver it.',
103
+ },
104
+ ];
105
+ }
106
+
107
+ /**
108
+ * Every id-shaped column in this package's tables that will never carry a
109
+ * foreign key, with the reason.
110
+ *
111
+ * Eight entries, and the count is worth stating because the obvious count is
112
+ * six: `findIdColumnViolations` exempts a column only when `column.primary` is
113
+ * set, and a composite primary key declared in a table's extra config does NOT
114
+ * set it on its members — so `moderation_enforcements.decision_id` is scanned
115
+ * despite being part of the key.
116
+ *
117
+ * They fall into two kinds, and neither can be constrained:
118
+ *
119
+ * - **Ids in CrowdSource's database.** A decision, a case, a report as CrowdSource
120
+ * knows it. There is no local table to point at; the row lives in another
121
+ * service's store, and this deployment holds a copy of its identifier.
122
+ * - **The adopter's own opaque noun ids.** `subject_id` and `reported_id` name a
123
+ * post, a listing, a message — whichever table the adopter's subject provider
124
+ * resolves. The package cannot know which, and a single column cannot reference
125
+ * several tables, which is precisely why they are stored as opaque text.
126
+ *
127
+ * The report table is passed in rather than assumed: the entries embed the
128
+ * adopter's own table name, and a ledger entry naming a table that is not in the
129
+ * gate's `tables` list is reported as `stale_ledger_entry`.
130
+ */
131
+ export function moderationIdColumnsWithoutForeignKey(input: {
132
+ tables: ModerationTables;
133
+ reportTable: ModerationReportTable;
134
+ }): readonly { column: string; reason: string }[] {
135
+ const { tables, reportTable } = input;
136
+
137
+ return [
138
+ {
139
+ column: describe(tables.events, tables.events.caseId),
140
+ reason: 'Names a case in CrowdSource’s database, not the adopter’s.',
141
+ },
142
+ {
143
+ column: describe(tables.enforcements, tables.enforcements.decisionId),
144
+ reason:
145
+ 'Names a decision in CrowdSource’s database. Part of the composite ' +
146
+ 'primary key, which does not set `column.primary` on its members.',
147
+ },
148
+ {
149
+ column: describe(tables.enforcements, tables.enforcements.caseId),
150
+ reason: 'Names a case in CrowdSource’s database.',
151
+ },
152
+ {
153
+ column: describe(tables.enforcements, tables.enforcements.subjectId),
154
+ reason:
155
+ 'The adopter’s own opaque noun id; the package cannot know which table ' +
156
+ 'it points at, and one column cannot reference several.',
157
+ },
158
+ {
159
+ column: describe(reportTable, reportTable.reportedId),
160
+ reason:
161
+ 'The adopter’s own opaque noun id — same reason as ' +
162
+ '`moderation_enforcements.subject_id`.',
163
+ },
164
+ {
165
+ column: describe(reportTable, reportTable.crowdSourceReportId),
166
+ reason: 'A report id in CrowdSource’s database.',
167
+ },
168
+ {
169
+ column: describe(reportTable, reportTable.crowdSourceCaseId),
170
+ reason: 'A case id in CrowdSource’s database.',
171
+ },
172
+ {
173
+ column: describe(reportTable, reportTable.decisionId),
174
+ reason: 'A decision id in CrowdSource’s database.',
175
+ },
176
+ ];
177
+ }