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