@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,273 @@
1
+ import { and, asc, eq, gt, inArray, lte, or, sql } from 'drizzle-orm';
2
+ import { PgTransaction } from 'drizzle-orm/pg-core';
3
+ import { ModerationOutboxTransactionError } from '../../outbox/service.js';
4
+ import type { ModerationOutboxStore } from '../../store/types.js';
5
+ import type { ModerationOutboxEvent } from '../../types.js';
6
+ import type { ModerationTables } from '../tables.js';
7
+ import type { ModerationPgHandle } from './transaction.js';
8
+
9
+ /**
10
+ * The moderation outbox, in Postgres.
11
+ *
12
+ * Same six operations as the Mongo store, same policy handed down from the
13
+ * service above it, four places where the mechanism differs — and each of those
14
+ * is where the correctness lives:
15
+ *
16
+ * 1. **The transaction guard is `instanceof PgTransaction`.** Mongo's equivalent
17
+ * asks a session whether a transaction is open; here the mistake is passing
18
+ * the POOL handle where the `tx` belongs, which runs on a different connection
19
+ * and commits independently. Same lost guarantee, different shape, and it
20
+ * type-checks perfectly because both are `ModerationPgHandle`.
21
+ * 2. **The insert-if-absent is `ON CONFLICT DO NOTHING`.** A no-op by
22
+ * construction rather than by suppressing an ORM's timestamp behaviour: it
23
+ * writes nothing and takes no row lock on an already-committed conflicting
24
+ * row. Never `DO UPDATE` — that reintroduces exactly the defect the Mongo
25
+ * side's `timestamps: false` exists to prevent.
26
+ * 3. **The claim is `FOR UPDATE SKIP LOCKED`.** Load-bearing, not tuning: see
27
+ * `claim`.
28
+ * 4. **Every lease transition asks `RETURNING` how many rows matched.**
29
+ */
30
+
31
+ /** The columns a claimed event is read back through. */
32
+ type ClaimedRow = {
33
+ id: string;
34
+ kind: ModerationOutboxEvent['kind'];
35
+ payload: ModerationOutboxEvent['payload'];
36
+ attempts: number;
37
+ availableAt: Date;
38
+ leaseOwner: string | null;
39
+ leaseUntil: Date | null;
40
+ expiresAt: Date;
41
+ createdAt: Date;
42
+ };
43
+
44
+ export function postgresOutboxStore(input: {
45
+ db: ModerationPgHandle;
46
+ tables: ModerationTables;
47
+ }): ModerationOutboxStore<ModerationPgHandle> {
48
+ const { db } = input;
49
+ const outbox = input.tables.outbox;
50
+
51
+ /**
52
+ * Due work, as one predicate.
53
+ *
54
+ * Either `pending` and past its `available_at`, or `processing` with an EXPIRED
55
+ * lease — the second arm is what makes a dead worker's event reclaimable rather
56
+ * than stranded. The two arms have an index each
57
+ * (`moderation_outbox_due_idx`, `moderation_outbox_lease_idx`).
58
+ */
59
+ const dueOrExpired = (now: Date) =>
60
+ or(
61
+ and(eq(outbox.status, 'pending'), lte(outbox.availableAt, now)),
62
+ and(eq(outbox.status, 'processing'), lte(outbox.leaseUntil, now)),
63
+ );
64
+
65
+ /** The lease this caller claims to hold, still live. Shared by all three transitions. */
66
+ const heldLease = (eventId: string, leaseOwner: string, now: Date) =>
67
+ and(
68
+ eq(outbox.id, eventId),
69
+ eq(outbox.status, 'processing'),
70
+ eq(outbox.leaseOwner, leaseOwner),
71
+ gt(outbox.leaseUntil, now),
72
+ );
73
+
74
+ const toEvent = (row: ClaimedRow): ModerationOutboxEvent => ({
75
+ id: row.id,
76
+ kind: row.kind,
77
+ payload: row.payload,
78
+ attempts: row.attempts,
79
+ availableAt: row.availableAt,
80
+ // `null` is how Postgres stores "no lease"; the event type says absent.
81
+ ...(row.leaseOwner === null ? {} : { leaseOwner: row.leaseOwner }),
82
+ ...(row.leaseUntil === null ? {} : { leaseUntil: row.leaseUntil }),
83
+ expiresAt: row.expiresAt,
84
+ createdAt: row.createdAt,
85
+ });
86
+
87
+ return {
88
+ async enqueue(event, tx) {
89
+ /**
90
+ * The guard, and it is not the same mistake Mongo's guards against.
91
+ *
92
+ * Both `db` and `tx` are a `ModerationPgHandle`, so handing this the POOL
93
+ * handle type-checks perfectly — and then the row commits on its own
94
+ * connection, independently of the domain write it was supposed to be
95
+ * atomic with. That is "the report was answered 201 and never delivered",
96
+ * reached by a different route than a session nobody opened a transaction
97
+ * on, with the same silence.
98
+ *
99
+ * `PgTransaction` is a real runtime class in `drizzle-orm/pg-core`, so this
100
+ * is a genuine check rather than a duck-typed guess.
101
+ */
102
+ if (!(tx instanceof PgTransaction)) {
103
+ throw new ModerationOutboxTransactionError(event.eventId);
104
+ }
105
+
106
+ /**
107
+ * `ON CONFLICT DO NOTHING`, never `DO UPDATE`.
108
+ *
109
+ * A repeated enqueue is ORDINARY — a transaction retry, two concurrent
110
+ * duplicate submissions, a reconciliation sweep re-deriving an event — and
111
+ * the dispatcher is concurrently taking, renewing and completing leases on
112
+ * these same rows. `DO UPDATE` would make each repeat a real write, which
113
+ * conflicts with a live lease update and aborts the enclosing transaction:
114
+ * the exact defect the Mongo store's `timestamps: false` exists to prevent,
115
+ * reintroduced in a dialect where nothing forces it on you.
116
+ *
117
+ * One behavioural difference from Mongo, and Postgres has the better end of
118
+ * it: if a CONCURRENT UNCOMMITTED transaction holds this same key, Postgres
119
+ * WAITS for it and then proceeds (finding the row committed, and doing
120
+ * nothing), where Mongo raises `WriteConflict` (code 112) and aborts the
121
+ * enclosing transaction. Waiting is the outcome a caller wants.
122
+ *
123
+ * `created_at` and `updated_at` are written explicitly from the caller's
124
+ * clock rather than left to their defaults, so both backends stamp a row
125
+ * from one instant — and so a test can assert that a repeat changed
126
+ * NOTHING, which is a stronger claim than "no duplicate row".
127
+ */
128
+ await tx
129
+ .insert(outbox)
130
+ .values({
131
+ id: event.eventId,
132
+ kind: event.kind,
133
+ payload: event.payload,
134
+ status: 'pending',
135
+ attempts: 0,
136
+ availableAt: event.availableAt,
137
+ expiresAt: event.expiresAt,
138
+ createdAt: event.now,
139
+ updatedAt: event.now,
140
+ })
141
+ .onConflictDoNothing({ target: outbox.id });
142
+ },
143
+
144
+ async claim({ leaseOwner, leaseUntil, now, eventId }) {
145
+ /**
146
+ * `SKIP LOCKED` is load-bearing, not tuning.
147
+ *
148
+ * Without it, under READ COMMITTED the sub-select is evaluated once: the
149
+ * loser blocks on the head row and then returns ZERO rows. `claim` answers
150
+ * `null`, `dispatch` breaks out of its batch, and a deployment running N
151
+ * tasks silently drains at 1/N the rate with nothing failing anywhere.
152
+ *
153
+ * The `FOR UPDATE` lives in the SUB-SELECT because that is what locks the
154
+ * one row this claim intends to take, before the UPDATE touches it — the
155
+ * documented Postgres idiom for a work queue.
156
+ */
157
+ const due = db
158
+ .select({ id: outbox.id })
159
+ .from(outbox)
160
+ .where(
161
+ eventId === undefined
162
+ ? dueOrExpired(now)
163
+ : and(eq(outbox.id, eventId), dueOrExpired(now)),
164
+ )
165
+ // Oldest first, so a backlog drains in the order it was filed rather than
166
+ // in whatever order the storage engine finds convenient.
167
+ .orderBy(asc(outbox.createdAt))
168
+ .limit(1)
169
+ .for('update', { skipLocked: true });
170
+
171
+ const rows = await db
172
+ .update(outbox)
173
+ .set({
174
+ status: 'processing',
175
+ leaseOwner,
176
+ leaseUntil,
177
+ // The COLUMN is interpolated, not a value: `attempts` is incremented by
178
+ // the database so two claimers cannot both read 3 and both write 4.
179
+ attempts: sql`${outbox.attempts} + 1`,
180
+ // The previous attempt's error, cleared with the claim so a stale
181
+ // message can never be read as this attempt's. `null` CLEARS in
182
+ // drizzle; `undefined` would leave it alone.
183
+ lastError: null,
184
+ updatedAt: now,
185
+ })
186
+ .where(inArray(outbox.id, due))
187
+ .returning({
188
+ id: outbox.id,
189
+ kind: outbox.kind,
190
+ payload: outbox.payload,
191
+ attempts: outbox.attempts,
192
+ availableAt: outbox.availableAt,
193
+ leaseOwner: outbox.leaseOwner,
194
+ leaseUntil: outbox.leaseUntil,
195
+ expiresAt: outbox.expiresAt,
196
+ createdAt: outbox.createdAt,
197
+ });
198
+
199
+ const [row] = rows;
200
+ return row === undefined ? null : toEvent(row);
201
+ },
202
+
203
+ /**
204
+ * ## Why all three transitions read `RETURNING`, and what that collapses
205
+ *
206
+ * Mongo's `complete` and `fail` answer `modifiedCount === 1` while its
207
+ * `renew` answers `matchedCount === 1`. `RETURNING` counts MATCHED rows, so
208
+ * this store answers the `matchedCount` question in all three places.
209
+ *
210
+ * That is equivalent HERE, and the argument is worth writing down because it
211
+ * is an argument rather than a test: the WHERE clause requires
212
+ * `status = 'processing'`, and `complete` and `fail` both write a different
213
+ * status, so a matched row is always a modified row. `renew` writes only
214
+ * `lease_until`/`updated_at` and Mongo already used `matchedCount` for it —
215
+ * a renewal that lands on the values already stored is still a lease this
216
+ * caller holds, and reporting it as lost would make a dispatcher abandon an
217
+ * event it still owns.
218
+ *
219
+ * If a later transition ever stops changing `status`, this equivalence stops
220
+ * holding and the difference becomes silent.
221
+ */
222
+ async complete({ eventId, leaseOwner, now }) {
223
+ const rows = await db
224
+ .update(outbox)
225
+ .set({
226
+ status: 'processed',
227
+ processedAt: now,
228
+ updatedAt: now,
229
+ leaseOwner: null,
230
+ leaseUntil: null,
231
+ lastError: null,
232
+ })
233
+ .where(heldLease(eventId, leaseOwner, now))
234
+ .returning({ id: outbox.id });
235
+ return rows.length === 1;
236
+ },
237
+
238
+ async renew({ eventId, leaseOwner, leaseUntil, now }) {
239
+ const rows = await db
240
+ .update(outbox)
241
+ .set({ leaseUntil, updatedAt: now })
242
+ .where(heldLease(eventId, leaseOwner, now))
243
+ .returning({ id: outbox.id });
244
+ return rows.length === 1;
245
+ },
246
+
247
+ async fail({ eventId, leaseOwner, status, availableAt, lastError, now }) {
248
+ const rows = await db
249
+ .update(outbox)
250
+ .set({
251
+ status,
252
+ availableAt,
253
+ lastError,
254
+ updatedAt: now,
255
+ leaseOwner: null,
256
+ leaseUntil: null,
257
+ })
258
+ .where(heldLease(eventId, leaseOwner, now))
259
+ .returning({ id: outbox.id });
260
+ return rows.length === 1;
261
+ },
262
+
263
+ async statusOf(eventId) {
264
+ const rows = await db
265
+ .select({ status: outbox.status })
266
+ .from(outbox)
267
+ .where(eq(outbox.id, eventId))
268
+ .limit(1);
269
+ const [row] = rows;
270
+ return row === undefined ? null : row.status;
271
+ },
272
+ };
273
+ }
@@ -0,0 +1,349 @@
1
+ import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm';
2
+ import type {
3
+ ModerationReportDecisionUpdate,
4
+ ModerationReportRef,
5
+ ModerationReportStore,
6
+ } from '../../store/types.js';
7
+ import type { ModerationReportFields } from '../../types.js';
8
+ import type { ModerationReportTable } from '../reportColumns.js';
9
+ import type { ModerationPgHandle } from './transaction.js';
10
+
11
+ /**
12
+ * The application's own report table, in Postgres.
13
+ *
14
+ * The only store that takes a table it did not define, which is what makes the
15
+ * structural type worth having: an adopter who forgets a column this package
16
+ * queries gets a COMPILE ERROR at the `postgresModerationStore(...)` call rather
17
+ * than a runtime failure on the first delivery.
18
+ *
19
+ * `scripts/test-report-table-type.mjs` proves that gate in both directions and
20
+ * records exactly what it does not catch — a column present under the right name
21
+ * but the wrong TYPE passes, because every member of `ModerationReportTable` is a
22
+ * bare `PgColumn`. The DDL and the schema tests are what cover that half.
23
+ *
24
+ * ## Two Mongo hazards that do not exist here
25
+ *
26
+ * **A malformed id is not an error.** `id` is `text`, so an id nothing could have
27
+ * generated simply matches no rows and `findById` answers `null` — which is
28
+ * exactly what the delivery worker already does with "the report is gone".
29
+ * Mongoose raises a `CastError` for the same input and its store has to catch it.
30
+ * There is nothing to catch here, and adding a branch for `22P02` would be
31
+ * writing a handler for an error this column cannot raise.
32
+ *
33
+ * **A bound parameter cannot become a query operator.** `requireIdentifier` in
34
+ * `intake.ts` still runs, and still should — a non-string corrupts data on any
35
+ * backend, and the function is exported for callers with no route validation. But
36
+ * the specific failure it was written for, `{ $ne: null }` arriving as a value and
37
+ * matching an unrelated report, is a Mongo shape: here a parameter is a parameter.
38
+ *
39
+ * ## One thing that is worse, stated plainly
40
+ *
41
+ * Every read hands the caller `TReport`, and no type can prove that: the adopter
42
+ * owns the type AND the table, and the two are only connected by their names. See
43
+ * {@link asReport}.
44
+ */
45
+
46
+ /**
47
+ * The fields `ModerationReportFields` declares OPTIONAL, computed from the type.
48
+ *
49
+ * Postgres stores an absent value as NULL; Mongo omits the field. Both are the
50
+ * same claim — "this never happened" — and one suite has to be able to assert it
51
+ * once, so the package's own optional fields come back ABSENT from either
52
+ * backend. Two `describe.each` pairs failed on exactly this before it was
53
+ * settled, both reading `expected null to be undefined`, and neither was about
54
+ * behaviour.
55
+ *
56
+ * Only the fields the PORT owns are normalised. An adopter's own nullable column
57
+ * is theirs: `extra` goes in untouched and comes back untouched.
58
+ */
59
+ type OptionalReportField = {
60
+ [K in keyof ModerationReportFields]-?: undefined extends ModerationReportFields[K]
61
+ ? K
62
+ : never;
63
+ }[keyof ModerationReportFields];
64
+
65
+ const OPTIONAL_REPORT_FIELDS = [
66
+ 'details',
67
+ 'localStatusReason',
68
+ 'crowdSourceReportId',
69
+ 'crowdSourceCaseId',
70
+ 'crowdSourceMerged',
71
+ 'contentSnapshotHash',
72
+ 'submittedAt',
73
+ 'lastDeliveryError',
74
+ 'decisionId',
75
+ 'decisionRevision',
76
+ 'decisionOutcome',
77
+ 'decisionStatus',
78
+ 'decidedAt',
79
+ 'enforcedAction',
80
+ 'enforcedAt',
81
+ ] as const satisfies readonly OptionalReportField[];
82
+
83
+ /** `T` when it is `never`, and a compile error naming the field when it is not. */
84
+ type AssertNever<T extends never> = T;
85
+
86
+ /**
87
+ * The exhaustiveness gate. `satisfies` above refuses a field that is not optional;
88
+ * THIS refuses an optional field that is missing from the list — the direction
89
+ * that would otherwise ship as a backend difference nobody looked for.
90
+ */
91
+ export type UncoveredOptionalReportField = AssertNever<
92
+ Exclude<OptionalReportField, (typeof OPTIONAL_REPORT_FIELDS)[number]>
93
+ >;
94
+
95
+ /** Drop the package's own optional fields when the column is NULL. */
96
+ function absentWhereNull(row: Record<string, unknown>): Record<string, unknown> {
97
+ const normalised: Record<string, unknown> = { ...row };
98
+ for (const field of OPTIONAL_REPORT_FIELDS) {
99
+ if (normalised[field] === null) delete normalised[field];
100
+ }
101
+ return normalised;
102
+ }
103
+
104
+ /**
105
+ * The row, as the port declares it.
106
+ *
107
+ * An unchecked declaration, not a conversion — the same escape the Mongoose store
108
+ * takes with `.lean<TReport>()`, and for the same reason: a driver cannot know an
109
+ * adopter's row type, and `ModerationReportTable` deliberately erases column types
110
+ * so that any adopter's table is accepted.
111
+ *
112
+ * What bounds it is that both halves are checked elsewhere. Column PRESENCE is a
113
+ * compile error, gated by `scripts/test-report-table-type.mjs`. Column VALUES are
114
+ * asserted by round-trips against a real database in
115
+ * `postgresReportStore.test.ts`. What remains unchecked is a column declared with
116
+ * the wrong TYPE, which the migration and the schema test cover instead.
117
+ */
118
+ function asReport<TReport extends ModerationReportFields>(
119
+ row: Record<string, unknown>,
120
+ ): TReport {
121
+ return absentWhereNull(row) as TReport;
122
+ }
123
+
124
+ export function postgresReportStore<TReport extends ModerationReportFields>(input: {
125
+ db: ModerationPgHandle;
126
+ reportTable: ModerationReportTable;
127
+ }): ModerationReportStore<TReport, ModerationPgHandle> {
128
+ const { db } = input;
129
+ const reports = input.reportTable;
130
+
131
+ /**
132
+ * `count(*)::int`, and the cast is load-bearing.
133
+ *
134
+ * `count(*)` is `bigint`, which postgres.js hands back as a STRING to avoid
135
+ * losing precision — so `sql<number>` without the cast is an assertion that
136
+ * quietly lies, and the caller's `number` is `'3'`. The `::int` makes the
137
+ * database do the conversion.
138
+ */
139
+ const total = sql<number>`count(*)::int`;
140
+
141
+ return {
142
+ async findDuplicate({ reporter, reportedId, reportedType }, tx) {
143
+ const rows = await tx
144
+ .select()
145
+ .from(reports)
146
+ .where(
147
+ and(
148
+ eq(reports.reporter, reporter),
149
+ eq(reports.reportedId, reportedId),
150
+ eq(reports.reportedType, reportedType),
151
+ ),
152
+ )
153
+ .limit(1);
154
+ const [row] = rows;
155
+ return row === undefined ? null : asReport<TReport>(row);
156
+ },
157
+
158
+ async insert(report, tx) {
159
+ const rows = await tx
160
+ .insert(reports)
161
+ .values({
162
+ /**
163
+ * The application's own columns FIRST, so a field this package owns can
164
+ * never be overwritten by one it knows nothing about. A report whose
165
+ * `localStatus` came from `extra` would be queued with nothing to
166
+ * deliver it, or received with a delivery event that tries anyway.
167
+ *
168
+ * A key that is not a column raises here — Postgres has no silent
169
+ * discard. Mongoose strict mode drops an undeclared path with no throw
170
+ * and no warning, which is why the Mongo half needs a standing test
171
+ * that every DTO field resolves to a schema path.
172
+ */
173
+ ...report.extra,
174
+ reportedType: report.reportedType,
175
+ reportedId: report.reportedId,
176
+ reporter: report.reporter,
177
+ categories: [...report.categories],
178
+ ...(report.details === undefined ? {} : { details: report.details }),
179
+ localStatus: report.localStatus,
180
+ ...(report.localStatusReason === undefined
181
+ ? {}
182
+ : { localStatusReason: report.localStatusReason }),
183
+ })
184
+ .returning();
185
+
186
+ const [row] = rows;
187
+ if (row === undefined) {
188
+ throw new Error('The moderation report insert returned no row.');
189
+ }
190
+ return asReport<TReport>(row);
191
+ },
192
+
193
+ async findById(reportId) {
194
+ const rows = await db.select().from(reports).where(eq(reports.id, reportId)).limit(1);
195
+ const [row] = rows;
196
+ return row === undefined ? null : asReport<TReport>(row);
197
+ },
198
+
199
+ async findByCaseId(caseId) {
200
+ const rows = await db
201
+ .select({
202
+ id: reports.id,
203
+ reportedType: reports.reportedType,
204
+ reportedId: reports.reportedId,
205
+ })
206
+ .from(reports)
207
+ .where(eq(reports.crowdSourceCaseId, caseId));
208
+
209
+ return rows.map(
210
+ (row): ModerationReportRef => ({
211
+ id: String(row.id),
212
+ reportedType: String(row.reportedType),
213
+ reportedId: String(row.reportedId),
214
+ }),
215
+ );
216
+ },
217
+
218
+ async applyDecision(reportId, update, maxRevision) {
219
+ const rows = await db
220
+ .update(reports)
221
+ .set(decisionSet(update))
222
+ .where(
223
+ and(
224
+ eq(reports.id, reportId),
225
+ /**
226
+ * The revision guard, in the WHERE clause. It is the DATABASE that
227
+ * refuses a stale write rather than a read-then-write in this
228
+ * process: deliveries overlap — CrowdSource retries for 24 hours, and
229
+ * a correction can arrive while the decision it supersedes is still
230
+ * being applied — and an older revision landing last would otherwise
231
+ * overwrite the current answer.
232
+ *
233
+ * `IS NULL` is the port of Mongo's `$exists: false`, because a report
234
+ * with no decision yet stores NULL rather than omitting the column.
235
+ * `<=` rather than `<` is deliberate: a redelivery of the SAME
236
+ * revision rewrites, which is harmless and keeps a partially-applied
237
+ * decision converging.
238
+ */
239
+ or(
240
+ isNull(reports.decisionRevision),
241
+ lte(reports.decisionRevision, maxRevision),
242
+ ),
243
+ ),
244
+ )
245
+ .returning({ id: reports.id });
246
+
247
+ return rows.length === 1;
248
+ },
249
+
250
+ async markSubmitted(reportId, submission) {
251
+ await db
252
+ .update(reports)
253
+ .set({
254
+ localStatus: 'submitted',
255
+ crowdSourceReportId: submission.crowdSourceReportId,
256
+ crowdSourceCaseId: submission.crowdSourceCaseId,
257
+ crowdSourceMerged: submission.crowdSourceMerged,
258
+ contentSnapshotHash: submission.contentSnapshotHash,
259
+ submittedAt: submission.submittedAt,
260
+ // A report that has landed carries no failure and no reason it was
261
+ // going nowhere. `null` CLEARS in drizzle; `undefined` would leave the
262
+ // stale value in place, which is the port of Mongo's `$unset`.
263
+ lastDeliveryError: null,
264
+ localStatusReason: null,
265
+ })
266
+ .where(eq(reports.id, reportId));
267
+ },
268
+
269
+ async markDeliveryFailed(reportId, lastDeliveryError) {
270
+ await db
271
+ .update(reports)
272
+ .set({ localStatus: 'delivery_failed', lastDeliveryError })
273
+ .where(eq(reports.id, reportId));
274
+ },
275
+
276
+ async close(reportId, localStatusReason) {
277
+ await db
278
+ .update(reports)
279
+ .set({ localStatus: 'closed', localStatusReason })
280
+ .where(eq(reports.id, reportId));
281
+ },
282
+
283
+ async findPendingOldestFirst(limit) {
284
+ /**
285
+ * `queued` and `delivery_failed` only. `received` is excluded deliberately
286
+ * and the omission is the safety property, not an oversight: those reports
287
+ * have no subject provider, so an event re-derived for one would fail on
288
+ * its first attempt and dead-letter.
289
+ *
290
+ * Oldest first, and ASC needs no explicit NULLS placement: both drizzle and
291
+ * Postgres default ascending to NULLS LAST, so this matches the
292
+ * `(local_status, created_at)` index. A DESCENDING order would need the
293
+ * placement spelled out — see the enforcement store's reversal lookup for
294
+ * what that costs when it is not.
295
+ */
296
+ const rows = await db
297
+ .select({ id: reports.id })
298
+ .from(reports)
299
+ .where(inArray(reports.localStatus, ['queued', 'delivery_failed']))
300
+ .orderBy(asc(reports.createdAt))
301
+ .limit(limit);
302
+
303
+ return rows.map((row) => String(row.id));
304
+ },
305
+
306
+ async countAwaitingDecision(submittedBefore) {
307
+ const rows = await db
308
+ .select({ total })
309
+ .from(reports)
310
+ .where(
311
+ and(
312
+ eq(reports.localStatus, 'submitted'),
313
+ lt(reports.submittedAt, submittedBefore),
314
+ ),
315
+ );
316
+ return rows[0]?.total ?? 0;
317
+ },
318
+
319
+ async countLocalOnly() {
320
+ const rows = await db
321
+ .select({ total })
322
+ .from(reports)
323
+ .where(eq(reports.localStatus, 'received'));
324
+ return rows[0]?.total ?? 0;
325
+ },
326
+ };
327
+ }
328
+
329
+ /**
330
+ * The `$set` a decision writes, as one object.
331
+ *
332
+ * `extra` FIRST, for the same reason it is first on insert: it is the adopter's
333
+ * legacy verdict field, and this package's own fields must win.
334
+ */
335
+ function decisionSet(update: ModerationReportDecisionUpdate): Record<string, unknown> {
336
+ return {
337
+ ...update.extra,
338
+ localStatus: update.localStatus,
339
+ decisionId: update.decisionId,
340
+ decisionRevision: update.decisionRevision,
341
+ decisionOutcome: update.decisionOutcome,
342
+ decisionStatus: update.decisionStatus,
343
+ decidedAt: update.decidedAt,
344
+ ...(update.enforcedAction === undefined
345
+ ? {}
346
+ : { enforcedAction: update.enforcedAction }),
347
+ ...(update.enforcedAt === undefined ? {} : { enforcedAt: update.enforcedAt }),
348
+ };
349
+ }
@@ -0,0 +1,64 @@
1
+ import type { TablesRelationalConfig } from 'drizzle-orm';
2
+ import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
3
+ import type { ModerationTransactionRunner } from '../../store/types.js';
4
+
5
+ /**
6
+ * The drizzle handle this package's Postgres stores talk to.
7
+ *
8
+ * Deliberately the BASE `PgDatabase` rather than a `PostgresJsDatabase`: the
9
+ * stores must accept both the pool handle an adopter builds and the `tx` handed
10
+ * to a `db.transaction` callback, and those are different types. Written this
11
+ * way, one signature serves both and no store needs to know which it holds.
12
+ *
13
+ * ## The generic that has to be `Record<string, unknown>`, and the error you get
14
+ *
15
+ * `TablesRelationalConfig` and `Record<string, unknown>` are the only spellings
16
+ * that accept both. The narrow one a reader reaches for first —
17
+ * `Record<string, never>` for the schema — accepts NEITHER (`TS2345`, the schema
18
+ * generic is invariant).
19
+ *
20
+ * The consequence lands on the ADOPTER, so it is worth stating: a handle built
21
+ * with NO schema (`drizzle(client)`, or `PostgresJsDatabase<Record<string, never>>`)
22
+ * is not assignable either, and the compiler explains it as
23
+ * `Seems like the schema generic is missing - did you forget to add it to your DB
24
+ * type?` — an error about `query`, on a store call that never touches the
25
+ * relational query builder. Pass your schema to `drizzle()` (or use
26
+ * `createDatabase({ databaseUrl, schema })` from `@oxy.so/db`) and it resolves.
27
+ * Both directions are verified in this package's own type-check.
28
+ */
29
+ export type ModerationPgHandle = PgDatabase<
30
+ PgQueryResultHKT,
31
+ Record<string, unknown>,
32
+ TablesRelationalConfig
33
+ >;
34
+
35
+ /**
36
+ * The transaction runner, in Postgres.
37
+ *
38
+ * READ COMMITTED, explicitly, and not `repeatable read`. Neither multi-statement
39
+ * transaction this package runs — a report insert plus an outbox upsert, an event
40
+ * update plus an outbox upsert — reads-then-decides in a way snapshot isolation
41
+ * protects, so `repeatable read` would import `40001` serialization failures and
42
+ * a retry loop for no benefit.
43
+ *
44
+ * What it deliberately does NOT promise: intake's duplicate-check-then-insert is
45
+ * not serialized at READ COMMITTED, and the "one report per reporter per object"
46
+ * unique index is explicitly the application's responsibility.
47
+ *
48
+ * The failure this runner exists to make impossible is subtler than Mongo's. A
49
+ * store call given `db` instead of `tx` inside the callback runs on a DIFFERENT
50
+ * pooled connection and commits on its own — no error, no warning, and the
51
+ * atomicity the outbox exists for is simply gone. `postgresOutboxStore.enqueue`
52
+ * refuses a handle that is not a transaction for exactly that reason.
53
+ */
54
+ export function postgresTransactionRunner(
55
+ db: ModerationPgHandle,
56
+ ): ModerationTransactionRunner<ModerationPgHandle> {
57
+ return {
58
+ async run<T>(operation: (tx: ModerationPgHandle) => Promise<T>): Promise<T> {
59
+ return await db.transaction(async (tx) => await operation(tx), {
60
+ isolationLevel: 'read committed',
61
+ });
62
+ },
63
+ };
64
+ }