@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,339 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import {
3
+ boolean,
4
+ check,
5
+ index,
6
+ integer,
7
+ jsonb,
8
+ pgTable,
9
+ primaryKey,
10
+ text,
11
+ varchar,
12
+ } from 'drizzle-orm/pg-core';
13
+ import { createdAt, inList, timestamptz, updatedAt } from '@oxy.so/db';
14
+ import type {
15
+ EnforcementPreviousState,
16
+ ModerationEnforcementMode,
17
+ ModerationOutboxKind,
18
+ ModerationOutboxPayload,
19
+ ModerationOutboxStatus,
20
+ } from '../types.js';
21
+
22
+ /**
23
+ * The three tables this package owns, in Postgres.
24
+ *
25
+ * These names and constraints are stable application data contracts. The
26
+ * reasoning for each field is repeated on the column because it is not carried
27
+ * by the DDL.
28
+ *
29
+ * Three things differ, and each is a property of Postgres rather than a choice:
30
+ *
31
+ * 1. **A closed value set is `text` plus a CHECK built from the same tuple that
32
+ * types it**, never a Postgres `enum` type. Adding an enforcement action
33
+ * becomes a migration where Mongo only needed a restart, which is the correct
34
+ * trade: a stored action outside the declared set is exactly what the
35
+ * constraint exists to refuse.
36
+ * 2. **The TTL indexes have no counterpart.** Postgres does not reap. Each
37
+ * `expires_at` keeps its index — the sweep's predicate needs it — and the
38
+ * reaping itself becomes an entry in the adopter's expiry registry; see
39
+ * `moderationExpirySweepTargets` in `registries.ts`. A table ported without
40
+ * one grows forever, with no error and no failing test.
41
+ * 3. **The enforcement unique index IS the primary key.** Mongo needed a
42
+ * surrogate `_id` plus a unique index on the idempotency triple; here the
43
+ * triple is the primary key, so there is no second object to keep in step.
44
+ *
45
+ * Every column is named EXPLICITLY. Drizzle can derive a snake_case name from
46
+ * the property, and its derivation mangles digit- and capital-adjacent names
47
+ * (`cacheS3Key` becomes `cache_s_3_key` — a working column with a name nobody
48
+ * chose). The SQL name has to match what the adopter's migration created, so it
49
+ * is written down here rather than computed.
50
+ */
51
+
52
+ /**
53
+ * `timestamptz()` from `@oxy.so/db` takes NO name argument, so these column names
54
+ * are DERIVED from the property by `DATABASE_CASING` rather than written out.
55
+ *
56
+ * That is the one exception to naming every column explicitly, and it is the
57
+ * package's decision rather than this one's: the builder's whole point is that
58
+ * "timestamps are `timestamptz`" is decided once, and re-implementing it here
59
+ * with `timestamp(name, { withTimezone: true })` would be a second copy of the
60
+ * rule that could drift. Every derived name in this file is a plain two-word
61
+ * property (`availableAt`, `expiresAt`, …) with no digit and no adjacent capital,
62
+ * so the derivation has nothing to mangle — and `postgresSchema.test.ts` asserts
63
+ * the EXACT column-name set of every table against the real catalogue, which is
64
+ * what would catch a derivation surprise rather than this comment.
65
+ */
66
+
67
+ /**
68
+ * The three table names are kept beside their definitions. The schema tests
69
+ * assert their exact values so a rename cannot strand deployed data.
70
+ */
71
+ const OUTBOX_TABLE = 'moderation_outbox';
72
+ const EVENT_TABLE = 'moderation_events';
73
+ const ENFORCEMENT_TABLE = 'moderation_enforcements';
74
+
75
+ /** The two kinds of work this package enqueues. Types the column and its CHECK. */
76
+ const OUTBOX_KINDS = ['report.submit', 'decision.apply'] as const;
77
+ const OUTBOX_STATUSES = ['pending', 'processing', 'processed', 'dead_letter'] as const;
78
+ const EVENT_STATES = ['claimed', 'queued', 'ignored'] as const;
79
+ const ENFORCEMENT_MODES = [
80
+ 'observe',
81
+ 'manual',
82
+ 'automatic',
83
+ ] as const satisfies readonly ModerationEnforcementMode[];
84
+
85
+ /**
86
+ * Build the three tables.
87
+ *
88
+ * Call this ONCE and export the result. Two calls produce two distinct sets of
89
+ * drizzle table objects for the same SQL names, and drizzle-kit would then see
90
+ * the schema twice.
91
+ *
92
+ * `enforcementActions` is the adopter's own action union. It types the CHECK on
93
+ * `action` and `recorded_as`, which is why the tables are a function rather than
94
+ * module-level constants.
95
+ */
96
+ export function moderationTables(options: { enforcementActions: readonly string[] }) {
97
+ const actions = inList(options.enforcementActions);
98
+
99
+ /**
100
+ * The durable record of moderation work that has to happen but has not
101
+ * happened yet.
102
+ *
103
+ * This table is what makes the intake guarantee true. A 201 from an
104
+ * application's report route means the report and its outbox row committed in
105
+ * ONE transaction — not that a call to CrowdSource succeeded. Delivery is a
106
+ * separate, retried step, and the user is never made to wait for a third party
107
+ * to be reachable.
108
+ *
109
+ * The same shape carries work in the other direction. A decision arriving over
110
+ * a webhook is answered 2xx as soon as it is recorded here, and applied
111
+ * afterwards. Nothing is enqueued that is not already written down: if the
112
+ * dispatcher, the process or the whole task disappears, every pending piece of
113
+ * moderation work is re-derivable by reading this table.
114
+ */
115
+ const outbox = pgTable(
116
+ OUTBOX_TABLE,
117
+ {
118
+ /**
119
+ * The deterministic event id — `moderation:report.submit:<reportId>` or
120
+ * `moderation:decision.apply:<eventId>`.
121
+ *
122
+ * The primary key, and that is the mechanism rather than a convention: it
123
+ * is what makes a repeated enqueue a no-op instead of a second delivery.
124
+ * `text` rather than a generated id, because the WRITER computes it from
125
+ * the report or the inbound event.
126
+ */
127
+ id: text('id').primaryKey(),
128
+ /**
129
+ * `$type` on the closed sets, so a row read back is typed as the union the
130
+ * CHECK already enforces rather than as a bare `string` the store would
131
+ * have to assert. TypeScript-only: it changes no DDL.
132
+ */
133
+ kind: text('kind').$type<ModerationOutboxKind>().notNull(),
134
+ /**
135
+ * The payload, stored whole and opaque.
136
+ *
137
+ * `jsonb` rather than columns: a decision document is deliberately loose,
138
+ * and a projection would silently drop whatever a newer CrowdSource added.
139
+ * It is validated against the published contract when it is READ, so an
140
+ * event is never lost to a schema this deployment has not caught up with.
141
+ */
142
+ payload: jsonb('payload').$type<ModerationOutboxPayload>().notNull(),
143
+ status: text('status').$type<ModerationOutboxStatus>().notNull().default('pending'),
144
+ /** Counted by the claim; the retry ceiling reads it. */
145
+ attempts: integer('attempts').notNull().default(0),
146
+ availableAt: timestamptz().notNull(),
147
+ leaseOwner: text('lease_owner'),
148
+ leaseUntil: timestamptz(),
149
+ /**
150
+ * The last delivery error, bounded.
151
+ *
152
+ * `varchar(2000)` matches the Mongoose `maxlength`, and the application
153
+ * slices to the same 2000 before writing. Both halves are needed: a
154
+ * Mongoose validator THROWS on overflow and Postgres errors `22001`, so
155
+ * the slice is what makes the two dialects agree rather than one of them
156
+ * failing a delivery over an error message.
157
+ */
158
+ lastError: varchar('last_error', { length: 2_000 }),
159
+ processedAt: timestamptz(),
160
+ /** The retention deadline. Swept, not reaped — see the registry. */
161
+ expiresAt: timestamptz().notNull(),
162
+ createdAt: createdAt(),
163
+ updatedAt: updatedAt(),
164
+ },
165
+ (t) => [
166
+ check(
167
+ 'moderation_outbox_kind_check',
168
+ sql`${t.kind} in (${sql.raw(inList(OUTBOX_KINDS))})`,
169
+ ),
170
+ check(
171
+ 'moderation_outbox_status_check',
172
+ sql`${t.status} in (${sql.raw(inList(OUTBOX_STATUSES))})`,
173
+ ),
174
+ // Due work and expired claims are separate bounded scans.
175
+ index('moderation_outbox_due_idx').on(t.status, t.availableAt, t.createdAt),
176
+ index('moderation_outbox_lease_idx').on(t.status, t.leaseUntil, t.createdAt),
177
+ // The sweep's predicate is `expires_at <= now()`; without this it is a
178
+ // full scan on every run — the cost Mongo's TTL index hid.
179
+ index('moderation_outbox_expires_at_idx').on(t.expiresAt),
180
+ ],
181
+ );
182
+
183
+ /**
184
+ * Every webhook event CrowdSource has delivered to this deployment.
185
+ *
186
+ * Two jobs, and they are the same row on purpose.
187
+ *
188
+ * **Deduplication.** A receiver must record the processed event id. `id` IS
189
+ * the event id, so the primary key is the dedupe: a redelivery cannot insert a
190
+ * second row, and a claim therefore cannot succeed twice. Doing this in the
191
+ * database rather than in the SDK's default in-process store is not
192
+ * optimisation — an application running several tasks behind one load balancer
193
+ * would otherwise dedupe only whichever task happened to receive both copies.
194
+ *
195
+ * **Audit.** What arrived, when, and whether it was acted on. `payload` is the
196
+ * event's `data` exactly as delivered.
197
+ *
198
+ * The stored payload is a decision — an outcome, findings, policy versions, a
199
+ * jury summary. It is not the reported material and must not become a place
200
+ * where reported material is kept: nothing in this table is read into a log
201
+ * line.
202
+ */
203
+ const events = pgTable(
204
+ EVENT_TABLE,
205
+ {
206
+ /** The webhook event id. The primary key IS the deduplication. */
207
+ id: text('id').primaryKey(),
208
+ type: text('type'),
209
+ caseId: text('case_id'),
210
+ payload: jsonb('payload'),
211
+ state: text('state').$type<(typeof EVENT_STATES)[number]>().notNull().default('claimed'),
212
+ receivedAt: timestamptz().notNull(),
213
+ queuedAt: timestamptz(),
214
+ expiresAt: timestamptz().notNull(),
215
+ createdAt: createdAt(),
216
+ updatedAt: updatedAt(),
217
+ },
218
+ (t) => [
219
+ check(
220
+ 'moderation_events_state_check',
221
+ sql`${t.state} in (${sql.raw(inList(EVENT_STATES))})`,
222
+ ),
223
+ index('moderation_events_case_id_idx').on(t.caseId),
224
+ // Operational: what arrived recently, and what never got past `claimed`.
225
+ index('moderation_events_state_received_at_idx').on(t.state, t.receivedAt),
226
+ index('moderation_events_expires_at_idx').on(t.expiresAt),
227
+ ],
228
+ );
229
+
230
+ /**
231
+ * What the application did about a decision — one row per action, and the
232
+ * reason it is impossible to do twice.
233
+ *
234
+ * The idempotency key is `decision_id + decision_revision + action`, and here
235
+ * it is the PRIMARY KEY. That is the whole mechanism: a redelivered webhook, a
236
+ * reclaimed outbox lease and a manual replay all try to insert the same row,
237
+ * and only the first can. Checking "have I done this?" with a read before a
238
+ * write would leave the window between them, which is precisely the window a
239
+ * redelivery arrives in.
240
+ *
241
+ * `decision_revision` is in the key for a reason of its own. A correction is a
242
+ * NEW revision that supersedes the old one, so the restore it asks for is a
243
+ * different action from the removal that came before and must be allowed to
244
+ * happen — while still being impossible to apply twice itself.
245
+ *
246
+ * `previous_state` is what makes reversibility real rather than aspirational.
247
+ * It is the application's own opaque record of what an effect replaced, so a
248
+ * restore returns an object to what it WAS and a correction does not silently
249
+ * lift a content warning moderation never set.
250
+ */
251
+ const enforcements = pgTable(
252
+ ENFORCEMENT_TABLE,
253
+ {
254
+ decisionId: text('decision_id').notNull(),
255
+ decisionRevision: integer('decision_revision').notNull(),
256
+ action: text('action').notNull(),
257
+
258
+ caseId: text('case_id').notNull(),
259
+ /** The application's own noun. Never a CrowdSource resource id. */
260
+ subjectType: text('subject_type').notNull(),
261
+ subjectId: text('subject_id').notNull(),
262
+
263
+ outcome: text('outcome').notNull(),
264
+ recommendedAction: text('recommended_action'),
265
+ /**
266
+ * What the action amounted to, when the effect said it was not what was
267
+ * planned. The `action` column stays the PLANNED one — it is part of the
268
+ * primary key and it is what was decided.
269
+ */
270
+ recordedAs: text('recorded_as'),
271
+ /** Why this action, in words an operator can read. Never reported material. */
272
+ reason: varchar('reason', { length: 500 }).notNull(),
273
+
274
+ mode: text('mode').$type<(typeof ENFORCEMENT_MODES)[number]>().notNull(),
275
+ /**
276
+ * Whether the effect was actually carried out.
277
+ *
278
+ * `false` in `observe` mode for every action, which is the point of the
279
+ * mode: the plan is recorded and auditable, and nothing is removed.
280
+ */
281
+ applied: boolean('applied').notNull().default(false),
282
+ appliedAt: timestamptz(),
283
+ /** Why an action was recorded but not carried out. */
284
+ skippedReason: varchar('skipped_reason', { length: 300 }),
285
+
286
+ /**
287
+ * What to put back on a reversal. Only set for an action that changed
288
+ * state, so NULL here means "this action displaced nothing" — which is a
289
+ * different claim from "we did not look", and is why the reversal lookup
290
+ * filters on `applied` rather than reading whichever row is newest.
291
+ */
292
+ previousState: jsonb('previous_state').$type<EnforcementPreviousState>(),
293
+
294
+ createdAt: createdAt(),
295
+ updatedAt: updatedAt(),
296
+ },
297
+ (t) => [
298
+ /**
299
+ * The idempotency key. Unique because it is the PRIMARY KEY, and
300
+ * load-bearing: without it a redelivered decision removes an object twice,
301
+ * and a redelivered correction restores it twice.
302
+ */
303
+ primaryKey({
304
+ name: 'moderation_enforcements_pkey',
305
+ columns: [t.decisionId, t.decisionRevision, t.action],
306
+ }),
307
+ check('moderation_enforcements_revision_check', sql`${t.decisionRevision} >= 1`),
308
+ check('moderation_enforcements_action_check', sql`${t.action} in (${sql.raw(actions)})`),
309
+ check(
310
+ 'moderation_enforcements_recorded_as_check',
311
+ sql`${t.recordedAs} is null or ${t.recordedAs} in (${sql.raw(actions)})`,
312
+ ),
313
+ check(
314
+ 'moderation_enforcements_mode_check',
315
+ sql`${t.mode} in (${sql.raw(inList(ENFORCEMENT_MODES))})`,
316
+ ),
317
+ index('moderation_enforcements_case_id_idx').on(t.caseId),
318
+ // Operational: what has been done to this object, newest first.
319
+ index('moderation_enforcements_subject_chrono_idx').on(
320
+ t.subjectType,
321
+ t.subjectId,
322
+ t.createdAt.desc(),
323
+ ),
324
+ // The reversal lookup: the most recent APPLIED row for one action set on
325
+ // one object. `applied` is in the index because it is in every such query.
326
+ index('moderation_enforcements_subject_action_applied_idx').on(
327
+ t.subjectType,
328
+ t.subjectId,
329
+ t.action,
330
+ t.applied,
331
+ t.createdAt.desc(),
332
+ ),
333
+ ],
334
+ );
335
+
336
+ return { outbox, events, enforcements };
337
+ }
338
+
339
+ export type ModerationTables = ReturnType<typeof moderationTables>;
@@ -0,0 +1,199 @@
1
+ import { reportSubmitEventId, type OutboxService } from './outbox/service.js';
2
+ import type {
3
+ ModerationReportStore,
4
+ ModerationTransactionRunner,
5
+ } from './store/types.js';
6
+ import type {
7
+ ModerationLogger,
8
+ ModerationReconciliationResult,
9
+ ModerationReportFields,
10
+ } from './types.js';
11
+
12
+ /**
13
+ * Finding the reports the pipeline lost sight of.
14
+ *
15
+ * The outbox makes delivery durable, not infallible. Four divergences are
16
+ * possible and none of them announces itself:
17
+ *
18
+ * 1. A report that should have a delivery event and does not — one whose event
19
+ * was dropped by a retention TTL while the deployment was down for longer
20
+ * than the retention window, or one whose enqueue was lost to an operator's
21
+ * intervention.
22
+ * 2. A report stuck at `delivery_failed` whose outbox event has been
23
+ * dead-lettered. That one is not re-queued: something about the payload has
24
+ * to change first, and re-queueing it would spin. It is COUNTED, because the
25
+ * count is the alert.
26
+ * 3. A report `submitted` long ago whose case never came back. Nothing to do
27
+ * locally — the decision is CrowdSource's to publish — but a rising count is
28
+ * how a broken webhook endpoint or a rotated secret becomes visible before
29
+ * somebody notices a quiet moderation queue.
30
+ * 4. A report that was never going anywhere: `received`, because its type has no
31
+ * subject provider, or because it predates the integration. This is the one
32
+ * divergence that is not a fault, so it is counted and NEVER re-queued —
33
+ * re-deriving a delivery event for a report nothing can describe would send
34
+ * it straight to the dead-letter queue and turn a deliberate local-only
35
+ * report into a recurring alert. Counting it is still worth doing: it is the
36
+ * only number that makes "reports stored here that no jury will ever see"
37
+ * visible at all, and that is precisely the cost of accepting them.
38
+ *
39
+ * A sweep only ever RE-DERIVES work from the reports; it never invents any.
40
+ * Everything it enqueues uses the same deterministic event id as the original,
41
+ * so a report that did have an event is untouched rather than delivered twice.
42
+ */
43
+
44
+ const DEFAULT_BATCH_SIZE = 200;
45
+ const MAX_BATCH_SIZE = 1_000;
46
+ const DEFAULT_STALE_SUBMITTED_HOURS = 72;
47
+ const DEFAULT_INTERVAL_MS = 15 * 60 * 1_000;
48
+
49
+ export type ReconcileModerationReports = (options?: {
50
+ batchSize?: number;
51
+ now?: Date;
52
+ }) => Promise<ModerationReconciliationResult>;
53
+
54
+ /**
55
+ * One sweep. Bounded, idempotent, safe to run on every task.
56
+ *
57
+ * Reads `queued` and `delivery_failed` reports oldest-first — the index on
58
+ * `{ localStatus, createdAt }` exists for this query — and re-enqueues only
59
+ * those with no outbox event at all.
60
+ */
61
+ export function createReconciliation<TReport extends ModerationReportFields, TTx>(input: {
62
+ transaction: ModerationTransactionRunner<TTx>;
63
+ reports: ModerationReportStore<TReport, TTx>;
64
+ outbox: OutboxService<TTx>;
65
+ logger: ModerationLogger;
66
+ staleSubmittedHours?: number;
67
+ }): ReconcileModerationReports {
68
+ const staleHours = input.staleSubmittedHours ?? DEFAULT_STALE_SUBMITTED_HOURS;
69
+
70
+ return async (options = {}) => {
71
+ const batchSize = Math.min(
72
+ Math.max(1, options.batchSize ?? DEFAULT_BATCH_SIZE),
73
+ MAX_BATCH_SIZE,
74
+ );
75
+ const now = options.now ?? new Date();
76
+ const result: ModerationReconciliationResult = {
77
+ requeued: 0,
78
+ deadLettered: 0,
79
+ awaitingDecision: 0,
80
+ localOnly: 0,
81
+ };
82
+
83
+ /**
84
+ * `queued` and `delivery_failed` only — the store's own query says so, and
85
+ * the omission of `received` is the safety property rather than an
86
+ * oversight: those reports have no subject provider, so an event re-derived
87
+ * for one would fail as `ModerationSubjectUnsupportedError` on its first
88
+ * attempt and dead-letter. They are counted below instead.
89
+ */
90
+ const pending = await input.reports.findPendingOldestFirst(batchSize);
91
+
92
+ for (const reportId of pending) {
93
+ const eventId = reportSubmitEventId(reportId);
94
+ const status = await input.outbox.statusOf(eventId);
95
+
96
+ if (status === 'dead_letter') {
97
+ result.deadLettered += 1;
98
+ continue;
99
+ }
100
+ if (status !== null) continue;
101
+
102
+ /**
103
+ * A transaction for a single upsert, for consistency with intake rather
104
+ * than for atomicity: the enqueue requires a transaction precisely so that
105
+ * no path in this package can write an outbox event outside one. A
106
+ * signature that made it optional would be the crack the next caller slips
107
+ * through, and the runner is what makes taking one here cost a line.
108
+ */
109
+ await input.transaction.run(async (tx) => {
110
+ await input.outbox.enqueue(
111
+ { eventId, kind: 'report.submit', payload: { reportId } },
112
+ tx,
113
+ );
114
+ });
115
+ result.requeued += 1;
116
+ }
117
+
118
+ result.awaitingDecision = await input.reports.countAwaitingDecision(
119
+ new Date(now.getTime() - staleHours * 60 * 60 * 1_000),
120
+ );
121
+ result.localOnly = await input.reports.countLocalOnly();
122
+
123
+ if (result.requeued > 0 || result.deadLettered > 0) {
124
+ input.logger.warn('[CrowdSource] reconciliation found divergence', { ...result });
125
+ } else if (result.awaitingDecision > 0 || result.localOnly > 0) {
126
+ input.logger.info('[CrowdSource] reports with no decision to apply', {
127
+ awaitingDecision: result.awaitingDecision,
128
+ olderThanHours: staleHours,
129
+ localOnly: result.localOnly,
130
+ });
131
+ }
132
+
133
+ return result;
134
+ };
135
+ }
136
+
137
+ /**
138
+ * The reconciliation sweep, on a timer.
139
+ *
140
+ * Start it from a LEADER-elected scheduler only. Unlike the outbox dispatcher,
141
+ * whose per-event lease makes it safe on every task, this sweep scans and counts
142
+ * across the whole collection; running it on every task would multiply that work
143
+ * by the task count for no benefit.
144
+ *
145
+ * Long interval on purpose. It is a safety net for divergences the durable path
146
+ * is supposed to prevent, not a delivery mechanism — a report that needs this
147
+ * sweep to be delivered is already a report something went wrong with.
148
+ */
149
+ export class ModerationReconciliationJob {
150
+ private timer: ReturnType<typeof setInterval> | null = null;
151
+ private inFlight: Promise<void> | null = null;
152
+ private running = false;
153
+
154
+ constructor(
155
+ private readonly options: {
156
+ reconcile: ReconcileModerationReports;
157
+ logger: ModerationLogger;
158
+ enabled: boolean;
159
+ intervalMs?: number;
160
+ },
161
+ ) {}
162
+
163
+ start(): void {
164
+ if (this.running) return;
165
+ if (!this.options.enabled) return;
166
+ const intervalMs = this.options.intervalMs ?? DEFAULT_INTERVAL_MS;
167
+ this.running = true;
168
+ this.timer = setInterval(() => {
169
+ void this.tick();
170
+ }, intervalMs);
171
+ this.timer.unref?.();
172
+ this.options.logger.info('[CrowdSource] reconciliation job started', { intervalMs });
173
+ }
174
+
175
+ stop(): void {
176
+ this.running = false;
177
+ if (this.timer) {
178
+ clearInterval(this.timer);
179
+ this.timer = null;
180
+ }
181
+ }
182
+
183
+ private async tick(): Promise<void> {
184
+ if (!this.running || this.inFlight) return;
185
+ const work = this.options
186
+ .reconcile()
187
+ .then(() => undefined)
188
+ .catch((error: unknown) => {
189
+ this.options.logger.error('[CrowdSource] reconciliation sweep failed', {
190
+ error: error instanceof Error ? error.message : String(error),
191
+ });
192
+ })
193
+ .finally(() => {
194
+ if (this.inFlight === work) this.inFlight = null;
195
+ });
196
+ this.inFlight = work;
197
+ await work;
198
+ }
199
+ }
@@ -0,0 +1,24 @@
1
+ import type { ModerationLocalStatus } from './types.js';
2
+
3
+ /**
4
+ * Decision statuses that end the application's side of the case.
5
+ *
6
+ * A `provisional` decision leaves the report at `submitted`: a later revision
7
+ * may supersede it, and a report the application had already closed would have
8
+ * to be reopened. `superseded` is not here either — a superseded revision is not
9
+ * the current answer and must never be the one that closes the report.
10
+ */
11
+ const TERMINAL_DECISION_STATUSES: ReadonlySet<string> = new Set(['final', 'corrected']);
12
+
13
+ /**
14
+ * Where a decision leaves the report.
15
+ *
16
+ * Takes the decision status as a plain STRING on purpose. This is reached with a
17
+ * value that came off the wire, and a newer CrowdSource introducing a status
18
+ * this version has never seen must be handled rather than throw. An unrecognised
19
+ * status leaves the report open, which is the only safe reading: closing a
20
+ * report on a status nobody has defined would retire a case that may still move.
21
+ */
22
+ export function localStatusForDecision(decisionStatus: string): ModerationLocalStatus {
23
+ return TERMINAL_DECISION_STATUSES.has(decisionStatus) ? 'closed' : 'submitted';
24
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How long this package keeps the two things it writes down for its own sake.
3
+ *
4
+ * Policy, not storage, which is why it lives in the shared half: a deployment
5
+ * whose outbox rows expired after an hour and whose event rows expired after
6
+ * ninety days would have two different answers to "what happened to that
7
+ * report", and neither backend would consider that an error. Both stores are
8
+ * handed the computed `expiresAt` rather than the window, so the window has one
9
+ * definition.
10
+ *
11
+ * How the deadline is ENFORCED is a backend's business — a Mongo TTL index on
12
+ * `expiresAt`, an expiry sweep on Postgres — but a table registered with
13
+ * neither grows forever with no error and no failing test.
14
+ */
15
+
16
+ /**
17
+ * The outbox.
18
+ *
19
+ * A retention ceiling, so a stalled dispatcher cannot turn the outbox into an
20
+ * unbounded table. Long, because a moderation case can legitimately sit open for
21
+ * weeks and a `dead_letter` event is evidence somebody still has to look at.
22
+ * Operational alerts must fire long before this deadline.
23
+ */
24
+ export const MODERATION_OUTBOX_RETENTION_SECONDS = 90 * 24 * 60 * 60;
25
+
26
+ /**
27
+ * Inbound webhook events.
28
+ *
29
+ * CrowdSource's retry schedule ends at 24 hours, so a dedupe row only has to
30
+ * outlive that. It is kept far longer because the row is also the audit trail of
31
+ * what a third party told this deployment to do, and an enforcement question
32
+ * asked weeks later is answered from here.
33
+ */
34
+ export const MODERATION_EVENT_RETENTION_SECONDS = 90 * 24 * 60 * 60;