@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,281 @@
1
+ import { getTableName, sql } from 'drizzle-orm';
2
+ import {
3
+ boolean,
4
+ check,
5
+ index,
6
+ integer,
7
+ text,
8
+ varchar,
9
+ type PgColumn,
10
+ type PgTable,
11
+ } from 'drizzle-orm/pg-core';
12
+ import {
13
+ createdAt,
14
+ generatedId,
15
+ inList,
16
+ textArrayLiteral,
17
+ timestamptz,
18
+ updatedAt,
19
+ } from '@oxy.so/db';
20
+ import type { ModerationLocalStatus } from '../types.js';
21
+
22
+ /**
23
+ * The moderation half of an application's report table.
24
+ *
25
+ * The Postgres twin of `moderationReportSchemaFields`, and the split is the same
26
+ * one: the application owns the table — its name, its own extra columns, whatever
27
+ * verdict field it already had — and this package owns the SHAPE of the columns
28
+ * it queries, plus the indexes those queries depend on.
29
+ *
30
+ * ```ts
31
+ * const REPORT_MODERATION = {
32
+ * reportedTypes: ['listing', 'review'],
33
+ * categories: ['spam', 'harassment'],
34
+ * } as const;
35
+ *
36
+ * export const reports = pgTable(
37
+ * 'reports',
38
+ * {
39
+ * ...moderationReportColumns(REPORT_MODERATION),
40
+ * // …the application's own columns
41
+ * },
42
+ * moderationReportTableExtras(REPORT_MODERATION),
43
+ * );
44
+ * ```
45
+ *
46
+ * **`moderationReportTableExtras` takes the OPTIONS and returns the callback**,
47
+ * rather than taking the built columns directly. It has to: the CHECK on
48
+ * `reported_type` and the containment CHECK on `categories` are built from the
49
+ * same tuples that constrain the columns, and drizzle does not carry those
50
+ * tuples onto the built column (`enumValues` is `undefined` there — measured).
51
+ * Passing one options object to both calls is what keeps the column and its
52
+ * constraint from drifting apart.
53
+ */
54
+
55
+ /**
56
+ * The local statuses, as the CHECK renders them.
57
+ *
58
+ * Spelled out beside the DDL so it cannot acquire an ORM dependency. `satisfies`
59
+ * refuses a value outside the union; `postgresSchema.test.ts` asserts this tuple
60
+ * and `MODERATION_LOCAL_STATUSES` still agree, which is what catches an OMISSION
61
+ * — the direction a type cannot see.
62
+ */
63
+ const LOCAL_STATUSES = [
64
+ 'received',
65
+ 'queued',
66
+ 'submitted',
67
+ 'delivery_failed',
68
+ 'closed',
69
+ ] as const satisfies readonly ModerationLocalStatus[];
70
+
71
+ /** Mongo's `detailsMaxLength ?? 2_000`, for the same reason: it bounds free text. */
72
+ const DEFAULT_DETAILS_MAX_LENGTH = 2_000;
73
+
74
+ export interface ModerationReportColumnOptions {
75
+ /**
76
+ * The application's reportable types. Constrains the stored value.
77
+ *
78
+ * This is NOT the set of DELIVERABLE types — a type with no subject provider is
79
+ * still reportable and still stored, it simply never leaves. Registering a
80
+ * provider is what makes a type deliverable, and the two lists are allowed to
81
+ * differ.
82
+ *
83
+ * Omitted, no CHECK is created. Supplying it later is a migration.
84
+ */
85
+ readonly reportedTypes?: readonly string[];
86
+ /** The application's report categories. Omitted, no CHECK is created. */
87
+ readonly categories?: readonly string[];
88
+ /** Maximum length of the reporter's free text. */
89
+ readonly detailsMaxLength?: number;
90
+ }
91
+
92
+ /**
93
+ * The columns this package reads and writes, to spread into the application's
94
+ * own `pgTable`.
95
+ *
96
+ * Every SQL name is written out. Drizzle would derive one from the property, and
97
+ * its derivation mangles digit- and capital-adjacent names — `crowdSourceReportId`
98
+ * becomes `crowd_source_report_id`, which is a working column with a name nobody
99
+ * chose and which no gate would notice.
100
+ */
101
+ export function moderationReportColumns(options: ModerationReportColumnOptions = {}) {
102
+ return {
103
+ /**
104
+ * `text`, not `uuid`, and generated in the application.
105
+ *
106
+ * `generatedId()` holds a uuid v7 for a row created here and a 24-character
107
+ * ObjectId hex for every row that existed before a Mongo cutover, so one id
108
+ * space serves both. The consequence that matters downstream: a malformed id
109
+ * matches no rows instead of raising `22P02`, which is exactly the Mongo
110
+ * behaviour the delivery path already handles.
111
+ */
112
+ id: generatedId(),
113
+
114
+ reportedType: text('reported_type').notNull(),
115
+ reportedId: text('reported_id').notNull(),
116
+ /**
117
+ * The reporting Oxy user id.
118
+ *
119
+ * The Oxy subject IS the identity binding proof, so there is no separate
120
+ * binding step for an application to implement — but it does mean this
121
+ * column must hold an Oxy user id and not an application-local one.
122
+ */
123
+ reporter: text('reporter').notNull(),
124
+ /**
125
+ * `text[]`, written and read WHOLE.
126
+ *
127
+ * This package sets it at intake and reads it at delivery; it never queries
128
+ * by element, so an array column is the port rather than a join table. A
129
+ * containment CHECK constrains the values when the application declares them.
130
+ */
131
+ categories: text('categories').array().notNull(),
132
+ details: varchar('details', {
133
+ length: options.detailsMaxLength ?? DEFAULT_DETAILS_MAX_LENGTH,
134
+ }),
135
+
136
+ localStatus: text('local_status').notNull().default('received'),
137
+ /**
138
+ * Why a report is not going anywhere, in words an operator can read.
139
+ *
140
+ * Stored rather than inferred from a missing outbox row. A missing row is
141
+ * also what a lost write looks like, and the two need to be distinguishable
142
+ * months later without re-deriving which types had providers at the time.
143
+ */
144
+ localStatusReason: varchar('local_status_reason', { length: 300 }),
145
+
146
+ crowdSourceReportId: text('crowdsource_report_id'),
147
+ crowdSourceCaseId: text('crowdsource_case_id'),
148
+ crowdSourceMerged: boolean('crowdsource_merged'),
149
+ /** SHA-256 of the exact representation that was reviewed. */
150
+ contentSnapshotHash: text('content_snapshot_hash'),
151
+ submittedAt: timestamptz(),
152
+ lastDeliveryError: varchar('last_delivery_error', { length: 2_000 }),
153
+
154
+ decisionId: text('decision_id'),
155
+ /**
156
+ * The revision guard.
157
+ *
158
+ * Compared in the update's WHERE clause, so it is the database that refuses a
159
+ * stale write rather than a read-then-write in the application process.
160
+ */
161
+ decisionRevision: integer('decision_revision'),
162
+ decisionOutcome: text('decision_outcome'),
163
+ decisionStatus: text('decision_status'),
164
+ decidedAt: timestamptz(),
165
+ enforcedAction: text('enforced_action'),
166
+ enforcedAt: timestamptz(),
167
+
168
+ createdAt: createdAt(),
169
+ updatedAt: updatedAt(),
170
+ };
171
+ }
172
+
173
+ /** The column map, as the adopter's table carries it once built. */
174
+ export type ModerationReportColumns = ReturnType<typeof moderationReportColumns>;
175
+
176
+ /** Every column name the report store queries by. */
177
+ export type ModerationReportColumnName = keyof ModerationReportColumns;
178
+
179
+ /**
180
+ * The built columns, as `pgTable`'s third argument receives them.
181
+ *
182
+ * Deliberately NOT `ModerationReportTable`: drizzle calls that callback with the
183
+ * column map alone, which is not a table, so a parameter typed as one would
184
+ * refuse to be a `pgTable` callback at all.
185
+ */
186
+ export type ModerationReportBuiltColumns = Record<ModerationReportColumnName, PgColumn>;
187
+
188
+ /**
189
+ * A table carrying at least the moderation report columns.
190
+ *
191
+ * Structural on purpose: the adopter's table has its own name and its own extra
192
+ * columns, and a type derived from a concrete `pgTable` would name one table and
193
+ * therefore accept no other. What this asserts is the part that matters — that
194
+ * every column the report store reaches for is present.
195
+ */
196
+ export type ModerationReportTable = PgTable & Record<ModerationReportColumnName, PgColumn>;
197
+
198
+ /** Postgres truncates an identifier at 63 bytes, silently, and then names collide. */
199
+ const MAX_IDENTIFIER_BYTES = 63;
200
+
201
+ function identifier(parts: readonly string[]): string {
202
+ const name = parts.join('_');
203
+ if (Buffer.byteLength(name, 'utf8') > MAX_IDENTIFIER_BYTES) {
204
+ throw new Error(
205
+ `The moderation report index name '${name}' is ${Buffer.byteLength(name, 'utf8')} bytes; ` +
206
+ `Postgres truncates identifiers at ${MAX_IDENTIFIER_BYTES} and two names that differ ` +
207
+ 'only past the cut then collide. Give the report table a shorter name.',
208
+ );
209
+ }
210
+ return name;
211
+ }
212
+
213
+ /**
214
+ * The CHECKs and indexes the report table needs, as `pgTable`'s third argument.
215
+ *
216
+ * The three indexes are the same three `applyModerationReportIndexes` creates,
217
+ * and not optional: reconciliation scans `(local_status, created_at)` oldest-first
218
+ * on every sweep, and the decision worker looks a case up by
219
+ * `crowdsource_case_id` on every inbound decision. Without them both become
220
+ * sequential scans that grow with the table.
221
+ *
222
+ * The compound uniqueness of "one report per reporter per object" is the
223
+ * APPLICATION's to declare — some applications allow a reporter to file twice
224
+ * under different categories — so it is not created here. Intake's duplicate
225
+ * check reads `(reporter, reported_id, reported_type)`, so that one is indexed
226
+ * for it either way.
227
+ *
228
+ * Index names are derived from the adopter's own table name and length-checked,
229
+ * because two names that differ only past the 63rd byte are the same name to
230
+ * Postgres.
231
+ */
232
+ export function moderationReportTableExtras(options: ModerationReportColumnOptions = {}) {
233
+ return (columns: ModerationReportBuiltColumns) => {
234
+ /**
235
+ * The adopter's table name, read off one of its own columns — `pgTable`
236
+ * calls this with the BUILT columns, each of which carries its table. That
237
+ * is what lets the index names belong to the adopter's table without being
238
+ * passed in a second time and drifting.
239
+ */
240
+ const prefix = getTableName(columns.localStatus.table);
241
+
242
+ return [
243
+ check(
244
+ identifier([prefix, 'local_status_check']),
245
+ sql`${columns.localStatus} in (${sql.raw(inList(LOCAL_STATUSES))})`,
246
+ ),
247
+ ...(options.reportedTypes === undefined
248
+ ? []
249
+ : [
250
+ check(
251
+ identifier([prefix, 'reported_type_check']),
252
+ sql`${columns.reportedType} in (${sql.raw(inList(options.reportedTypes))})`,
253
+ ),
254
+ ]),
255
+ ...(options.categories === undefined
256
+ ? []
257
+ : [
258
+ /**
259
+ * Containment, not equality: a report carries a SUBSET of the
260
+ * declared categories, and `<@` is trivially satisfied by an empty
261
+ * array — which the application's own validation refuses before this
262
+ * is ever reached.
263
+ */
264
+ check(
265
+ identifier([prefix, 'categories_check']),
266
+ sql`${columns.categories} <@ ${sql.raw(textArrayLiteral(options.categories))}`,
267
+ ),
268
+ ]),
269
+ index(identifier([prefix, 'local_status_created_at_idx'])).on(
270
+ columns.localStatus,
271
+ columns.createdAt,
272
+ ),
273
+ index(identifier([prefix, 'crowdsource_case_id_idx'])).on(columns.crowdSourceCaseId),
274
+ index(identifier([prefix, 'reporter_object_idx'])).on(
275
+ columns.reporter,
276
+ columns.reportedId,
277
+ columns.reportedType,
278
+ ),
279
+ ];
280
+ };
281
+ }
@@ -0,0 +1,186 @@
1
+ import { and, eq, inArray, sql } from 'drizzle-orm';
2
+ import type {
3
+ ModerationEnforcementKey,
4
+ ModerationEnforcementStore,
5
+ } from '../../store/types.js';
6
+ import type { ModerationTables } from '../tables.js';
7
+ import type { ModerationPgHandle } from './transaction.js';
8
+
9
+ /**
10
+ * The enforcement ledger, in Postgres.
11
+ *
12
+ * Two operations carry everything: the CLAIM that makes an action happen at most
13
+ * once, and the reversal LOOKUP that decides what a correction puts back. Two of
14
+ * this package's eleven proven mutations attack the second one, which makes its
15
+ * predicate the most load-bearing SQL in the Postgres half.
16
+ *
17
+ * ## The idempotency key IS the primary key
18
+ *
19
+ * `decision_id + decision_revision + action`. Mongo needed a surrogate `_id` plus
20
+ * a unique index on that triple; here the triple is the key, so
21
+ * `onConflictDoNothing()` needs no explicit `target` — there is only one
22
+ * constraint it could mean — and there is no second object to keep in step.
23
+ *
24
+ * `decision_revision` being IN the key is what lets a correction act: a new
25
+ * revision is a different row, so the restore it asks for is allowed to happen
26
+ * while still being impossible to apply twice itself.
27
+ *
28
+ * ## Addressed by the key, never by a record id
29
+ *
30
+ * Every write after the claim addresses the row by the same three values the
31
+ * claim used. PostgreSQL reaches it through the composite primary key, so no
32
+ * opaque record id crosses the port.
33
+ */
34
+ export function postgresEnforcementStore(input: {
35
+ db: ModerationPgHandle;
36
+ tables: ModerationTables;
37
+ }): ModerationEnforcementStore {
38
+ const { db } = input;
39
+ const enforcements = input.tables.enforcements;
40
+
41
+ /** The primary key, as a predicate. Addresses at most one row, by construction. */
42
+ const keyFilter = (key: ModerationEnforcementKey) =>
43
+ and(
44
+ eq(enforcements.decisionId, key.decisionId),
45
+ eq(enforcements.decisionRevision, key.decisionRevision),
46
+ eq(enforcements.action, key.action),
47
+ );
48
+
49
+ return {
50
+ async claim(row) {
51
+ /**
52
+ * The insert IS the check, and a lost race is zero rows rather than an
53
+ * error — the same shape as the event store's claim, and for the same
54
+ * reason: there is no catch block whose predicate could be widened into
55
+ * swallowing a real fault as "another delivery already handled it".
56
+ *
57
+ * `created_at` and `updated_at` are written from the caller's `now`. The
58
+ * Mongo store lets Mongoose own them; here nothing does, and the reversal
59
+ * lookup ORDERS BY `created_at`, so the clock that decides which row is
60
+ * "most recent" is the caller's rather than two different defaults'.
61
+ */
62
+ const rows = await db
63
+ .insert(enforcements)
64
+ .values({
65
+ decisionId: row.decisionId,
66
+ decisionRevision: row.decisionRevision,
67
+ action: row.action,
68
+ caseId: row.caseId,
69
+ subjectType: row.subjectType,
70
+ subjectId: row.subjectId,
71
+ outcome: row.outcome,
72
+ ...(row.recommendedAction === undefined
73
+ ? {}
74
+ : { recommendedAction: row.recommendedAction }),
75
+ reason: row.reason,
76
+ mode: row.mode,
77
+ applied: false,
78
+ createdAt: row.now,
79
+ updatedAt: row.now,
80
+ })
81
+ .onConflictDoNothing()
82
+ .returning({ decisionId: enforcements.decisionId });
83
+
84
+ return rows.length === 1;
85
+ },
86
+
87
+ async markSkipped(key, { skippedReason, recordedAs, now }) {
88
+ await db
89
+ .update(enforcements)
90
+ .set({
91
+ skippedReason,
92
+ // Absent means leave it: `recorded_as` only carries a value when the
93
+ // effect said the planned action amounted to something else.
94
+ ...(recordedAs === undefined ? {} : { recordedAs }),
95
+ updatedAt: now,
96
+ })
97
+ .where(keyFilter(key));
98
+ },
99
+
100
+ async markApplied(key, { appliedAt, previousState, now }) {
101
+ await db
102
+ .update(enforcements)
103
+ .set({
104
+ applied: true,
105
+ appliedAt,
106
+ /**
107
+ * Written as `jsonb`, so it round-trips as the object it was rather
108
+ * than as its `JSON.stringify` — a `text` column would hand a reversal
109
+ * a string, and `previousState?.status` on a string is `undefined`,
110
+ * which reads as "moderation displaced nothing" and restores a guess.
111
+ *
112
+ * Absent means leave it NULL: an action that changed state records what
113
+ * it displaced, and one that did not must not claim it displaced
114
+ * nothing-in-particular.
115
+ */
116
+ ...(previousState === undefined ? {} : { previousState }),
117
+ updatedAt: now,
118
+ })
119
+ .where(keyFilter(key));
120
+ },
121
+
122
+ async releaseClaim(key) {
123
+ await db.delete(enforcements).where(keyFilter(key));
124
+ },
125
+
126
+ async latestApplied({ subjectType, subjectId, actions }) {
127
+ /**
128
+ * The reversal lookup, and every clause in it is load-bearing.
129
+ *
130
+ * `eq(applied, true)` — a row that was RECORDED and never carried out
131
+ * describes a state change that never happened, and it carries no
132
+ * `previous_state` at all. Reading it hands a reversal nothing and the
133
+ * application's fallback publishes a draft moderation only ever hid.
134
+ *
135
+ * `inArray(action, actions)` — one action may reverse several. The most
136
+ * recent APPLIED row across the whole declared set wins, so `apply`
137
+ * receives whatever actually happened last rather than whichever action
138
+ * happens to be first in the array. Note the membership is POSITIVE: a
139
+ * negated set (`not in (…)`) would render as `<> ALL (…)`, which no btree
140
+ * can serve, and the supporting index would quietly stop being used.
141
+ *
142
+ * `created_at desc NULLS LAST` with `limit(1)` — newest first, and the
143
+ * NULLS placement is written out because drizzle's two spellings of "desc"
144
+ * DISAGREE and the difference is a blocking sort.
145
+ *
146
+ * Measured on Postgres 17. `.desc()` inside a drizzle INDEX emits
147
+ * `DESC NULLS LAST`; `desc(column)` in an ORDER BY emits plain `DESC`,
148
+ * which in Postgres means NULLS FIRST. Those two orderings do not match, so
149
+ * no index can satisfy the sort and the plan gains a `Sort` node — even
150
+ * though `created_at` is NOT NULL and the two orderings can differ by
151
+ * nothing at all. With `nulls last` the plan is a plain index scan under
152
+ * the Limit:
153
+ *
154
+ * desc -> Limit → Sort (created_at DESC) → Index Scan …
155
+ * desc nulls last -> Limit → Index Scan …
156
+ *
157
+ * Correct results either way, and a sort that grows with the number of
158
+ * enforcement rows for one subject. `postgresEnforcementStore.test.ts`
159
+ * asserts the absence of that `Sort` against the real planner.
160
+ */
161
+ const rows = await db
162
+ .select({
163
+ action: enforcements.action,
164
+ previousState: enforcements.previousState,
165
+ })
166
+ .from(enforcements)
167
+ .where(
168
+ and(
169
+ eq(enforcements.subjectType, subjectType),
170
+ eq(enforcements.subjectId, subjectId),
171
+ inArray(enforcements.action, [...actions]),
172
+ eq(enforcements.applied, true),
173
+ ),
174
+ )
175
+ .orderBy(sql`${enforcements.createdAt} desc nulls last`)
176
+ .limit(1);
177
+
178
+ const [row] = rows;
179
+ if (row === undefined) return null;
180
+ return {
181
+ action: row.action,
182
+ ...(row.previousState === null ? {} : { previousState: row.previousState }),
183
+ };
184
+ },
185
+ };
186
+ }
@@ -0,0 +1,112 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import type { ModerationEventStore } from '../../store/types.js';
3
+ import type { ModerationTables } from '../tables.js';
4
+ import type { ModerationPgHandle } from './transaction.js';
5
+
6
+ /**
7
+ * The inbound webhook event log, in Postgres.
8
+ *
9
+ * ## The one place this backend is structurally better rather than equivalent
10
+ *
11
+ * Everything else in this port aims for parity. This claim does not: it removes a
12
+ * failure mode instead of handling it, and the difference is worth being precise
13
+ * about, because "better" is otherwise just an adjective.
14
+ *
15
+ * **Mongo's claim throws and catches.** It inserts, and reads `code === 11000` to
16
+ * tell "somebody else has this event" from a real fault; everything that is not
17
+ * 11000 is rethrown, so a lost connection or a failover answers non-2xx and the
18
+ * event stays on the sender's retry schedule. That property is correct — and it
19
+ * lives in a PREDICATE. `catch { return false }` is one keystroke away, it
20
+ * type-checks, and it turns a connection failure into "already processed": the
21
+ * receiver answers 200 and a decision is retired that nobody ever handled. The
22
+ * only thing standing between those two behaviours is a conditional somebody
23
+ * could widen, and a test can only catch that by injecting a driver failure.
24
+ *
25
+ * **Postgres's claim does not throw at all.** `ON CONFLICT DO NOTHING` plus
26
+ * `RETURNING` makes a duplicate a ROW COUNT rather than an error: one row means
27
+ * this call took the claim, zero means somebody else holds it. So there is no
28
+ * catch block here — and therefore
29
+ *
30
+ * - no predicate to widen,
31
+ * - no code path that can convert a fault into a negative answer,
32
+ * - and the "rethrow everything else" guarantee holds by the ABSENCE of code
33
+ * rather than by the presence of correct code.
34
+ *
35
+ * That is the mechanism: the Mongo version is a property of code that exists and
36
+ * can be edited wrongly; this one is a property of code that does not exist. Only
37
+ * the second cannot be broken by a well-meaning change.
38
+ *
39
+ * The consequence for the mutation suite, stated because its absence would
40
+ * otherwise look like a gap: there is nothing to DELETE on the insert side, so no
41
+ * mutation can attack it. The one that exists attacks the READ — `rows.length === 1`
42
+ * collapsing to `true`, which would hand the same event to two handlers.
43
+ */
44
+ export function postgresEventStore(input: {
45
+ db: ModerationPgHandle;
46
+ tables: ModerationTables;
47
+ }): ModerationEventStore<ModerationPgHandle> {
48
+ const { db } = input;
49
+ const events = input.tables.events;
50
+
51
+ return {
52
+ async claim({ eventId, receivedAt, expiresAt }) {
53
+ /**
54
+ * The insert IS the claim, and a lost race is zero rows rather than an
55
+ * exception. `created_at`/`updated_at` are left to their column defaults:
56
+ * unlike the outbox, nothing here needs a repeated write to be provably a
57
+ * no-op, so the database's clock is the simpler authority.
58
+ */
59
+ const rows = await db
60
+ .insert(events)
61
+ .values({ id: eventId, state: 'claimed', receivedAt, expiresAt })
62
+ .onConflictDoNothing({ target: events.id })
63
+ .returning({ id: events.id });
64
+
65
+ return rows.length === 1;
66
+ },
67
+
68
+ /** Give the claim back so a redelivery can be processed. */
69
+ async release(eventId) {
70
+ await db.delete(events).where(eq(events.id, eventId));
71
+ },
72
+
73
+ async markQueued({ eventId, type, caseId, payload, now }, tx) {
74
+ /**
75
+ * On the CALLER's transaction, which is the whole point: this row's
76
+ * completion and the outbox row that carries the work commit together, or a
77
+ * crash between them leaves an event permanently deduplicated with no work
78
+ * queued — a decision silently lost, with a row saying it arrived.
79
+ */
80
+ await tx
81
+ .update(events)
82
+ .set({
83
+ type,
84
+ caseId,
85
+ payload,
86
+ state: 'queued',
87
+ queuedAt: now,
88
+ updatedAt: now,
89
+ })
90
+ .where(eq(events.id, eventId));
91
+ },
92
+
93
+ async markIgnored({ eventId, type, caseId, now }) {
94
+ await db
95
+ .update(events)
96
+ .set({
97
+ type,
98
+ /**
99
+ * Absent means LEAVE IT, not write something. An event type carrying no
100
+ * case id is ordinary — `case.created` before a case is linked — and
101
+ * `caseId: String(caseId)` would store the four characters `null` or
102
+ * the nine characters `undefined`, which then reads as a case id
103
+ * everywhere downstream. Postgres keeps the column NULL.
104
+ */
105
+ ...(caseId === undefined ? {} : { caseId }),
106
+ state: 'ignored',
107
+ updatedAt: now,
108
+ })
109
+ .where(eq(events.id, eventId));
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,72 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import type { PgTable } from 'drizzle-orm/pg-core';
3
+ import type { ModerationStore } from '../../store/types.js';
4
+ import type { ModerationReportFields } from '../../types.js';
5
+ import type { ModerationReportTable } from '../reportColumns.js';
6
+ import type { ModerationTables } from '../tables.js';
7
+ import { postgresEnforcementStore } from './enforcement.js';
8
+ import { postgresEventStore } from './events.js';
9
+ import { postgresOutboxStore } from './outbox.js';
10
+ import { postgresReportStore } from './reports.js';
11
+ import { postgresTransactionRunner, type ModerationPgHandle } from './transaction.js';
12
+
13
+ /**
14
+ * Everything this package writes, in Postgres.
15
+ *
16
+ * One factory rather than five, because the five members must share one handle: a
17
+ * report and its outbox row commit in the SAME transaction, and a store assembled
18
+ * from two handles would type-check perfectly and quietly lose that.
19
+ *
20
+ * Unlike the Mongo factory, this one CREATES NOTHING. There is no schema to
21
+ * register: the adopter's own migration created the tables, generated from the
22
+ * definitions this package exports, in the adopter's own journal. That asymmetry
23
+ * is deliberate and is the reason no migrations folder ships here — two journals
24
+ * against one `drizzle.__drizzle_migrations` table interleave, and the loser is
25
+ * skipped in silence with exit 0.
26
+ */
27
+ export function postgresModerationStore<TReport extends ModerationReportFields>(input: {
28
+ db: ModerationPgHandle;
29
+ /** The application's own report table, built from `moderationReportColumns`. */
30
+ reportTable: ModerationReportTable;
31
+ /** The three tables this package owns, from `moderationTables`. */
32
+ tables: ModerationTables;
33
+ }): ModerationStore<TReport, ModerationPgHandle> {
34
+ const { db, reportTable, tables } = input;
35
+
36
+ return {
37
+ transaction: postgresTransactionRunner(db),
38
+ outbox: postgresOutboxStore({ db, tables }),
39
+ events: postgresEventStore({ db, tables }),
40
+ enforcement: postgresEnforcementStore({ db, tables }),
41
+ reports: postgresReportStore<TReport>({ db, reportTable }),
42
+
43
+ /**
44
+ * ASSERTS the schema, rather than creating it.
45
+ *
46
+ * The Mongo half calls `init()` and builds indexes; there is nothing here to
47
+ * build, so what remains is worth doing for its own sake: confirming the four
48
+ * tables this package queries exist BEFORE the first report is filed rather
49
+ * than at the first delivery.
50
+ *
51
+ * A missing table raises `42P01 undefined_table` and names it. `limit(0)`
52
+ * means Postgres parses and plans each statement — which is what proves the
53
+ * relation resolves — without reading a row.
54
+ *
55
+ * It cannot check COLUMNS. An adopter whose migration is a version behind
56
+ * gets a compile error from `ModerationReportTable` for a missing column and
57
+ * a `42703` from the query for a stale migration; both are loud, and neither
58
+ * is this function's job.
59
+ */
60
+ async ensureSchema() {
61
+ const present: readonly PgTable[] = [
62
+ tables.outbox,
63
+ tables.events,
64
+ tables.enforcements,
65
+ reportTable,
66
+ ];
67
+ for (const table of present) {
68
+ await db.select({ resolves: sql`1` }).from(table).limit(0);
69
+ }
70
+ },
71
+ };
72
+ }