@shware/workflow 3.3.0 → 4.1.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 (350) hide show
  1. package/README.md +230 -40
  2. package/dist/apns.cjs +113 -26
  3. package/dist/apns.cjs.map +1 -1
  4. package/dist/apns.d.cts +52 -6
  5. package/dist/apns.d.cts.map +1 -1
  6. package/dist/apns.d.mts +52 -6
  7. package/dist/apns.d.mts.map +1 -1
  8. package/dist/apns.mjs +111 -27
  9. package/dist/apns.mjs.map +1 -1
  10. package/dist/aws/engine-step.cjs +5 -5
  11. package/dist/aws/engine-step.cjs.map +1 -1
  12. package/dist/aws/engine-step.d.cts +8 -2
  13. package/dist/aws/engine-step.d.cts.map +1 -1
  14. package/dist/aws/engine-step.d.mts +8 -2
  15. package/dist/aws/engine-step.d.mts.map +1 -1
  16. package/dist/aws/engine-step.mjs +5 -5
  17. package/dist/aws/engine-step.mjs.map +1 -1
  18. package/dist/aws/index.cjs +50 -6
  19. package/dist/aws/index.cjs.map +1 -1
  20. package/dist/aws/index.d.cts +4 -3
  21. package/dist/aws/index.d.cts.map +1 -1
  22. package/dist/aws/index.d.mts +4 -3
  23. package/dist/aws/index.d.mts.map +1 -1
  24. package/dist/aws/index.mjs +49 -7
  25. package/dist/aws/index.mjs.map +1 -1
  26. package/dist/aws/instances.cjs +29 -6
  27. package/dist/aws/instances.cjs.map +1 -1
  28. package/dist/aws/instances.d.cts +11 -1
  29. package/dist/aws/instances.d.cts.map +1 -1
  30. package/dist/aws/instances.d.mts +11 -1
  31. package/dist/aws/instances.d.mts.map +1 -1
  32. package/dist/aws/instances.mjs +27 -4
  33. package/dist/aws/instances.mjs.map +1 -1
  34. package/dist/client.cjs +4 -1
  35. package/dist/client.cjs.map +1 -1
  36. package/dist/client.d.cts +2 -2
  37. package/dist/client.d.cts.map +1 -1
  38. package/dist/client.d.mts +2 -2
  39. package/dist/client.d.mts.map +1 -1
  40. package/dist/client.mjs +4 -1
  41. package/dist/client.mjs.map +1 -1
  42. package/dist/cloudflare/bindings.cjs +28 -25
  43. package/dist/cloudflare/bindings.cjs.map +1 -1
  44. package/dist/cloudflare/bindings.d.cts +15 -129
  45. package/dist/cloudflare/bindings.d.cts.map +1 -1
  46. package/dist/cloudflare/bindings.d.mts +15 -129
  47. package/dist/cloudflare/bindings.d.mts.map +1 -1
  48. package/dist/cloudflare/bindings.mjs +28 -23
  49. package/dist/cloudflare/bindings.mjs.map +1 -1
  50. package/dist/cloudflare/d1-env.cjs +6 -6
  51. package/dist/cloudflare/d1-env.cjs.map +1 -1
  52. package/dist/cloudflare/d1-env.d.cts +5 -5
  53. package/dist/cloudflare/d1-env.d.mts +5 -5
  54. package/dist/cloudflare/d1-env.mjs +6 -6
  55. package/dist/cloudflare/d1-env.mjs.map +1 -1
  56. package/dist/cloudflare/index.cjs +29 -18
  57. package/dist/cloudflare/index.d.cts +8 -4
  58. package/dist/cloudflare/index.d.mts +8 -4
  59. package/dist/cloudflare/index.mjs +9 -5
  60. package/dist/cloudflare/runner.cjs +29 -14
  61. package/dist/cloudflare/runner.cjs.map +1 -1
  62. package/dist/cloudflare/runner.d.cts +9 -2
  63. package/dist/cloudflare/runner.d.cts.map +1 -1
  64. package/dist/cloudflare/runner.d.mts +9 -2
  65. package/dist/cloudflare/runner.d.mts.map +1 -1
  66. package/dist/cloudflare/runner.mjs +28 -13
  67. package/dist/cloudflare/runner.mjs.map +1 -1
  68. package/dist/cloudflare/schema.sql +27 -6
  69. package/dist/cloudflare/worker.cjs +19 -9
  70. package/dist/cloudflare/worker.cjs.map +1 -1
  71. package/dist/cloudflare/worker.d.cts +2 -2
  72. package/dist/cloudflare/worker.d.cts.map +1 -1
  73. package/dist/cloudflare/worker.d.mts +2 -2
  74. package/dist/cloudflare/worker.d.mts.map +1 -1
  75. package/dist/cloudflare/worker.mjs +19 -9
  76. package/dist/cloudflare/worker.mjs.map +1 -1
  77. package/dist/config.cjs +21 -5
  78. package/dist/config.cjs.map +1 -1
  79. package/dist/config.mjs +21 -5
  80. package/dist/config.mjs.map +1 -1
  81. package/dist/dsl/action.cjs +3 -6
  82. package/dist/dsl/action.cjs.map +1 -1
  83. package/dist/dsl/action.d.cts +12 -11
  84. package/dist/dsl/action.d.cts.map +1 -1
  85. package/dist/dsl/action.d.mts +12 -11
  86. package/dist/dsl/action.d.mts.map +1 -1
  87. package/dist/dsl/action.mjs +3 -6
  88. package/dist/dsl/action.mjs.map +1 -1
  89. package/dist/dsl/bundle.cjs +35 -31
  90. package/dist/dsl/bundle.cjs.map +1 -1
  91. package/dist/dsl/bundle.d.cts.map +1 -1
  92. package/dist/dsl/bundle.d.mts.map +1 -1
  93. package/dist/dsl/bundle.mjs +35 -31
  94. package/dist/dsl/bundle.mjs.map +1 -1
  95. package/dist/dsl/condition.cjs +13 -1
  96. package/dist/dsl/condition.cjs.map +1 -1
  97. package/dist/dsl/condition.d.cts +9 -1
  98. package/dist/dsl/condition.d.cts.map +1 -1
  99. package/dist/dsl/condition.d.mts +9 -1
  100. package/dist/dsl/condition.d.mts.map +1 -1
  101. package/dist/dsl/condition.mjs +13 -2
  102. package/dist/dsl/condition.mjs.map +1 -1
  103. package/dist/dsl/flow.cjs +33 -11
  104. package/dist/dsl/flow.cjs.map +1 -1
  105. package/dist/dsl/flow.d.cts +9 -1
  106. package/dist/dsl/flow.d.cts.map +1 -1
  107. package/dist/dsl/flow.d.mts +9 -1
  108. package/dist/dsl/flow.d.mts.map +1 -1
  109. package/dist/dsl/flow.mjs +34 -13
  110. package/dist/dsl/flow.mjs.map +1 -1
  111. package/dist/dsl/workflow.cjs +2 -3
  112. package/dist/dsl/workflow.cjs.map +1 -1
  113. package/dist/dsl/workflow.d.cts.map +1 -1
  114. package/dist/dsl/workflow.d.mts.map +1 -1
  115. package/dist/dsl/workflow.mjs +3 -4
  116. package/dist/dsl/workflow.mjs.map +1 -1
  117. package/dist/engine/actions.cjs +1 -8
  118. package/dist/engine/actions.cjs.map +1 -1
  119. package/dist/engine/actions.d.cts +1 -12
  120. package/dist/engine/actions.d.cts.map +1 -1
  121. package/dist/engine/actions.d.mts +1 -12
  122. package/dist/engine/actions.d.mts.map +1 -1
  123. package/dist/engine/actions.mjs +1 -8
  124. package/dist/engine/actions.mjs.map +1 -1
  125. package/dist/engine/condition.cjs +25 -5
  126. package/dist/engine/condition.cjs.map +1 -1
  127. package/dist/engine/condition.d.cts +12 -3
  128. package/dist/engine/condition.d.cts.map +1 -1
  129. package/dist/engine/condition.d.mts +12 -3
  130. package/dist/engine/condition.d.mts.map +1 -1
  131. package/dist/engine/condition.mjs +25 -6
  132. package/dist/engine/condition.mjs.map +1 -1
  133. package/dist/engine/index.cjs +3 -0
  134. package/dist/engine/index.d.cts +5 -5
  135. package/dist/engine/index.d.mts +5 -5
  136. package/dist/engine/index.mjs +4 -4
  137. package/dist/engine/interpreter.cjs +27 -20
  138. package/dist/engine/interpreter.cjs.map +1 -1
  139. package/dist/engine/interpreter.d.cts.map +1 -1
  140. package/dist/engine/interpreter.d.mts.map +1 -1
  141. package/dist/engine/interpreter.mjs +27 -20
  142. package/dist/engine/interpreter.mjs.map +1 -1
  143. package/dist/engine/ports.d.cts +15 -6
  144. package/dist/engine/ports.d.cts.map +1 -1
  145. package/dist/engine/ports.d.mts +15 -6
  146. package/dist/engine/ports.d.mts.map +1 -1
  147. package/dist/engine/retry.cjs +13 -0
  148. package/dist/engine/retry.cjs.map +1 -1
  149. package/dist/engine/retry.d.cts +12 -0
  150. package/dist/engine/retry.d.cts.map +1 -1
  151. package/dist/engine/retry.d.mts +12 -0
  152. package/dist/engine/retry.d.mts.map +1 -1
  153. package/dist/engine/retry.mjs +13 -1
  154. package/dist/engine/retry.mjs.map +1 -1
  155. package/dist/engine/time-scale.cjs +90 -15
  156. package/dist/engine/time-scale.cjs.map +1 -1
  157. package/dist/engine/time-scale.d.cts +9 -6
  158. package/dist/engine/time-scale.d.cts.map +1 -1
  159. package/dist/engine/time-scale.d.mts +9 -6
  160. package/dist/engine/time-scale.d.mts.map +1 -1
  161. package/dist/engine/time-scale.mjs +90 -16
  162. package/dist/engine/time-scale.mjs.map +1 -1
  163. package/dist/fcm.cjs +111 -38
  164. package/dist/fcm.cjs.map +1 -1
  165. package/dist/fcm.d.cts +45 -5
  166. package/dist/fcm.d.cts.map +1 -1
  167. package/dist/fcm.d.mts +45 -5
  168. package/dist/fcm.d.mts.map +1 -1
  169. package/dist/fcm.mjs +110 -39
  170. package/dist/fcm.mjs.map +1 -1
  171. package/dist/hash.cjs +6 -6
  172. package/dist/hash.cjs.map +1 -1
  173. package/dist/hash.mjs +6 -6
  174. package/dist/hash.mjs.map +1 -1
  175. package/dist/host/auth.cjs +49 -0
  176. package/dist/host/auth.cjs.map +1 -0
  177. package/dist/host/auth.d.cts +35 -0
  178. package/dist/host/auth.d.cts.map +1 -0
  179. package/dist/host/auth.d.mts +35 -0
  180. package/dist/host/auth.d.mts.map +1 -0
  181. package/dist/host/auth.mjs +46 -0
  182. package/dist/host/auth.mjs.map +1 -0
  183. package/dist/host/channels.cjs +118 -40
  184. package/dist/host/channels.cjs.map +1 -1
  185. package/dist/host/channels.d.cts +27 -1
  186. package/dist/host/channels.d.cts.map +1 -1
  187. package/dist/host/channels.d.mts +27 -1
  188. package/dist/host/channels.d.mts.map +1 -1
  189. package/dist/host/channels.mjs +121 -44
  190. package/dist/host/channels.mjs.map +1 -1
  191. package/dist/host/execute.cjs +14 -2
  192. package/dist/host/execute.cjs.map +1 -1
  193. package/dist/host/execute.d.cts +4 -4
  194. package/dist/host/execute.d.cts.map +1 -1
  195. package/dist/host/execute.d.mts +4 -4
  196. package/dist/host/execute.d.mts.map +1 -1
  197. package/dist/host/execute.mjs +13 -1
  198. package/dist/host/execute.mjs.map +1 -1
  199. package/dist/host/fresh-read.cjs +30 -0
  200. package/dist/host/fresh-read.cjs.map +1 -0
  201. package/dist/host/fresh-read.d.cts +18 -0
  202. package/dist/host/fresh-read.d.cts.map +1 -0
  203. package/dist/host/fresh-read.d.mts +18 -0
  204. package/dist/host/fresh-read.d.mts.map +1 -0
  205. package/dist/host/fresh-read.mjs +29 -0
  206. package/dist/host/fresh-read.mjs.map +1 -0
  207. package/dist/host/http.cjs +340 -0
  208. package/dist/host/http.cjs.map +1 -0
  209. package/dist/host/http.d.cts +58 -0
  210. package/dist/host/http.d.cts.map +1 -0
  211. package/dist/host/http.d.mts +58 -0
  212. package/dist/host/http.d.mts.map +1 -0
  213. package/dist/host/http.mjs +336 -0
  214. package/dist/host/http.mjs.map +1 -0
  215. package/dist/host/{router.cjs → ingest.cjs} +147 -175
  216. package/dist/host/ingest.cjs.map +1 -0
  217. package/dist/host/{router.d.cts → ingest.d.cts} +48 -30
  218. package/dist/host/ingest.d.cts.map +1 -0
  219. package/dist/host/{router.d.mts → ingest.d.mts} +48 -30
  220. package/dist/host/ingest.d.mts.map +1 -0
  221. package/dist/host/{router.mjs → ingest.mjs} +144 -173
  222. package/dist/host/ingest.mjs.map +1 -0
  223. package/dist/host/journey-host.cjs +66 -93
  224. package/dist/host/journey-host.cjs.map +1 -1
  225. package/dist/host/journey-host.d.cts +31 -26
  226. package/dist/host/journey-host.d.cts.map +1 -1
  227. package/dist/host/journey-host.d.mts +31 -26
  228. package/dist/host/journey-host.d.mts.map +1 -1
  229. package/dist/host/journey-host.mjs +66 -93
  230. package/dist/host/journey-host.mjs.map +1 -1
  231. package/dist/host/platform.cjs +21 -0
  232. package/dist/host/platform.cjs.map +1 -0
  233. package/dist/host/platform.d.cts +126 -0
  234. package/dist/host/platform.d.cts.map +1 -0
  235. package/dist/host/platform.d.mts +126 -0
  236. package/dist/host/platform.d.mts.map +1 -0
  237. package/dist/host/platform.mjs +19 -0
  238. package/dist/host/platform.mjs.map +1 -0
  239. package/dist/host/senders.cjs +18 -9
  240. package/dist/host/senders.cjs.map +1 -1
  241. package/dist/host/senders.d.cts +9 -4
  242. package/dist/host/senders.d.cts.map +1 -1
  243. package/dist/host/senders.d.mts +9 -4
  244. package/dist/host/senders.d.mts.map +1 -1
  245. package/dist/host/senders.mjs +18 -9
  246. package/dist/host/senders.mjs.map +1 -1
  247. package/dist/index.cjs +1 -1
  248. package/dist/index.mjs +1 -1
  249. package/dist/ir.cjs +5 -7
  250. package/dist/ir.cjs.map +1 -1
  251. package/dist/ir.d.cts +3 -12
  252. package/dist/ir.d.cts.map +1 -1
  253. package/dist/ir.d.mts +3 -12
  254. package/dist/ir.d.mts.map +1 -1
  255. package/dist/ir.mjs +5 -7
  256. package/dist/ir.mjs.map +1 -1
  257. package/dist/plan.cjs +27 -33
  258. package/dist/plan.cjs.map +1 -1
  259. package/dist/plan.d.cts +1 -1
  260. package/dist/plan.d.cts.map +1 -1
  261. package/dist/plan.d.mts +1 -1
  262. package/dist/plan.d.mts.map +1 -1
  263. package/dist/plan.mjs +27 -33
  264. package/dist/plan.mjs.map +1 -1
  265. package/dist/reporting/tracking.cjs +14 -4
  266. package/dist/reporting/tracking.cjs.map +1 -1
  267. package/dist/reporting/tracking.d.cts +10 -10
  268. package/dist/reporting/tracking.d.cts.map +1 -1
  269. package/dist/reporting/tracking.d.mts +10 -10
  270. package/dist/reporting/tracking.d.mts.map +1 -1
  271. package/dist/reporting/tracking.mjs +14 -4
  272. package/dist/reporting/tracking.mjs.map +1 -1
  273. package/dist/store/d1.cjs +88 -29
  274. package/dist/store/d1.cjs.map +1 -1
  275. package/dist/store/d1.d.cts +29 -11
  276. package/dist/store/d1.d.cts.map +1 -1
  277. package/dist/store/d1.d.mts +29 -11
  278. package/dist/store/d1.d.mts.map +1 -1
  279. package/dist/store/d1.mjs +88 -29
  280. package/dist/store/d1.mjs.map +1 -1
  281. package/dist/store/erasure.cjs +22 -0
  282. package/dist/store/erasure.cjs.map +1 -0
  283. package/dist/store/erasure.d.cts +18 -0
  284. package/dist/store/erasure.d.cts.map +1 -0
  285. package/dist/store/erasure.d.mts +18 -0
  286. package/dist/store/erasure.d.mts.map +1 -0
  287. package/dist/store/erasure.mjs +21 -0
  288. package/dist/store/erasure.mjs.map +1 -0
  289. package/dist/store/index.cjs +2 -0
  290. package/dist/store/index.cjs.map +1 -1
  291. package/dist/store/index.d.cts +67 -13
  292. package/dist/store/index.d.cts.map +1 -1
  293. package/dist/store/index.d.mts +67 -13
  294. package/dist/store/index.d.mts.map +1 -1
  295. package/dist/store/index.mjs +2 -1
  296. package/dist/store/index.mjs.map +1 -1
  297. package/dist/store/postgres-reporting.cjs +125 -62
  298. package/dist/store/postgres-reporting.cjs.map +1 -1
  299. package/dist/store/postgres-reporting.d.cts +30 -2
  300. package/dist/store/postgres-reporting.d.cts.map +1 -1
  301. package/dist/store/postgres-reporting.d.mts +30 -2
  302. package/dist/store/postgres-reporting.d.mts.map +1 -1
  303. package/dist/store/postgres-reporting.mjs +125 -62
  304. package/dist/store/postgres-reporting.mjs.map +1 -1
  305. package/dist/store/postgres.cjs +179 -60
  306. package/dist/store/postgres.cjs.map +1 -1
  307. package/dist/store/postgres.d.cts +44 -7
  308. package/dist/store/postgres.d.cts.map +1 -1
  309. package/dist/store/postgres.d.mts +44 -7
  310. package/dist/store/postgres.d.mts.map +1 -1
  311. package/dist/store/postgres.mjs +179 -60
  312. package/dist/store/postgres.mjs.map +1 -1
  313. package/dist/store/tables.cjs +7 -5
  314. package/dist/store/tables.cjs.map +1 -1
  315. package/dist/store/tables.d.cts.map +1 -1
  316. package/dist/store/tables.d.mts.map +1 -1
  317. package/dist/store/tables.mjs +7 -5
  318. package/dist/store/tables.mjs.map +1 -1
  319. package/dist/store/user-lock.cjs +43 -0
  320. package/dist/store/user-lock.cjs.map +1 -0
  321. package/dist/store/user-lock.d.cts +32 -0
  322. package/dist/store/user-lock.d.cts.map +1 -0
  323. package/dist/store/user-lock.d.mts +32 -0
  324. package/dist/store/user-lock.d.mts.map +1 -0
  325. package/dist/store/user-lock.mjs +40 -0
  326. package/dist/store/user-lock.mjs.map +1 -0
  327. package/dist/transports/cloudflare-email.cjs +1 -1
  328. package/dist/transports/cloudflare-email.mjs +1 -1
  329. package/dist/transports/ses.cjs +47 -6
  330. package/dist/transports/ses.cjs.map +1 -1
  331. package/dist/transports/ses.d.cts +9 -1
  332. package/dist/transports/ses.d.cts.map +1 -1
  333. package/dist/transports/ses.d.mts +9 -1
  334. package/dist/transports/ses.d.mts.map +1 -1
  335. package/dist/transports/ses.mjs +47 -7
  336. package/dist/transports/ses.mjs.map +1 -1
  337. package/dist/webpush.cjs +133 -47
  338. package/dist/webpush.cjs.map +1 -1
  339. package/dist/webpush.d.cts +49 -8
  340. package/dist/webpush.d.cts.map +1 -1
  341. package/dist/webpush.d.mts +49 -8
  342. package/dist/webpush.d.mts.map +1 -1
  343. package/dist/webpush.mjs +130 -47
  344. package/dist/webpush.mjs.map +1 -1
  345. package/package.json +1 -1
  346. package/src/cloudflare/schema.sql +27 -6
  347. package/dist/host/router.cjs.map +0 -1
  348. package/dist/host/router.d.cts.map +0 -1
  349. package/dist/host/router.d.mts.map +0 -1
  350. package/dist/host/router.mjs.map +0 -1
@@ -1,5 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_store_erasure = require("./erasure.cjs");
2
3
  const require_store_sql = require("./sql.cjs");
4
+ const require_store_user_lock = require("./user-lock.cjs");
3
5
  //#region src/store/postgres-reporting.ts
4
6
  /**
5
7
  * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a
@@ -12,67 +14,123 @@ const MS = (column) => `(extract(epoch from ${column}) * 1000)::double precision
12
14
  * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp
13
15
  * is written here, never defaulted, and every write is idempotent: the engine reports again when
14
16
  * a step is retried.
17
+ *
18
+ * The instance and message rows are written like the store's rows about a user (postgres.ts,
19
+ * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's
20
+ * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after
21
+ * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts
22
+ * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not
23
+ * retryable) so it is never sent.
24
+ *
25
+ * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is
26
+ * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the
27
+ * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone
28
+ * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only
29
+ * then locks the copy's row (where the path locks it) and writes. User lock before row lock,
30
+ * always: the erasure deletes under the user lock, so the two never wait on each other in
31
+ * opposite orders. The unsubscribe is the compliance exception: its suppression is written
32
+ * whatever the user's state, under the same lock order for consistency.
15
33
  */
16
34
  var PostgresReporting = class {
17
35
  sql;
18
36
  t;
19
- constructor(sql, t) {
37
+ schema;
38
+ constructor(sql, t, schema) {
20
39
  this.sql = sql;
21
40
  this.t = t;
41
+ this.schema = schema;
42
+ }
43
+ /** The condition every row about a user carries: no erasure tombstone for `$user`. */
44
+ notErased(user) {
45
+ return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;
46
+ }
47
+ /**
48
+ * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the
49
+ * owner's user lock, then the copy re-read under it — present and its owner without a
50
+ * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).
51
+ * `forUpdate` locks the copy's row on that re-read, after the user lock.
52
+ */
53
+ async lockMessage(tx, messageId, forUpdate = false) {
54
+ await require_store_user_lock.beginReadCommitted(tx);
55
+ const owner = (await tx.query(`SELECT user_id FROM ${this.t.message} WHERE id = $1`, [messageId])).at(0);
56
+ if (owner === void 0) return void 0;
57
+ await require_store_user_lock.lockUser(tx, this.schema, owner.user_id);
58
+ const row = (await tx.query(`SELECT user_id, ${MS("coalesce(delivered_at, sent_at)")} AS delivered_at
59
+ FROM ${this.t.message} WHERE id = $1
60
+ AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${this.t.message}.user_id)
61
+ ${forUpdate ? "FOR UPDATE" : ""}`, [messageId])).at(0);
62
+ if (row === void 0) return void 0;
63
+ return {
64
+ userId: row.user_id,
65
+ deliveredAt: Number(row.delivered_at)
66
+ };
22
67
  }
23
68
  async instanceStarted(input) {
24
- await this.sql.query(`INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)
25
- VALUES ($1, $2, $3, ${TS("$4")}, 'running')
26
- ON CONFLICT (instance_id) DO NOTHING`, [
27
- input.instanceId,
28
- input.workflow,
29
- input.userId,
30
- input.at
31
- ]);
69
+ await this.sql.transaction(async (tx) => {
70
+ await require_store_user_lock.lockUserTransaction(tx, this.schema, input.userId);
71
+ await tx.query(`INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)
72
+ SELECT $1, $2, $3, ${TS("$4")}, 'running' ${this.notErased("$3")}
73
+ ON CONFLICT (instance_id) DO NOTHING`, [
74
+ input.instanceId,
75
+ input.workflow,
76
+ input.userId,
77
+ input.at
78
+ ]);
79
+ });
32
80
  }
33
81
  async nodeReached(input) {
34
- await this.sql.query(`INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)
35
- VALUES ($1, $2, $3, ${TS("$4")}, $5, ${TS("$4")}, 'running')
36
- ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`, [
37
- input.instanceId,
38
- input.workflow,
39
- input.userId,
40
- input.at,
41
- input.nodeId
42
- ]);
82
+ await this.sql.transaction(async (tx) => {
83
+ await require_store_user_lock.lockUserTransaction(tx, this.schema, input.userId);
84
+ await tx.query(`INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)
85
+ SELECT $1, $2, $3, ${TS("$4")}, $5, ${TS("$4")}, 'running' ${this.notErased("$3")}
86
+ ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`, [
87
+ input.instanceId,
88
+ input.workflow,
89
+ input.userId,
90
+ input.at,
91
+ input.nodeId
92
+ ]);
93
+ });
43
94
  }
44
95
  async instanceFinished(input) {
45
- await this.sql.query(`INSERT INTO ${this.t.instance}
46
- (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)
47
- VALUES ($1, $2, $3, ${TS("$5")}, $4, ${TS("$5")}, ${TS("$6")})
48
- ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,
49
- finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`, [
50
- input.instanceId,
51
- input.workflow,
52
- input.userId,
53
- input.status,
54
- input.at,
55
- input.convertedAt ?? null
56
- ]);
96
+ await this.sql.transaction(async (tx) => {
97
+ await require_store_user_lock.lockUserTransaction(tx, this.schema, input.userId);
98
+ await tx.query(`INSERT INTO ${this.t.instance}
99
+ (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)
100
+ SELECT $1, $2, $3, ${TS("$5")}, $4, ${TS("$5")}, ${TS("$6")} ${this.notErased("$3")}
101
+ ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,
102
+ finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`, [
103
+ input.instanceId,
104
+ input.workflow,
105
+ input.userId,
106
+ input.status,
107
+ input.at,
108
+ input.convertedAt ?? null
109
+ ]);
110
+ });
57
111
  }
58
112
  async recordMessage(message) {
59
- const id = (await this.sql.query(`INSERT INTO ${this.t.message}
60
- (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, sent_at)
61
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ${TS("$9")})
62
- ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key
63
- RETURNING id`, [
64
- require_store_sql.uuidv7(message.sentAt),
65
- message.idempotencyKey,
66
- message.workflow,
67
- message.instanceId,
68
- message.nodeId,
69
- message.template,
70
- message.channel,
71
- message.userId,
72
- message.sentAt
73
- ])).at(0)?.id;
74
- if (id === void 0) throw new Error(`message ${message.idempotencyKey} was not recorded`);
75
- return id;
113
+ return this.sql.transaction(async (tx) => {
114
+ await require_store_user_lock.lockUserTransaction(tx, this.schema, message.userId);
115
+ const id = (await tx.query(`INSERT INTO ${this.t.message}
116
+ (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, sent_at)
117
+ SELECT $1, $2, $3, $4, $5, $6, $7, $8, ${TS("$9")} ${this.notErased("$8")}
118
+ ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key
119
+ RETURNING id`, [
120
+ require_store_sql.uuidv7(message.sentAt),
121
+ message.idempotencyKey,
122
+ message.workflow,
123
+ message.instanceId,
124
+ message.nodeId,
125
+ message.template,
126
+ message.channel,
127
+ message.userId,
128
+ message.sentAt
129
+ ])).at(0)?.id;
130
+ if (id !== void 0) return id;
131
+ if ((await tx.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [message.userId])).length > 0) throw new require_store_erasure.UserErasedError(message.userId);
132
+ throw new Error(`message ${message.idempotencyKey} was not recorded`);
133
+ });
76
134
  }
77
135
  async settleMessage(idempotencyKey, result, at) {
78
136
  if (result.delivered) await this.sql.query(`UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS("$2")}) WHERE idempotency_key = $1`, [idempotencyKey, at]);
@@ -81,17 +139,16 @@ var PostgresReporting = class {
81
139
  async recordLinks(messageId, urls) {
82
140
  if (urls.length === 0) return;
83
141
  const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(", ");
84
- await this.sql.query(`INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`, [messageId, ...urls]);
142
+ await this.sql.transaction(async (tx) => {
143
+ if (await this.lockMessage(tx, messageId) === void 0) return;
144
+ await tx.query(`INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`, [messageId, ...urls]);
145
+ });
85
146
  }
86
147
  async recordOpen(messageId, judge) {
87
148
  return this.sql.transaction(async (tx) => {
88
- const row = (await tx.query(`SELECT user_id, ${MS("coalesce(delivered_at, sent_at)")} AS delivered_at
89
- FROM ${this.t.message} WHERE id = $1`, [messageId])).at(0);
90
- if (row === void 0) return false;
91
- const open = judge({
92
- userId: row.user_id,
93
- deliveredAt: Number(row.delivered_at)
94
- });
149
+ const message = await this.lockMessage(tx, messageId);
150
+ if (message === void 0) return false;
151
+ const open = judge(message);
95
152
  if (open === void 0) return true;
96
153
  await tx.query(`INSERT INTO ${this.t.messageOpen}
97
154
  (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)
@@ -119,8 +176,7 @@ var PostgresReporting = class {
119
176
  }
120
177
  async recordClick(messageId, position, at, windowMs, judge) {
121
178
  return this.sql.transaction(async (tx) => {
122
- const message = (await tx.query(`SELECT ${MS("coalesce(delivered_at, sent_at)")} AS delivered_at
123
- FROM ${this.t.message} WHERE id = $1 FOR UPDATE`, [messageId])).at(0);
179
+ const message = await this.lockMessage(tx, messageId, true);
124
180
  if (message === void 0) return void 0;
125
181
  const link = (await tx.query(`SELECT l.url,
126
182
  (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count
@@ -131,7 +187,7 @@ var PostgresReporting = class {
131
187
  WHERE message_id = $1 AND clicked_at >= ${TS("$2")} ORDER BY clicked_at`, [messageId, since]);
132
188
  const { engagement: click, backdate } = judge({
133
189
  url: link.url,
134
- deliveredAt: Number(message.delivered_at),
190
+ deliveredAt: message.deliveredAt,
135
191
  linkCount: Number(link.link_count),
136
192
  recent: recent.map((row) => ({
137
193
  position: row.position,
@@ -179,17 +235,24 @@ var PostgresReporting = class {
179
235
  });
180
236
  }
181
237
  async recordDeviceDelivered(messageId, at) {
182
- return (await this.sql.query(`UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS("$2")})
183
- WHERE id = $1 RETURNING id`, [messageId, at])).length > 0;
238
+ return this.sql.transaction(async (tx) => {
239
+ if (await this.lockMessage(tx, messageId) === void 0) return false;
240
+ return (await tx.query(`UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS("$2")})
241
+ WHERE id = $1 RETURNING id`, [messageId, at])).length > 0;
242
+ });
184
243
  }
185
244
  async recordUnsubscribe(messageId, at) {
186
245
  return this.sql.transaction(async (tx) => {
246
+ await require_store_user_lock.beginReadCommitted(tx);
247
+ const owner = (await tx.query(`SELECT user_id FROM ${this.t.message} WHERE id = $1 AND channel = 'email'`, [messageId])).at(0);
248
+ if (owner === void 0) return void 0;
249
+ await require_store_user_lock.lockUser(tx, this.schema, owner.user_id);
187
250
  const userId = (await tx.query(`UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS("$2")})
188
251
  WHERE id = $1 AND channel = 'email' RETURNING user_id`, [messageId, at])).at(0)?.user_id;
189
- if (userId === void 0) return false;
252
+ if (userId === void 0) return void 0;
190
253
  await tx.query(`INSERT INTO ${this.t.suppression} (user_id, channel, ts) VALUES ($1, 'email', ${TS("$2")})
191
254
  ON CONFLICT DO NOTHING`, [userId, at]);
192
- return true;
255
+ return { userId };
193
256
  });
194
257
  }
195
258
  async snapshotSegments(at) {
@@ -1 +1 @@
1
- {"version":3,"file":"postgres-reporting.cjs","names":["uuidv7"],"sources":["../../src/store/postgres-reporting.ts"],"sourcesContent":["import type { ClickContext, Engagement, JourneyReporting, MessageRecord } from './index';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport type { TableKey } from './tables';\n\n/**\n * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a\n * driver may hand a double or a bigint count back as a string).\n */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\nconst MS = (column: string) => `(extract(epoch from ${column}) * 1000)::double precision`;\n\n/**\n * Journey reporting on the engine's Postgres schema (tables.ts: instance, message,\n * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp\n * is written here, never defaulted, and every write is idempotent: the engine reports again when\n * a step is retried.\n */\nexport class PostgresReporting implements JourneyReporting {\n constructor(\n private readonly sql: SqlClientLike,\n private readonly t: Record<TableKey, string>\n ) {}\n\n async instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void> {\n await this.sql.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)\n VALUES ($1, $2, $3, ${TS('$4')}, 'running')\n ON CONFLICT (instance_id) DO NOTHING`,\n [input.instanceId, input.workflow, input.userId, input.at]\n );\n }\n\n async nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void> {\n await this.sql.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)\n VALUES ($1, $2, $3, ${TS('$4')}, $5, ${TS('$4')}, 'running')\n ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`,\n [input.instanceId, input.workflow, input.userId, input.at, input.nodeId]\n );\n }\n\n async instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void> {\n // An upsert: an instance that started before its start was reported still gets its row\n await this.sql.query(\n `INSERT INTO ${this.t.instance}\n (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)\n VALUES ($1, $2, $3, ${TS('$5')}, $4, ${TS('$5')}, ${TS('$6')})\n ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,\n finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`,\n [\n input.instanceId,\n input.workflow,\n input.userId,\n input.status,\n input.at,\n input.convertedAt ?? null,\n ]\n );\n }\n\n async recordMessage(message: MessageRecord): Promise<string> {\n // A retried step records the same copy again: the row it already wrote is the one\n const rows = await this.sql.query<{ id: string }>(\n `INSERT INTO ${this.t.message}\n (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, sent_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ${TS('$9')})\n ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key\n RETURNING id`,\n [\n uuidv7(message.sentAt),\n message.idempotencyKey,\n message.workflow,\n message.instanceId,\n message.nodeId,\n message.template,\n message.channel,\n message.userId,\n message.sentAt,\n ]\n );\n const id = rows.at(0)?.id;\n if (id === undefined) throw new Error(`message ${message.idempotencyKey} was not recorded`);\n return id;\n }\n\n async settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void> {\n if (result.delivered) {\n await this.sql.query(\n `UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS('$2')}) WHERE idempotency_key = $1`,\n [idempotencyKey, at]\n );\n } else {\n await this.sql.query(\n `UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`,\n [idempotencyKey, result.reason]\n );\n }\n }\n\n async recordLinks(messageId: string, urls: readonly string[]): Promise<void> {\n if (urls.length === 0) return;\n const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(', ');\n await this.sql.query(\n `INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`,\n [messageId, ...urls]\n );\n }\n\n async recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean> {\n // In a transaction: read through Hyperdrive, a bare read may come from its cache, from before\n // the copy existed\n return this.sql.transaction(async (tx) => {\n const rows = await tx.query<{ user_id: string; delivered_at: number | string }>(\n `SELECT user_id, ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const row = rows.at(0);\n if (row === undefined) return false;\n const open = judge({ userId: row.user_id, deliveredAt: Number(row.delivered_at) });\n if (open === undefined) return true;\n await tx.query(\n `INSERT INTO ${this.t.messageOpen}\n (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, ${TS('$3')}, $4, $5, $6, $7, $8, $9)`,\n [\n uuidv7(open.at),\n messageId,\n open.at,\n open.machine,\n open.reason,\n open.userAgent,\n open.asn,\n open.organization,\n open.country,\n ]\n );\n await tx.query(\n `UPDATE ${this.t.message} SET\n opened_at = coalesce(opened_at, ${TS('$2')}),\n human_opened_at = CASE WHEN $3 THEN human_opened_at ELSE coalesce(human_opened_at, ${TS('$2')}) END\n WHERE id = $1`,\n [messageId, open.at, open.machine]\n );\n return true;\n });\n }\n\n async recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The copy's row is the lock: concurrent clicks on it are judged one at a time\n const messages = await tx.query<{ delivered_at: number | string }>(\n `SELECT ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1 FOR UPDATE`,\n [messageId]\n );\n const message = messages.at(0);\n if (message === undefined) return undefined;\n const links = await tx.query<{ url: string; link_count: number | string }>(\n `SELECT l.url,\n (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count\n FROM ${this.t.messageLink} l WHERE l.message_id = $1 AND l.position = $2`,\n [messageId, position]\n );\n const link = links.at(0);\n if (link === undefined) return undefined;\n const since = at - windowMs;\n const recent = await tx.query<{ position: number; at: number | string }>(\n `SELECT position, ${MS('clicked_at')} AS at FROM ${this.t.messageClick}\n WHERE message_id = $1 AND clicked_at >= ${TS('$2')} ORDER BY clicked_at`,\n [messageId, since]\n );\n const { engagement: click, backdate } = judge({\n url: link.url,\n deliveredAt: Number(message.delivered_at),\n linkCount: Number(link.link_count),\n recent: recent.map((row) => ({ position: row.position, at: Number(row.at) })),\n });\n await tx.query(\n `INSERT INTO ${this.t.messageClick}\n (id, message_id, position, url, clicked_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6, $7, $8, $9, $10, $11)`,\n [\n uuidv7(click.at),\n messageId,\n position,\n link.url,\n click.at,\n click.machine,\n click.reason,\n click.userAgent,\n click.asn,\n click.organization,\n click.country,\n ]\n );\n if (backdate) {\n // A walk across the copy is found on its later clicks: the earlier ones were part of it\n await tx.query(\n `UPDATE ${this.t.messageClick} SET machine = true, reason = $4\n WHERE message_id = $1 AND NOT machine\n AND clicked_at BETWEEN ${TS('$2')} AND ${TS('$3')}`,\n [messageId, since, at, click.reason]\n );\n }\n // First opens and clicks, from the classified rows (a click implies an open)\n await tx.query(\n `UPDATE ${this.t.message} m SET\n clicked_at = coalesce(m.clicked_at, ${TS('$2')}),\n opened_at = coalesce(m.opened_at, ${TS('$2')}),\n human_clicked_at = (SELECT min(c.clicked_at) FROM ${this.t.messageClick} c\n WHERE c.message_id = m.id AND NOT c.machine),\n human_opened_at = (SELECT min(t) FROM (\n SELECT o.opened_at AS t FROM ${this.t.messageOpen} o WHERE o.message_id = m.id AND NOT o.machine\n UNION ALL\n SELECT c.clicked_at FROM ${this.t.messageClick} c WHERE c.message_id = m.id AND NOT c.machine\n ) human)\n WHERE m.id = $1`,\n [messageId, at]\n );\n return { url: link.url, engagement: click };\n });\n }\n\n async recordDeviceDelivered(messageId: string, at: number): Promise<boolean> {\n const rows = await this.sql.query<{ id: string }>(\n `UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS('$2')})\n WHERE id = $1 RETURNING id`,\n [messageId, at]\n );\n return rows.length > 0;\n }\n\n async recordUnsubscribe(messageId: string, at: number): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n const rows = await tx.query<{ user_id: string }>(\n `UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS('$2')})\n WHERE id = $1 AND channel = 'email' RETURNING user_id`,\n [messageId, at]\n );\n const userId = rows.at(0)?.user_id;\n if (userId === undefined) return false;\n // The opt-out itself: the engine sends this user no more email\n await tx.query(\n `INSERT INTO ${this.t.suppression} (user_id, channel, ts) VALUES ($1, 'email', ${TS('$2')})\n ON CONFLICT DO NOTHING`,\n [userId, at]\n );\n return true;\n });\n }\n\n async snapshotSegments(at: number): Promise<{ segment: string; size: number }[]> {\n return this.sql.transaction(async (tx) => {\n // Segments no longer deployed take their members with them\n await tx.query(\n `DELETE FROM ${this.t.segmentMember} m\n WHERE NOT EXISTS (SELECT 1 FROM ${this.t.segment} s WHERE s.id = m.segment)`\n );\n const rows = await tx.query<{ segment: string; size: number | string }>(\n `INSERT INTO ${this.t.segmentSnapshot} (segment, taken_at, size)\n SELECT s.id, ${TS('$1')}, (SELECT count(*) FROM ${this.t.segmentMember} m WHERE m.segment = s.id)\n FROM ${this.t.segment} s\n ON CONFLICT DO NOTHING\n RETURNING segment, size`,\n [at]\n );\n return rows\n .map((row) => ({ segment: row.segment, size: Number(row.size) }))\n .sort((a, b) => a.segment.localeCompare(b.segment));\n });\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"postgres-reporting.cjs","names":["beginReadCommitted","lockUser","lockUserTransaction","uuidv7","UserErasedError"],"sources":["../../src/store/postgres-reporting.ts"],"sourcesContent":["import { UserErasedError } from './erasure';\nimport type { ClickContext, Engagement, JourneyReporting, MessageRecord } from './index';\nimport { type SqlClientLike, uuidv7 } from './sql';\nimport type { TableKey } from './tables';\nimport { beginReadCommitted, lockUser, lockUserTransaction } from './user-lock';\n\n/**\n * Epoch milliseconds → timestamptz, and back: numbers in and out, whatever the driver maps (a\n * driver may hand a double or a bigint count back as a string).\n */\nconst TS = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\nconst MS = (column: string) => `(extract(epoch from ${column}) * 1000)::double precision`;\n\n/**\n * Journey reporting on the engine's Postgres schema (tables.ts: instance, message,\n * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp\n * is written here, never defaulted, and every write is idempotent: the engine reports again when\n * a step is retried.\n *\n * The instance and message rows are written like the store's rows about a user (postgres.ts,\n * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's\n * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after\n * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts\n * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not\n * retryable) so it is never sent.\n *\n * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is\n * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the\n * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone\n * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only\n * then locks the copy's row (where the path locks it) and writes. User lock before row lock,\n * always: the erasure deletes under the user lock, so the two never wait on each other in\n * opposite orders. The unsubscribe is the compliance exception: its suppression is written\n * whatever the user's state, under the same lock order for consistency.\n */\nexport class PostgresReporting implements JourneyReporting {\n constructor(\n private readonly sql: SqlClientLike,\n private readonly t: Record<TableKey, string>,\n private readonly schema: string\n ) {}\n\n /** The condition every row about a user carries: no erasure tombstone for `$user`. */\n private notErased(user: string): string {\n return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;\n }\n\n /**\n * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the\n * owner's user lock, then the copy re-read under it — present and its owner without a\n * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).\n * `forUpdate` locks the copy's row on that re-read, after the user lock.\n */\n private async lockMessage(\n tx: SqlClientLike,\n messageId: string,\n forUpdate = false\n ): Promise<{ userId: string; deliveredAt: number } | undefined> {\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.message} WHERE id = $1`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n await lockUser(tx, this.schema, owner.user_id);\n const rows = await tx.query<{ user_id: string; delivered_at: number | string }>(\n `SELECT user_id, ${MS('coalesce(delivered_at, sent_at)')} AS delivered_at\n FROM ${this.t.message} WHERE id = $1\n AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${this.t.message}.user_id)\n ${forUpdate ? 'FOR UPDATE' : ''}`,\n [messageId]\n );\n const row = rows.at(0);\n if (row === undefined) return undefined;\n return { userId: row.user_id, deliveredAt: Number(row.delivered_at) };\n }\n\n async instanceStarted(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO NOTHING`,\n [input.instanceId, input.workflow, input.userId, input.at]\n );\n });\n }\n\n async nodeReached(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n nodeId: string;\n at: number;\n }): Promise<void> {\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance} (instance_id, workflow, user_id, entered_at, node_id, node_at, status)\n SELECT $1, $2, $3, ${TS('$4')}, $5, ${TS('$4')}, 'running' ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET node_id = EXCLUDED.node_id, node_at = EXCLUDED.node_at`,\n [input.instanceId, input.workflow, input.userId, input.at, input.nodeId]\n );\n });\n }\n\n async instanceFinished(input: {\n workflow: string;\n instanceId: string;\n userId: string;\n status: string;\n at: number;\n convertedAt?: number;\n }): Promise<void> {\n // An upsert: an instance that started before its start was reported still gets its row —\n // unless the user is erased, where a late finish must not put one back\n await this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, input.userId);\n await tx.query(\n `INSERT INTO ${this.t.instance}\n (instance_id, workflow, user_id, entered_at, status, finished_at, converted_at)\n SELECT $1, $2, $3, ${TS('$5')}, $4, ${TS('$5')}, ${TS('$6')} ${this.notErased('$3')}\n ON CONFLICT (instance_id) DO UPDATE SET status = EXCLUDED.status,\n finished_at = EXCLUDED.finished_at, converted_at = EXCLUDED.converted_at`,\n [\n input.instanceId,\n input.workflow,\n input.userId,\n input.status,\n input.at,\n input.convertedAt ?? null,\n ]\n );\n });\n }\n\n async recordMessage(message: MessageRecord): Promise<string> {\n // A retried step records the same copy again: the row it already wrote is the one. With a\n // tombstone the statement yields nothing, a retry's row included: the copy is not sent\n return this.sql.transaction(async (tx) => {\n await lockUserTransaction(tx, this.schema, message.userId);\n const rows = await tx.query<{ id: string }>(\n `INSERT INTO ${this.t.message}\n (id, idempotency_key, workflow, instance_id, node_id, template, channel, user_id, sent_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, ${TS('$9')} ${this.notErased('$8')}\n ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key\n RETURNING id`,\n [\n uuidv7(message.sentAt),\n message.idempotencyKey,\n message.workflow,\n message.instanceId,\n message.nodeId,\n message.template,\n message.channel,\n message.userId,\n message.sentAt,\n ]\n );\n const id = rows.at(0)?.id;\n if (id !== undefined) return id;\n // Nothing recorded: the tombstone refused it, and the sender must stop here\n const erased = await tx.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [\n message.userId,\n ]);\n if (erased.length > 0) throw new UserErasedError(message.userId);\n throw new Error(`message ${message.idempotencyKey} was not recorded`);\n });\n }\n\n async settleMessage(\n idempotencyKey: string,\n result: { delivered: true } | { delivered: false; reason: string },\n at: number\n ): Promise<void> {\n if (result.delivered) {\n await this.sql.query(\n `UPDATE ${this.t.message} SET delivered_at = coalesce(delivered_at, ${TS('$2')}) WHERE idempotency_key = $1`,\n [idempotencyKey, at]\n );\n } else {\n await this.sql.query(\n `UPDATE ${this.t.message} SET undelivered_reason = $2 WHERE idempotency_key = $1`,\n [idempotencyKey, result.reason]\n );\n }\n }\n\n async recordLinks(messageId: string, urls: readonly string[]): Promise<void> {\n if (urls.length === 0) return;\n const values = urls.map((_, i) => `($1, ${i}, $${i + 2})`).join(', ');\n await this.sql.transaction(async (tx) => {\n // No copy (or one being erased): nothing to record the links under\n if ((await this.lockMessage(tx, messageId)) === undefined) return;\n await tx.query(\n `INSERT INTO ${this.t.messageLink} (message_id, position, url) VALUES ${values} ON CONFLICT DO NOTHING`,\n [messageId, ...urls]\n );\n });\n }\n\n async recordOpen(\n messageId: string,\n judge: (message: { userId: string; deliveredAt: number }) => Engagement | undefined\n ): Promise<boolean> {\n // In a transaction: read through Hyperdrive, a bare read may come from its cache, from before\n // the copy existed\n return this.sql.transaction(async (tx) => {\n const message = await this.lockMessage(tx, messageId);\n if (message === undefined) return false;\n const open = judge(message);\n if (open === undefined) return true;\n await tx.query(\n `INSERT INTO ${this.t.messageOpen}\n (id, message_id, opened_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, ${TS('$3')}, $4, $5, $6, $7, $8, $9)`,\n [\n uuidv7(open.at),\n messageId,\n open.at,\n open.machine,\n open.reason,\n open.userAgent,\n open.asn,\n open.organization,\n open.country,\n ]\n );\n await tx.query(\n `UPDATE ${this.t.message} SET\n opened_at = coalesce(opened_at, ${TS('$2')}),\n human_opened_at = CASE WHEN $3 THEN human_opened_at ELSE coalesce(human_opened_at, ${TS('$2')}) END\n WHERE id = $1`,\n [messageId, open.at, open.machine]\n );\n return true;\n });\n }\n\n async recordClick(\n messageId: string,\n position: number,\n at: number,\n windowMs: number,\n judge: (link: ClickContext) => { engagement: Engagement; backdate: boolean }\n ): Promise<{ url: string; engagement: Engagement } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The copy's row is the lock (taken after the user lock): concurrent clicks on it are\n // judged one at a time\n const message = await this.lockMessage(tx, messageId, true);\n if (message === undefined) return undefined;\n const links = await tx.query<{ url: string; link_count: number | string }>(\n `SELECT l.url,\n (SELECT count(*) FROM ${this.t.messageLink} a WHERE a.message_id = l.message_id) AS link_count\n FROM ${this.t.messageLink} l WHERE l.message_id = $1 AND l.position = $2`,\n [messageId, position]\n );\n const link = links.at(0);\n if (link === undefined) return undefined;\n const since = at - windowMs;\n const recent = await tx.query<{ position: number; at: number | string }>(\n `SELECT position, ${MS('clicked_at')} AS at FROM ${this.t.messageClick}\n WHERE message_id = $1 AND clicked_at >= ${TS('$2')} ORDER BY clicked_at`,\n [messageId, since]\n );\n const { engagement: click, backdate } = judge({\n url: link.url,\n deliveredAt: message.deliveredAt,\n linkCount: Number(link.link_count),\n recent: recent.map((row) => ({ position: row.position, at: Number(row.at) })),\n });\n await tx.query(\n `INSERT INTO ${this.t.messageClick}\n (id, message_id, position, url, clicked_at, machine, reason, user_agent, asn, as_organization, country)\n VALUES ($1, $2, $3, $4, ${TS('$5')}, $6, $7, $8, $9, $10, $11)`,\n [\n uuidv7(click.at),\n messageId,\n position,\n link.url,\n click.at,\n click.machine,\n click.reason,\n click.userAgent,\n click.asn,\n click.organization,\n click.country,\n ]\n );\n if (backdate) {\n // A walk across the copy is found on its later clicks: the earlier ones were part of it\n await tx.query(\n `UPDATE ${this.t.messageClick} SET machine = true, reason = $4\n WHERE message_id = $1 AND NOT machine\n AND clicked_at BETWEEN ${TS('$2')} AND ${TS('$3')}`,\n [messageId, since, at, click.reason]\n );\n }\n // First opens and clicks, from the classified rows (a click implies an open)\n await tx.query(\n `UPDATE ${this.t.message} m SET\n clicked_at = coalesce(m.clicked_at, ${TS('$2')}),\n opened_at = coalesce(m.opened_at, ${TS('$2')}),\n human_clicked_at = (SELECT min(c.clicked_at) FROM ${this.t.messageClick} c\n WHERE c.message_id = m.id AND NOT c.machine),\n human_opened_at = (SELECT min(t) FROM (\n SELECT o.opened_at AS t FROM ${this.t.messageOpen} o WHERE o.message_id = m.id AND NOT o.machine\n UNION ALL\n SELECT c.clicked_at FROM ${this.t.messageClick} c WHERE c.message_id = m.id AND NOT c.machine\n ) human)\n WHERE m.id = $1`,\n [messageId, at]\n );\n return { url: link.url, engagement: click };\n });\n }\n\n async recordDeviceDelivered(messageId: string, at: number): Promise<boolean> {\n return this.sql.transaction(async (tx) => {\n if ((await this.lockMessage(tx, messageId)) === undefined) return false;\n const rows = await tx.query<{ id: string }>(\n `UPDATE ${this.t.message} SET device_delivered_at = coalesce(device_delivered_at, ${TS('$2')})\n WHERE id = $1 RETURNING id`,\n [messageId, at]\n );\n return rows.length > 0;\n });\n }\n\n async recordUnsubscribe(messageId: string, at: number): Promise<{ userId: string } | undefined> {\n return this.sql.transaction(async (tx) => {\n // The lock order of the other message-id paths (user lock, then the copy's row), so an\n // unsubscribe and an erasure never wait on each other crosswise. Not the tombstone check:\n // an opt-out is recorded whatever the user's state, on the copy as long as it is there\n await beginReadCommitted(tx);\n const owners = await tx.query<{ user_id: string }>(\n `SELECT user_id FROM ${this.t.message} WHERE id = $1 AND channel = 'email'`,\n [messageId]\n );\n const owner = owners.at(0);\n if (owner === undefined) return undefined;\n await lockUser(tx, this.schema, owner.user_id);\n // The first unsubscribe sets the time; a later one keeps it and still names the recipient\n const rows = await tx.query<{ user_id: string }>(\n `UPDATE ${this.t.message} SET unsubscribed_at = coalesce(unsubscribed_at, ${TS('$2')})\n WHERE id = $1 AND channel = 'email' RETURNING user_id`,\n [messageId, at]\n );\n const userId = rows.at(0)?.user_id;\n if (userId === undefined) return undefined;\n // The opt-out itself: the engine sends this user no more email\n await tx.query(\n `INSERT INTO ${this.t.suppression} (user_id, channel, ts) VALUES ($1, 'email', ${TS('$2')})\n ON CONFLICT DO NOTHING`,\n [userId, at]\n );\n return { userId };\n });\n }\n\n async snapshotSegments(at: number): Promise<{ segment: string; size: number }[]> {\n return this.sql.transaction(async (tx) => {\n // Segments no longer deployed take their members with them\n await tx.query(\n `DELETE FROM ${this.t.segmentMember} m\n WHERE NOT EXISTS (SELECT 1 FROM ${this.t.segment} s WHERE s.id = m.segment)`\n );\n const rows = await tx.query<{ segment: string; size: number | string }>(\n `INSERT INTO ${this.t.segmentSnapshot} (segment, taken_at, size)\n SELECT s.id, ${TS('$1')}, (SELECT count(*) FROM ${this.t.segmentMember} m WHERE m.segment = s.id)\n FROM ${this.t.segment} s\n ON CONFLICT DO NOTHING\n RETURNING segment, size`,\n [at]\n );\n return rows\n .map((row) => ({ segment: row.segment, size: Number(row.size) }))\n .sort((a, b) => a.segment.localeCompare(b.segment));\n });\n }\n}\n"],"mappings":"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"}
@@ -7,11 +7,37 @@ import { ClickContext, Engagement, JourneyReporting, MessageRecord } from "./ind
7
7
  * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp
8
8
  * is written here, never defaulted, and every write is idempotent: the engine reports again when
9
9
  * a step is retried.
10
+ *
11
+ * The instance and message rows are written like the store's rows about a user (postgres.ts,
12
+ * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's
13
+ * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after
14
+ * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts
15
+ * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not
16
+ * retryable) so it is never sent.
17
+ *
18
+ * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is
19
+ * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the
20
+ * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone
21
+ * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only
22
+ * then locks the copy's row (where the path locks it) and writes. User lock before row lock,
23
+ * always: the erasure deletes under the user lock, so the two never wait on each other in
24
+ * opposite orders. The unsubscribe is the compliance exception: its suppression is written
25
+ * whatever the user's state, under the same lock order for consistency.
10
26
  */
11
27
  export declare class PostgresReporting implements JourneyReporting {
12
28
  private readonly sql;
13
29
  private readonly t;
14
- constructor(sql: SqlClientLike, t: Record<TableKey, string>);
30
+ private readonly schema;
31
+ constructor(sql: SqlClientLike, t: Record<TableKey, string>, schema: string);
32
+ /** The condition every row about a user carries: no erasure tombstone for `$user`. */
33
+ private notErased;
34
+ /**
35
+ * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the
36
+ * owner's user lock, then the copy re-read under it — present and its owner without a
37
+ * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).
38
+ * `forUpdate` locks the copy's row on that re-read, after the user lock.
39
+ */
40
+ private lockMessage;
15
41
  instanceStarted(input: {
16
42
  workflow: string;
17
43
  instanceId: string;
@@ -53,7 +79,9 @@ export declare class PostgresReporting implements JourneyReporting {
53
79
  engagement: Engagement;
54
80
  } | undefined>;
55
81
  recordDeviceDelivered(messageId: string, at: number): Promise<boolean>;
56
- recordUnsubscribe(messageId: string, at: number): Promise<boolean>;
82
+ recordUnsubscribe(messageId: string, at: number): Promise<{
83
+ userId: string;
84
+ } | undefined>;
57
85
  snapshotSegments(at: number): Promise<{
58
86
  segment: string;
59
87
  size: number;
@@ -1 +1 @@
1
- {"version":3,"file":"postgres-reporting.d.cts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;qBAiBa,6BAA6B;mBAErB;mBACA;EAFnB,YACmB,KAAK,eACL,GAAG,OAAO;EAGvB,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EASE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EASE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAmBE,cAAc,SAAS,gBAAgB;EAyBvC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EASzD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAwCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EA4EhC,sBAAsB,mBAAmB,aAAa;EAStD,kBAAkB,mBAAmB,aAAa;EAmBlD,iBAAiB,aAAa;IAAU;IAAiB"}
1
+ {"version":3,"file":"postgres-reporting.d.cts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;qBAmCa,6BAA6B;mBAErB;mBACA;mBACA;EAHnB,YACmB,KAAK,eACL,GAAG,OAAO,mBACV;;UAIX;;;;;;;UAUM;EAyBR,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EAYE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EAYE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAuBE,cAAc,SAAS,gBAAgB;EAkCvC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EAazD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAmCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EAwEhC,sBAAsB,mBAAmB,aAAa;EAYtD,kBAAkB,mBAAmB,aAAa;IAAU;;EA+B5D,iBAAiB,aAAa;IAAU;IAAiB"}
@@ -7,11 +7,37 @@ import { ClickContext, Engagement, JourneyReporting, MessageRecord } from "./ind
7
7
  * message_link, message_open, message_click, segment_member, segment_snapshot). Every timestamp
8
8
  * is written here, never defaulted, and every write is idempotent: the engine reports again when
9
9
  * a step is retried.
10
+ *
11
+ * The instance and message rows are written like the store's rows about a user (postgres.ts,
12
+ * user-lock.ts): in a transaction holding the user's advisory lock, conditional on the user's
13
+ * erasure tombstone (`INSERT ... SELECT ... WHERE NOT EXISTS`). A notification arriving after
14
+ * the user was erased — a late start, node or finish — is a no-op, never an upsert that puts
15
+ * the instance back; a copy to be recorded for them is refused (`UserErasedError`, not
16
+ * retryable) so it is never sent.
17
+ *
18
+ * Links, opens, clicks and device deliveries know only the copy's id. Their transaction is
19
+ * pinned to READ COMMITTED first, looks the copy's owner up without a row lock, takes the
20
+ * owner's user lock, then re-checks that the copy still exists and the owner has no tombstone
21
+ * — a copy deleted while waiting is a no-op, nothing is recorded under a stale owner — and only
22
+ * then locks the copy's row (where the path locks it) and writes. User lock before row lock,
23
+ * always: the erasure deletes under the user lock, so the two never wait on each other in
24
+ * opposite orders. The unsubscribe is the compliance exception: its suppression is written
25
+ * whatever the user's state, under the same lock order for consistency.
10
26
  */
11
27
  export declare class PostgresReporting implements JourneyReporting {
12
28
  private readonly sql;
13
29
  private readonly t;
14
- constructor(sql: SqlClientLike, t: Record<TableKey, string>);
30
+ private readonly schema;
31
+ constructor(sql: SqlClientLike, t: Record<TableKey, string>, schema: string);
32
+ /** The condition every row about a user carries: no erasure tombstone for `$user`. */
33
+ private notErased;
34
+ /**
35
+ * The message-id paths' opening: READ COMMITTED, the copy's owner without a row lock, the
36
+ * owner's user lock, then the copy re-read under it — present and its owner without a
37
+ * tombstone, or `undefined` (the copy went, or its owner is being erased: nothing to record).
38
+ * `forUpdate` locks the copy's row on that re-read, after the user lock.
39
+ */
40
+ private lockMessage;
15
41
  instanceStarted(input: {
16
42
  workflow: string;
17
43
  instanceId: string;
@@ -53,7 +79,9 @@ export declare class PostgresReporting implements JourneyReporting {
53
79
  engagement: Engagement;
54
80
  } | undefined>;
55
81
  recordDeviceDelivered(messageId: string, at: number): Promise<boolean>;
56
- recordUnsubscribe(messageId: string, at: number): Promise<boolean>;
82
+ recordUnsubscribe(messageId: string, at: number): Promise<{
83
+ userId: string;
84
+ } | undefined>;
57
85
  snapshotSegments(at: number): Promise<{
58
86
  segment: string;
59
87
  size: number;
@@ -1 +1 @@
1
- {"version":3,"file":"postgres-reporting.d.mts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;qBAiBa,6BAA6B;mBAErB;mBACA;EAFnB,YACmB,KAAK,eACL,GAAG,OAAO;EAGvB,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EASE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EASE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAmBE,cAAc,SAAS,gBAAgB;EAyBvC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EASzD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAwCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EA4EhC,sBAAsB,mBAAmB,aAAa;EAStD,kBAAkB,mBAAmB,aAAa;EAmBlD,iBAAiB,aAAa;IAAU;IAAiB"}
1
+ {"version":3,"file":"postgres-reporting.d.mts","names":[],"sources":["../../src/store/postgres-reporting.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;qBAmCa,6BAA6B;mBAErB;mBACA;mBACA;EAHnB,YACmB,KAAK,eACL,GAAG,OAAO,mBACV;;UAIX;;;;;;;UAUM;EAyBR,gBAAgB;IACpB;IACA;IACA;IACA;MACE;EAYE,YAAY;IAChB;IACA;IACA;IACA;IACA;MACE;EAYE,iBAAiB;IACrB;IACA;IACA;IACA;IACA;IACA;MACE;EAuBE,cAAc,SAAS,gBAAgB;EAkCvC,cACJ,wBACA;IAAU;;IAAsB;IAAkB;KAClD,aACC;EAcG,YAAY,mBAAmB,0BAA0B;EAazD,WACJ,mBACA,QAAQ;IAAW;IAAgB;QAA0B,yBAC5D;EAmCG,YACJ,mBACA,kBACA,YACA,kBACA,QAAQ,MAAM;IAAmB,YAAY;IAAY;MACxD;IAAU;IAAa,YAAY;;EAwEhC,sBAAsB,mBAAmB,aAAa;EAYtD,kBAAkB,mBAAmB,aAAa;IAAU;;EA+B5D,iBAAiB,aAAa;IAAU;IAAiB"}