@shware/workflow 3.3.0 → 4.0.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 +91 -29
  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 +47 -6
  19. package/dist/aws/index.cjs.map +1 -1
  20. package/dist/aws/index.d.cts +2 -2
  21. package/dist/aws/index.d.cts.map +1 -1
  22. package/dist/aws/index.d.mts +2 -2
  23. package/dist/aws/index.d.mts.map +1 -1
  24. package/dist/aws/index.mjs +47 -6
  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 +28 -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 +21 -0
  178. package/dist/host/auth.d.cts.map +1 -0
  179. package/dist/host/auth.d.mts +21 -0
  180. package/dist/host/auth.d.mts.map +1 -0
  181. package/dist/host/auth.mjs +47 -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 +321 -0
  208. package/dist/host/http.cjs.map +1 -0
  209. package/dist/host/http.d.cts +54 -0
  210. package/dist/host/http.d.cts.map +1 -0
  211. package/dist/host/http.d.mts +54 -0
  212. package/dist/host/http.d.mts.map +1 -0
  213. package/dist/host/http.mjs +317 -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 +65 -93
  224. package/dist/host/journey-host.cjs.map +1 -1
  225. package/dist/host/journey-host.d.cts +19 -26
  226. package/dist/host/journey-host.d.cts.map +1 -1
  227. package/dist/host/journey-host.d.mts +19 -26
  228. package/dist/host/journey-host.d.mts.map +1 -1
  229. package/dist/host/journey-host.mjs +65 -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,8 +1,9 @@
1
1
  import { BundleIR, ConditionIR, WorkflowIR } from "../ir.cjs";
2
2
  import { PushDevice } from "../engine/ports.cjs";
3
+ import { ErasureState } from "./erasure.cjs";
3
4
  import { SqlClientLike, uuidv7 } from "./sql.cjs";
4
5
  import { DEFAULT_SCHEMA } from "./tables.cjs";
5
- import { BundleVersion, DeviceInput, EntryInput, EntryOutcome, EventInput, EventWindow, ExecutionRegistry, JourneyReporting, JourneyStore, ProfileProps, SegmentTriggerRoute, TriggerRoute } from "./index.cjs";
6
+ import { BundleVersion, DeviceInput, EntryInput, EntryOutcome, EventInput, EventWindow, ExecutionRegistry, JourneyReporting, JourneyStore, ProfileProps, RunningInstance, SegmentTriggerRoute, TriggerRoute } from "./index.cjs";
6
7
  //#region src/store/postgres.d.ts
7
8
  export interface PostgresJourneyStoreOptions {
8
9
  /**
@@ -19,6 +20,23 @@ export interface PostgresJourneyStoreOptions {
19
20
  * guarantees come from the database: ON CONFLICT for the once policy and
20
21
  * idempotent subscriptions, a transaction for the route swap, a jsonb merge
21
22
  * for /identify. The tables are declared in tables.ts.
23
+ *
24
+ * Every write about a user is conditional on their erasure tombstone not existing
25
+ * (`INSERT ... SELECT ... WHERE NOT EXISTS`, see `notErased`), and runs in a transaction that
26
+ * holds the user's advisory lock (`lockUser`, the protocol in user-lock.ts): an event, a
27
+ * profile merge, a device, a subscription, a segment membership with its starts, an entry, an
28
+ * admission, and the reporting's instance and message rows (postgres-reporting.ts). The
29
+ * erasure's two steps, `requestErasure` and `forgetUser`, take the same lock. Under READ
30
+ * COMMITTED a conditional write sees only what was committed when its statement began, and an
31
+ * erasure sees only rows committed when it looked — so without the lock a write's transaction
32
+ * could commit its rows after the erase deleted everything (the user comes back), and a runner
33
+ * could admit itself after the tombstone (the instance runs on). With it, every write and the
34
+ * erasure of one user run one after another: a write either commits before the tombstone,
35
+ * where the erasure finds and deletes it, or waits for the lock, reads the tombstone and is
36
+ * refused (`UserErasedError`). Nothing lands after the erase, so there is no second pass. The
37
+ * conditional statement stays the in-transaction check; a write that wrote nothing reads the
38
+ * tombstone on that path only, to tell a refusal from a plain no-op (`refuseIfErased`).
39
+ * Suppressions are the one exception: an opt-out is written whatever the user's state.
22
40
  */
23
41
  export declare class PostgresJourneyStore implements JourneyStore {
24
42
  private readonly sql;
@@ -29,25 +47,44 @@ export declare class PostgresJourneyStore implements JourneyStore {
29
47
  constructor(sql: SqlClientLike, options?: PostgresJourneyStoreOptions);
30
48
  /**
31
49
  * What the live database lacks against tables.ts: missing tables, missing columns, a column
32
- * of another type or nullability. Read-only — the engine never migrates the host's database; a
33
- * deploy fails with these problems listed, and the fix is to regenerate the drizzle file and
34
- * run the host's migrations.
50
+ * of another type or nullability, and missing unique indexes (the store's ON CONFLICT targets
51
+ * — without one the first ingest fails, not the deploy). Read-only — the engine never migrates
52
+ * the host's database; a deploy fails with these problems listed, and the fix is to regenerate
53
+ * the drizzle file and run the host's migrations.
35
54
  */
36
55
  checkSchema(): Promise<string[]>;
37
56
  insertEvent(input: EventInput): Promise<boolean>;
57
+ /** The condition every write about a user carries: no erasure tombstone for `$user`. */
58
+ private notErased;
59
+ /**
60
+ * A conditional write wrote nothing: on that path only, read why. Throws when the tombstone
61
+ * refused it; returns when the statement had another reason (a duplicate, an entry taken).
62
+ */
63
+ private refuseIfErased;
64
+ /**
65
+ * The first two statements of every transaction that writes about the user (user-lock.ts):
66
+ * READ COMMITTED, then the user's advisory lock, held until the transaction ends. Any read
67
+ * about the user comes after, on a snapshot taken once the lock is held.
68
+ */
69
+ private lockUser;
38
70
  /** The event rows of one user and name, within the window's bounds (both inclusive). */
39
71
  private eventWindow;
40
72
  countEvents(userId: string, event: string, window?: EventWindow): Promise<number>;
41
73
  listEventPayloads(userId: string, event: string, window?: EventWindow): Promise<Record<string, unknown>[]>;
42
74
  getProfile(userId: string): Promise<ProfileProps | undefined>;
43
75
  mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps>;
44
- listUserIds(): Promise<string[]>;
76
+ listUserIds(page?: {
77
+ after?: string | undefined;
78
+ limit?: number | undefined;
79
+ }): Promise<string[]>;
45
80
  listDevices(userId: string): Promise<PushDevice[]>;
46
81
  upsertDevice(device: DeviceInput): Promise<void>;
47
82
  requestErasure(userId: string, at: number): Promise<void>;
48
83
  pendingErasures(): Promise<string[]>;
49
- runningInstances(userId: string): Promise<string[]>;
50
- forgetUser(userId: string): Promise<void>;
84
+ erasureState(userId: string): Promise<ErasureState>;
85
+ runningInstances(userId: string): Promise<RunningInstance[]>;
86
+ admitInstance(instanceId: string, userId: string): Promise<boolean>;
87
+ forgetUser(userId: string, at: number): Promise<void>;
51
88
  isSuppressed(userId: string, channel: string): Promise<boolean>;
52
89
  removeDevice(token: string): Promise<string | undefined>;
53
90
  unbindDevice(token: string): Promise<string | undefined>;
@@ -1 +1 @@
1
- {"version":3,"file":"postgres.d.cts","names":[],"sources":["../../src/store/postgres.ts"],"mappings":";;;;;;iBAyBiB;;;;;;;EAOf;;;;;;;;;qBA2BW,gCAAgC;mBAOxB;mBANF;mBACA;WACR,WAAW;WACX,YAAY;EAErB,YACmB,KAAK,eACtB,UAAS;;;;;;;EAiBL,eAAe;EAwCf,YAAY,OAAO,aAAa;;UAsB9B;EAcF,YAAY,gBAAgB,eAAe,SAAS,cAAc;EASlE,kBACJ,gBACA,eACA,SAAS,cACR,QAAQ;EASL,WAAW,iBAAiB,QAAQ;EASpC,aAAa,gBAAgB,OAAO,eAAe,QAAQ;EAe3D,eAAe;EAOrB,YAAY,iBAAiB,QAAQ;EAO/B,aAAa,QAAQ,cAAc;EAQnC,eAAe,gBAAgB,aAAa;EAQ5C,mBAAmB;EAOnB,iBAAiB,iBAAiB;EAQlC,WAAW,iBAAiB;EA6B5B,aAAa,gBAAgB,kBAAkB;EAQ/C,aAAa,gBAAgB;EAQ7B,aAAa,gBAAgB;EAW7B,gBAAgB;IAAU;IAAY,WAAW;;EAUjD,oBAAoB,aAAa,QAAQ;EASzC,aAAa,gBAAgB,QAAQ;EAoBrC,uBAAuB,QAAQ;EAe/B,cAAc,UAAU,aAAa;EAQrC,cAAc,sBAAsB,QAAQ;EASlD,cAAc,QAAQ,WAAW;EAI3B,qBAAqB,QAAQ;EAcnC,qBAAqB,QAAQ,UAAU,SAAS,gBAAgB;;UAoBlD;EAgCR,gBAAgB,iBAAiB,iBAAiB;EAQlD,iBACJ,iBACA,gBACA,YACA,+BACC;EAgBG,qBAAqB,iBAAiB,iBAAiB;EAQvD,qBAAqB,iBAAiB,gBAAgB,mBAAmB;EAOzE,oBAAoB,iBAAiB,iBAAiB;EAatD,aAAa,OAAO,aAAa,QAAQ;UAYjC;;;;;;UA4BA;EA0BR,YAAY,qBAAqB;EAIjC,eAAe,oBAAoB,iBAAiB;EAOpD,gBAAgB,gBAAgB,gBAAgB;EAQhD,UACJ,gBACA,gBACA,2BACA,aACC;EASG,YAAY,iBAAiB"}
1
+ {"version":3,"file":"postgres.d.cts","names":[],"sources":["../../src/store/postgres.ts"],"mappings":";;;;;;;iBA4BiB;;;;;;;EAOf;;;;;;;;;;;;;;;;;;;;;;;;;;qBA4CW,gCAAgC;mBAOxB;mBANF;mBACA;WACR,WAAW;WACX,YAAY;EAErB,YACmB,KAAK,eACtB,UAAS;;;;;;;;EAkBL,eAAe;EAqDf,YAAY,OAAO,aAAa;;UA4B9B;;;;;UAQM;;;;;;UAYN;;UAKA;EAcF,YAAY,gBAAgB,eAAe,SAAS,cAAc;EASlE,kBACJ,gBACA,eACA,SAAS,cACR,QAAQ;EASL,WAAW,iBAAiB,QAAQ;EASpC,aAAa,gBAAgB,OAAO,eAAe,QAAQ;EAqB3D,YAAY;IAChB;IACA;MACE;EAoBJ,YAAY,iBAAiB,QAAQ;EAO/B,aAAa,QAAQ,cAAc;EAcnC,eAAe,gBAAgB,aAAa;EAa5C,mBAAmB;EAOnB,aAAa,iBAAiB,QAAQ;EAUtC,iBAAiB,iBAAiB,QAAQ;EAS1C,cAAc,oBAAoB,iBAAiB;EAmBnD,WAAW,gBAAgB,aAAa;EAoCxC,aAAa,gBAAgB,kBAAkB;EAQ/C,aAAa,gBAAgB;EAQ7B,aAAa,gBAAgB;EAW7B,gBAAgB;IAAU;IAAY,WAAW;;EAUjD,oBAAoB,aAAa,QAAQ;EASzC,aAAa,gBAAgB,QAAQ;EAoBrC,uBAAuB,QAAQ;EAe/B,cAAc,UAAU,aAAa;EAQrC,cAAc,sBAAsB,QAAQ;EASlD,cAAc,QAAQ,WAAW;EAI3B,qBAAqB,QAAQ;EAcnC,qBAAqB,QAAQ,UAAU,SAAS,gBAAgB;;UAoBlD;EAgCR,gBAAgB,iBAAiB,iBAAiB;EAQlD,iBACJ,iBACA,gBACA,YACA,+BACC;EAuBG,qBAAqB,iBAAiB,iBAAiB;EAQvD,qBAAqB,iBAAiB,gBAAgB,mBAAmB;EAOzE,oBAAoB,iBAAiB,iBAAiB;EAatD,aAAa,OAAO,aAAa,QAAQ;UAejC;;;;;;UAqCA;EA6BR,YAAY,qBAAqB;EAIjC,eAAe,oBAAoB,iBAAiB;EAOpD,gBAAgB,gBAAgB,gBAAgB;EAQhD,UACJ,gBACA,gBACA,2BACA,aACC;EAmBG,YAAY,iBAAiB"}
@@ -1,8 +1,9 @@
1
1
  import { BundleIR, ConditionIR, WorkflowIR } from "../ir.mjs";
2
2
  import { PushDevice } from "../engine/ports.mjs";
3
+ import { ErasureState } from "./erasure.mjs";
3
4
  import { SqlClientLike, uuidv7 } from "./sql.mjs";
4
5
  import { DEFAULT_SCHEMA } from "./tables.mjs";
5
- import { BundleVersion, DeviceInput, EntryInput, EntryOutcome, EventInput, EventWindow, ExecutionRegistry, JourneyReporting, JourneyStore, ProfileProps, SegmentTriggerRoute, TriggerRoute } from "./index.mjs";
6
+ import { BundleVersion, DeviceInput, EntryInput, EntryOutcome, EventInput, EventWindow, ExecutionRegistry, JourneyReporting, JourneyStore, ProfileProps, RunningInstance, SegmentTriggerRoute, TriggerRoute } from "./index.mjs";
6
7
  //#region src/store/postgres.d.ts
7
8
  export interface PostgresJourneyStoreOptions {
8
9
  /**
@@ -19,6 +20,23 @@ export interface PostgresJourneyStoreOptions {
19
20
  * guarantees come from the database: ON CONFLICT for the once policy and
20
21
  * idempotent subscriptions, a transaction for the route swap, a jsonb merge
21
22
  * for /identify. The tables are declared in tables.ts.
23
+ *
24
+ * Every write about a user is conditional on their erasure tombstone not existing
25
+ * (`INSERT ... SELECT ... WHERE NOT EXISTS`, see `notErased`), and runs in a transaction that
26
+ * holds the user's advisory lock (`lockUser`, the protocol in user-lock.ts): an event, a
27
+ * profile merge, a device, a subscription, a segment membership with its starts, an entry, an
28
+ * admission, and the reporting's instance and message rows (postgres-reporting.ts). The
29
+ * erasure's two steps, `requestErasure` and `forgetUser`, take the same lock. Under READ
30
+ * COMMITTED a conditional write sees only what was committed when its statement began, and an
31
+ * erasure sees only rows committed when it looked — so without the lock a write's transaction
32
+ * could commit its rows after the erase deleted everything (the user comes back), and a runner
33
+ * could admit itself after the tombstone (the instance runs on). With it, every write and the
34
+ * erasure of one user run one after another: a write either commits before the tombstone,
35
+ * where the erasure finds and deletes it, or waits for the lock, reads the tombstone and is
36
+ * refused (`UserErasedError`). Nothing lands after the erase, so there is no second pass. The
37
+ * conditional statement stays the in-transaction check; a write that wrote nothing reads the
38
+ * tombstone on that path only, to tell a refusal from a plain no-op (`refuseIfErased`).
39
+ * Suppressions are the one exception: an opt-out is written whatever the user's state.
22
40
  */
23
41
  export declare class PostgresJourneyStore implements JourneyStore {
24
42
  private readonly sql;
@@ -29,25 +47,44 @@ export declare class PostgresJourneyStore implements JourneyStore {
29
47
  constructor(sql: SqlClientLike, options?: PostgresJourneyStoreOptions);
30
48
  /**
31
49
  * What the live database lacks against tables.ts: missing tables, missing columns, a column
32
- * of another type or nullability. Read-only — the engine never migrates the host's database; a
33
- * deploy fails with these problems listed, and the fix is to regenerate the drizzle file and
34
- * run the host's migrations.
50
+ * of another type or nullability, and missing unique indexes (the store's ON CONFLICT targets
51
+ * — without one the first ingest fails, not the deploy). Read-only — the engine never migrates
52
+ * the host's database; a deploy fails with these problems listed, and the fix is to regenerate
53
+ * the drizzle file and run the host's migrations.
35
54
  */
36
55
  checkSchema(): Promise<string[]>;
37
56
  insertEvent(input: EventInput): Promise<boolean>;
57
+ /** The condition every write about a user carries: no erasure tombstone for `$user`. */
58
+ private notErased;
59
+ /**
60
+ * A conditional write wrote nothing: on that path only, read why. Throws when the tombstone
61
+ * refused it; returns when the statement had another reason (a duplicate, an entry taken).
62
+ */
63
+ private refuseIfErased;
64
+ /**
65
+ * The first two statements of every transaction that writes about the user (user-lock.ts):
66
+ * READ COMMITTED, then the user's advisory lock, held until the transaction ends. Any read
67
+ * about the user comes after, on a snapshot taken once the lock is held.
68
+ */
69
+ private lockUser;
38
70
  /** The event rows of one user and name, within the window's bounds (both inclusive). */
39
71
  private eventWindow;
40
72
  countEvents(userId: string, event: string, window?: EventWindow): Promise<number>;
41
73
  listEventPayloads(userId: string, event: string, window?: EventWindow): Promise<Record<string, unknown>[]>;
42
74
  getProfile(userId: string): Promise<ProfileProps | undefined>;
43
75
  mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps>;
44
- listUserIds(): Promise<string[]>;
76
+ listUserIds(page?: {
77
+ after?: string | undefined;
78
+ limit?: number | undefined;
79
+ }): Promise<string[]>;
45
80
  listDevices(userId: string): Promise<PushDevice[]>;
46
81
  upsertDevice(device: DeviceInput): Promise<void>;
47
82
  requestErasure(userId: string, at: number): Promise<void>;
48
83
  pendingErasures(): Promise<string[]>;
49
- runningInstances(userId: string): Promise<string[]>;
50
- forgetUser(userId: string): Promise<void>;
84
+ erasureState(userId: string): Promise<ErasureState>;
85
+ runningInstances(userId: string): Promise<RunningInstance[]>;
86
+ admitInstance(instanceId: string, userId: string): Promise<boolean>;
87
+ forgetUser(userId: string, at: number): Promise<void>;
51
88
  isSuppressed(userId: string, channel: string): Promise<boolean>;
52
89
  removeDevice(token: string): Promise<string | undefined>;
53
90
  unbindDevice(token: string): Promise<string | undefined>;
@@ -1 +1 @@
1
- {"version":3,"file":"postgres.d.mts","names":[],"sources":["../../src/store/postgres.ts"],"mappings":";;;;;;iBAyBiB;;;;;;;EAOf;;;;;;;;;qBA2BW,gCAAgC;mBAOxB;mBANF;mBACA;WACR,WAAW;WACX,YAAY;EAErB,YACmB,KAAK,eACtB,UAAS;;;;;;;EAiBL,eAAe;EAwCf,YAAY,OAAO,aAAa;;UAsB9B;EAcF,YAAY,gBAAgB,eAAe,SAAS,cAAc;EASlE,kBACJ,gBACA,eACA,SAAS,cACR,QAAQ;EASL,WAAW,iBAAiB,QAAQ;EASpC,aAAa,gBAAgB,OAAO,eAAe,QAAQ;EAe3D,eAAe;EAOrB,YAAY,iBAAiB,QAAQ;EAO/B,aAAa,QAAQ,cAAc;EAQnC,eAAe,gBAAgB,aAAa;EAQ5C,mBAAmB;EAOnB,iBAAiB,iBAAiB;EAQlC,WAAW,iBAAiB;EA6B5B,aAAa,gBAAgB,kBAAkB;EAQ/C,aAAa,gBAAgB;EAQ7B,aAAa,gBAAgB;EAW7B,gBAAgB;IAAU;IAAY,WAAW;;EAUjD,oBAAoB,aAAa,QAAQ;EASzC,aAAa,gBAAgB,QAAQ;EAoBrC,uBAAuB,QAAQ;EAe/B,cAAc,UAAU,aAAa;EAQrC,cAAc,sBAAsB,QAAQ;EASlD,cAAc,QAAQ,WAAW;EAI3B,qBAAqB,QAAQ;EAcnC,qBAAqB,QAAQ,UAAU,SAAS,gBAAgB;;UAoBlD;EAgCR,gBAAgB,iBAAiB,iBAAiB;EAQlD,iBACJ,iBACA,gBACA,YACA,+BACC;EAgBG,qBAAqB,iBAAiB,iBAAiB;EAQvD,qBAAqB,iBAAiB,gBAAgB,mBAAmB;EAOzE,oBAAoB,iBAAiB,iBAAiB;EAatD,aAAa,OAAO,aAAa,QAAQ;UAYjC;;;;;;UA4BA;EA0BR,YAAY,qBAAqB;EAIjC,eAAe,oBAAoB,iBAAiB;EAOpD,gBAAgB,gBAAgB,gBAAgB;EAQhD,UACJ,gBACA,gBACA,2BACA,aACC;EASG,YAAY,iBAAiB"}
1
+ {"version":3,"file":"postgres.d.mts","names":[],"sources":["../../src/store/postgres.ts"],"mappings":";;;;;;;iBA4BiB;;;;;;;EAOf;;;;;;;;;;;;;;;;;;;;;;;;;;qBA4CW,gCAAgC;mBAOxB;mBANF;mBACA;WACR,WAAW;WACX,YAAY;EAErB,YACmB,KAAK,eACtB,UAAS;;;;;;;;EAkBL,eAAe;EAqDf,YAAY,OAAO,aAAa;;UA4B9B;;;;;UAQM;;;;;;UAYN;;UAKA;EAcF,YAAY,gBAAgB,eAAe,SAAS,cAAc;EASlE,kBACJ,gBACA,eACA,SAAS,cACR,QAAQ;EASL,WAAW,iBAAiB,QAAQ;EASpC,aAAa,gBAAgB,OAAO,eAAe,QAAQ;EAqB3D,YAAY;IAChB;IACA;MACE;EAoBJ,YAAY,iBAAiB,QAAQ;EAO/B,aAAa,QAAQ,cAAc;EAcnC,eAAe,gBAAgB,aAAa;EAa5C,mBAAmB;EAOnB,aAAa,iBAAiB,QAAQ;EAUtC,iBAAiB,iBAAiB,QAAQ;EAS1C,cAAc,oBAAoB,iBAAiB;EAmBnD,WAAW,gBAAgB,aAAa;EAoCxC,aAAa,gBAAgB,kBAAkB;EAQ/C,aAAa,gBAAgB;EAQ7B,aAAa,gBAAgB;EAW7B,gBAAgB;IAAU;IAAY,WAAW;;EAUjD,oBAAoB,aAAa,QAAQ;EASzC,aAAa,gBAAgB,QAAQ;EAoBrC,uBAAuB,QAAQ;EAe/B,cAAc,UAAU,aAAa;EAQrC,cAAc,sBAAsB,QAAQ;EASlD,cAAc,QAAQ,WAAW;EAI3B,qBAAqB,QAAQ;EAcnC,qBAAqB,QAAQ,UAAU,SAAS,gBAAgB;;UAoBlD;EAgCR,gBAAgB,iBAAiB,iBAAiB;EAQlD,iBACJ,iBACA,gBACA,YACA,+BACC;EAuBG,qBAAqB,iBAAiB,iBAAiB;EAQvD,qBAAqB,iBAAiB,gBAAgB,mBAAmB;EAOzE,oBAAoB,iBAAiB,iBAAiB;EAatD,aAAa,OAAO,aAAa,QAAQ;UAejC;;;;;;UAqCA;EA6BR,YAAY,qBAAqB;EAIjC,eAAe,oBAAoB,iBAAiB;EAOpD,gBAAgB,gBAAgB,gBAAgB;EAQhD,UACJ,gBACA,gBACA,2BACA,aACC;EAmBG,YAAY,iBAAiB"}
@@ -1,5 +1,7 @@
1
1
  import { ConditionIR, WorkflowIR } from "../ir.mjs";
2
+ import { UserErasedError } from "./erasure.mjs";
2
3
  import { uuidv7 } from "./sql.mjs";
4
+ import { lockUserTransaction } from "./user-lock.mjs";
3
5
  import { PostgresReporting } from "./postgres-reporting.mjs";
4
6
  import { DEFAULT_SCHEMA, TABLES, columnName } from "./tables.mjs";
5
7
  //#region src/store/postgres.ts
@@ -23,6 +25,23 @@ function asJson(value) {
23
25
  * guarantees come from the database: ON CONFLICT for the once policy and
24
26
  * idempotent subscriptions, a transaction for the route swap, a jsonb merge
25
27
  * for /identify. The tables are declared in tables.ts.
28
+ *
29
+ * Every write about a user is conditional on their erasure tombstone not existing
30
+ * (`INSERT ... SELECT ... WHERE NOT EXISTS`, see `notErased`), and runs in a transaction that
31
+ * holds the user's advisory lock (`lockUser`, the protocol in user-lock.ts): an event, a
32
+ * profile merge, a device, a subscription, a segment membership with its starts, an entry, an
33
+ * admission, and the reporting's instance and message rows (postgres-reporting.ts). The
34
+ * erasure's two steps, `requestErasure` and `forgetUser`, take the same lock. Under READ
35
+ * COMMITTED a conditional write sees only what was committed when its statement began, and an
36
+ * erasure sees only rows committed when it looked — so without the lock a write's transaction
37
+ * could commit its rows after the erase deleted everything (the user comes back), and a runner
38
+ * could admit itself after the tombstone (the instance runs on). With it, every write and the
39
+ * erasure of one user run one after another: a write either commits before the tombstone,
40
+ * where the erasure finds and deletes it, or waits for the lock, reads the tombstone and is
41
+ * refused (`UserErasedError`). Nothing lands after the erase, so there is no second pass. The
42
+ * conditional statement stays the in-transaction check; a write that wrote nothing reads the
43
+ * tombstone on that path only, to tell a refusal from a plain no-op (`refuseIfErased`).
44
+ * Suppressions are the one exception: an opt-out is written whatever the user's state.
26
45
  */
27
46
  var PostgresJourneyStore = class {
28
47
  sql;
@@ -35,19 +54,24 @@ var PostgresJourneyStore = class {
35
54
  this.schema = options.schema ?? "workflow";
36
55
  const qualify = (name) => `"${this.schema}"."${name}"`;
37
56
  this.t = Object.fromEntries(Object.keys(TABLES).map((key) => [key, qualify(TABLES[key].name)]));
38
- this.reporting = new PostgresReporting(sql, this.t);
57
+ this.reporting = new PostgresReporting(sql, this.t, this.schema);
39
58
  this.executions = new PostgresExecutions(sql, this.t.execution, this.t.entry);
40
59
  }
41
60
  /**
42
61
  * What the live database lacks against tables.ts: missing tables, missing columns, a column
43
- * of another type or nullability. Read-only — the engine never migrates the host's database; a
44
- * deploy fails with these problems listed, and the fix is to regenerate the drizzle file and
45
- * run the host's migrations.
62
+ * of another type or nullability, and missing unique indexes (the store's ON CONFLICT targets
63
+ * — without one the first ingest fails, not the deploy). Read-only — the engine never migrates
64
+ * the host's database; a deploy fails with these problems listed, and the fix is to regenerate
65
+ * the drizzle file and run the host's migrations.
46
66
  */
47
67
  async checkSchema() {
48
- const rows = await this.sql.transaction((tx) => tx.query(`SELECT table_name, column_name, udt_name, is_nullable FROM information_schema.columns WHERE table_schema = $1`, [this.schema]));
49
- const live = new Map(rows.map((row) => [`${row.table_name}.${row.column_name}`, row]));
50
- const tables = new Set(rows.map((row) => row.table_name));
68
+ const { columns, indexes } = await this.sql.transaction(async (tx) => ({
69
+ columns: await tx.query(`SELECT table_name, column_name, udt_name, is_nullable FROM information_schema.columns WHERE table_schema = $1`, [this.schema]),
70
+ indexes: await tx.query(`SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = $1`, [this.schema])
71
+ }));
72
+ const live = new Map(columns.map((row) => [`${row.table_name}.${row.column_name}`, row]));
73
+ const tables = new Set(columns.map((row) => row.table_name));
74
+ const liveIndexes = new Map(indexes.map((row) => [`${row.tablename}.${row.indexname}`, row]));
51
75
  const problems = [];
52
76
  for (const spec of Object.values(TABLES)) {
53
77
  const where = `"${this.schema}"."${spec.name}"`;
@@ -62,20 +86,50 @@ var PostgresJourneyStore = class {
62
86
  else if (row.udt_name !== UDT[column.type]) problems.push(`column ${where}."${name}" is ${row.udt_name}, expected ${UDT[column.type]}`);
63
87
  else if (row.is_nullable === "YES" !== (column.nullable === true)) problems.push(`column ${where}."${name}" should be ${column.nullable ? "nullable" : "NOT NULL"}`);
64
88
  }
89
+ for (const keys of spec.uniqueIndexes ?? []) {
90
+ const name = `${spec.name}_${keys.map(columnName).join("_")}_index`;
91
+ const row = liveIndexes.get(`${spec.name}.${name}`);
92
+ if (row === void 0) problems.push(`missing unique index "${this.schema}"."${name}"`);
93
+ else if (!/^CREATE UNIQUE INDEX/i.test(row.indexdef)) problems.push(`index "${this.schema}"."${name}" is not unique`);
94
+ }
65
95
  }
66
96
  return problems;
67
97
  }
68
98
  async insertEvent(input) {
69
- return (await this.sql.query(`INSERT INTO ${this.t.event} (id, event_id, user_id, name, ts, payload)
70
- VALUES ($1, $2, $3, $4, ${TS("$5")}, $6::text::jsonb)
71
- ON CONFLICT (event_id) DO NOTHING RETURNING id`, [
72
- uuidv7(input.ts),
73
- input.id,
74
- input.userId,
75
- input.event,
76
- input.ts,
77
- JSON.stringify(input.payload)
78
- ])).length > 0;
99
+ return this.sql.transaction(async (tx) => {
100
+ await this.lockUser(tx, input.userId);
101
+ if ((await tx.query(`INSERT INTO ${this.t.event} (id, event_id, user_id, name, ts, payload)
102
+ SELECT $1::uuid, $2, $3, $4, ${TS("$5")}, $6::text::jsonb ${this.notErased("$3")}
103
+ ON CONFLICT (user_id, event_id) DO NOTHING RETURNING id`, [
104
+ uuidv7(input.ts),
105
+ input.id,
106
+ input.userId,
107
+ input.event,
108
+ input.ts,
109
+ JSON.stringify(input.payload)
110
+ ])).length > 0) return true;
111
+ await this.refuseIfErased(tx, input.userId);
112
+ return false;
113
+ });
114
+ }
115
+ /** The condition every write about a user carries: no erasure tombstone for `$user`. */
116
+ notErased(user) {
117
+ return `WHERE NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = ${user})`;
118
+ }
119
+ /**
120
+ * A conditional write wrote nothing: on that path only, read why. Throws when the tombstone
121
+ * refused it; returns when the statement had another reason (a duplicate, an entry taken).
122
+ */
123
+ async refuseIfErased(sql, userId) {
124
+ if ((await sql.query(`SELECT 1 AS x FROM ${this.t.erasure} WHERE user_id = $1`, [userId])).length > 0) throw new UserErasedError(userId);
125
+ }
126
+ /**
127
+ * The first two statements of every transaction that writes about the user (user-lock.ts):
128
+ * READ COMMITTED, then the user's advisory lock, held until the transaction ends. Any read
129
+ * about the user comes after, on a snapshot taken once the lock is held.
130
+ */
131
+ lockUser(tx, userId) {
132
+ return lockUserTransaction(tx, this.schema, userId);
79
133
  }
80
134
  /** The event rows of one user and name, within the window's bounds (both inclusive). */
81
135
  eventWindow(userId, event, window) {
@@ -108,41 +162,90 @@ var PostgresJourneyStore = class {
108
162
  return row === void 0 ? void 0 : asJson(row.props);
109
163
  }
110
164
  async mergeProfile(userId, props) {
111
- return asJson((await this.sql.query(`INSERT INTO ${this.t.profile} (user_id, props) VALUES ($1, jsonb_strip_nulls($2::text::jsonb))
112
- ON CONFLICT (user_id) DO UPDATE SET props = jsonb_strip_nulls(${this.t.profile}.props || $3::text::jsonb)
113
- RETURNING props`, [
114
- userId,
115
- JSON.stringify(props),
116
- JSON.stringify(props)
117
- ])).at(0)?.props ?? {});
165
+ return this.sql.transaction(async (tx) => {
166
+ await this.lockUser(tx, userId);
167
+ const rows = await tx.query(`INSERT INTO ${this.t.profile} (user_id, props)
168
+ SELECT $1, jsonb_strip_nulls($2::text::jsonb) ${this.notErased("$1")}
169
+ ON CONFLICT (user_id) DO UPDATE SET props = jsonb_strip_nulls(${this.t.profile}.props || $3::text::jsonb)
170
+ RETURNING props`, [
171
+ userId,
172
+ JSON.stringify(props),
173
+ JSON.stringify(props)
174
+ ]);
175
+ if (rows.length === 0) throw new UserErasedError(userId);
176
+ return asJson(rows[0].props);
177
+ });
118
178
  }
119
- async listUserIds() {
120
- return (await this.sql.query(`SELECT user_id FROM ${this.t.profile} UNION SELECT DISTINCT user_id FROM ${this.t.event}`)).map((row) => row.user_id);
179
+ async listUserIds(page) {
180
+ const params = [];
181
+ let where = "";
182
+ if (page?.after !== void 0) {
183
+ params.push(page.after);
184
+ where = ` WHERE user_id > $${params.length}`;
185
+ }
186
+ let limit = "";
187
+ if (page?.limit !== void 0) {
188
+ params.push(page.limit);
189
+ limit = ` LIMIT $${params.length}`;
190
+ }
191
+ return (await this.sql.query(`SELECT user_id FROM (SELECT user_id FROM ${this.t.profile} UNION SELECT user_id FROM ${this.t.event}) u${where} ORDER BY user_id${limit}`, params)).map((row) => row.user_id);
121
192
  }
122
193
  listDevices(userId) {
123
194
  return this.sql.query(`SELECT token, platform FROM ${this.t.device} WHERE user_id = $1 ORDER BY ts DESC`, [userId]);
124
195
  }
125
196
  async upsertDevice(device) {
126
- await this.sql.query(`INSERT INTO ${this.t.device} (token, user_id, platform, ts) VALUES ($1, $2, $3, ${TS("$4")})
127
- ON CONFLICT (token) DO UPDATE SET user_id = EXCLUDED.user_id, platform = EXCLUDED.platform, ts = EXCLUDED.ts`, [
128
- device.token,
129
- device.userId,
130
- device.platform,
131
- device.ts
132
- ]);
197
+ await this.sql.transaction(async (tx) => {
198
+ await this.lockUser(tx, device.userId);
199
+ if ((await tx.query(`INSERT INTO ${this.t.device} (token, user_id, platform, ts)
200
+ SELECT $1, $2, $3, ${TS("$4")} ${this.notErased("$2")}
201
+ ON CONFLICT (token) DO UPDATE SET user_id = EXCLUDED.user_id, platform = EXCLUDED.platform, ts = EXCLUDED.ts
202
+ RETURNING token`, [
203
+ device.token,
204
+ device.userId,
205
+ device.platform,
206
+ device.ts
207
+ ])).length === 0) throw new UserErasedError(device.userId);
208
+ });
133
209
  }
134
210
  async requestErasure(userId, at) {
135
- await this.sql.query(`INSERT INTO ${this.t.erasure} (user_id, requested_at) VALUES ($1, ${TS("$2")})
136
- ON CONFLICT DO NOTHING`, [userId, at]);
211
+ await this.sql.transaction(async (tx) => {
212
+ await this.lockUser(tx, userId);
213
+ await tx.query(`INSERT INTO ${this.t.erasure} (user_id, requested_at) VALUES ($1, ${TS("$2")})
214
+ ON CONFLICT DO NOTHING`, [userId, at]);
215
+ });
137
216
  }
138
217
  async pendingErasures() {
139
- return (await this.sql.query(`SELECT user_id FROM ${this.t.erasure} ORDER BY requested_at`)).map((row) => row.user_id);
218
+ return (await this.sql.query(`SELECT user_id FROM ${this.t.erasure} WHERE erased_at IS NULL ORDER BY requested_at`)).map((row) => row.user_id);
219
+ }
220
+ async erasureState(userId) {
221
+ const row = (await this.sql.query(`SELECT erased_at IS NOT NULL AS erased FROM ${this.t.erasure} WHERE user_id = $1`, [userId])).at(0);
222
+ if (row === void 0) return "none";
223
+ return row.erased ? "erased" : "erasing";
140
224
  }
141
225
  async runningInstances(userId) {
142
- return (await this.sql.query(`SELECT instance_id FROM ${this.t.entry} WHERE user_id = $1 AND status = 'running'`, [userId])).map((row) => row.instance_id);
226
+ return (await this.sql.query(`SELECT instance_id, admitted_at IS NOT NULL AS admitted
227
+ FROM ${this.t.entry} WHERE user_id = $1 AND status = 'running'`, [userId])).map((row) => ({
228
+ instanceId: row.instance_id,
229
+ admitted: row.admitted
230
+ }));
231
+ }
232
+ async admitInstance(instanceId, userId) {
233
+ return this.sql.transaction(async (tx) => {
234
+ await this.lockUser(tx, userId);
235
+ return (await tx.query(`UPDATE ${this.t.entry} SET admitted_at = COALESCE(admitted_at, ${TS("$3")})
236
+ WHERE instance_id = $1 AND user_id = $2
237
+ AND (status = 'running' OR admitted_at IS NOT NULL)
238
+ AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = $2)
239
+ RETURNING instance_id`, [
240
+ instanceId,
241
+ userId,
242
+ Date.now()
243
+ ])).length > 0;
244
+ });
143
245
  }
144
- async forgetUser(userId) {
246
+ async forgetUser(userId, at) {
145
247
  await this.sql.transaction(async (tx) => {
248
+ await this.lockUser(tx, userId);
146
249
  for (const child of [
147
250
  this.t.messageOpen,
148
251
  this.t.messageClick,
@@ -156,12 +259,11 @@ var PostgresJourneyStore = class {
156
259
  this.t.subscription,
157
260
  this.t.segmentStart,
158
261
  this.t.segmentMember,
159
- this.t.suppression,
160
262
  this.t.device,
161
263
  this.t.event,
162
- this.t.profile,
163
- this.t.erasure
264
+ this.t.profile
164
265
  ]) await tx.query(`DELETE FROM ${table} WHERE user_id = $1`, [userId]);
266
+ await tx.query(`UPDATE ${this.t.erasure} SET erased_at = COALESCE(erased_at, ${TS("$2")}) WHERE user_id = $1`, [userId, at]);
165
267
  });
166
268
  }
167
269
  async isSuppressed(userId, channel) {
@@ -267,13 +369,17 @@ var PostgresJourneyStore = class {
267
369
  }
268
370
  async addSegmentMember(segment, userId, ts, workflows) {
269
371
  await this.sql.transaction(async (tx) => {
270
- await tx.query(`INSERT INTO ${this.t.segmentMember} (segment, user_id, ts) VALUES ($1, $2, ${TS("$3")}) ON CONFLICT DO NOTHING`, [
372
+ await this.lockUser(tx, userId);
373
+ if ((await tx.query(`INSERT INTO ${this.t.segmentMember} (segment, user_id, ts)
374
+ SELECT $1, $2, ${TS("$3")} ${this.notErased("$2")}
375
+ ON CONFLICT DO NOTHING RETURNING segment`, [
271
376
  segment,
272
377
  userId,
273
378
  ts
274
- ]);
379
+ ])).length === 0) await this.refuseIfErased(tx, userId);
275
380
  for (const workflow of workflows) await tx.query(`INSERT INTO ${this.t.segmentStart} (segment, workflow, user_id, ts)
276
- VALUES ($1, $2, $3, ${TS("$4")}) ON CONFLICT DO NOTHING`, [
381
+ SELECT $1, $2, $3, ${TS("$4")} ${this.notErased("$3")}
382
+ ON CONFLICT DO NOTHING`, [
277
383
  segment,
278
384
  workflow,
279
385
  userId,
@@ -299,16 +405,16 @@ var PostgresJourneyStore = class {
299
405
  }
300
406
  async enterJourney(entry) {
301
407
  const { execution } = entry;
302
- if (execution === void 0) return this.claimEntry(this.sql, entry);
303
408
  return this.sql.transaction(async (tx) => {
409
+ await this.lockUser(tx, entry.userId);
304
410
  const outcome = await this.claimEntry(tx, entry);
305
- if (outcome !== null) await this.recordStart(tx, entry, execution);
411
+ if (outcome !== null && execution !== void 0) await this.recordStart(tx, entry, execution);
306
412
  return outcome;
307
413
  });
308
414
  }
309
415
  async claimEntry(sql, entry) {
310
416
  if ((await sql.query(`INSERT INTO ${this.t.entry} (workflow, user_id, instance_id, hash, status, ts)
311
- VALUES ($1, $2, $3, $4, 'running', ${TS("$5")})
417
+ SELECT $1, $2, $3, $4, 'running', ${TS("$5")} ${this.notErased("$2")}
312
418
  ON CONFLICT (workflow, user_id) DO NOTHING RETURNING instance_id`, [
313
419
  entry.workflow,
314
420
  entry.userId,
@@ -316,14 +422,19 @@ var PostgresJourneyStore = class {
316
422
  entry.hash,
317
423
  entry.ts
318
424
  ])).length > 0) return "entered";
319
- return (await sql.query(`UPDATE ${this.t.entry} SET instance_id = $1, hash = $2, status = 'running', ts = ${TS("$3")}
320
- WHERE workflow = $4 AND user_id = $5 AND status = 'failed' RETURNING instance_id`, [
425
+ if ((await sql.query(`UPDATE ${this.t.entry} SET instance_id = $1, hash = $2, status = 'running', ts = ${TS("$3")},
426
+ admitted_at = NULL
427
+ WHERE workflow = $4 AND user_id = $5 AND status = 'failed'
428
+ AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = $5)
429
+ RETURNING instance_id`, [
321
430
  entry.instanceId,
322
431
  entry.hash,
323
432
  entry.ts,
324
433
  entry.workflow,
325
434
  entry.userId
326
- ])).length > 0 ? "reclaimed" : null;
435
+ ])).length > 0) return "reclaimed";
436
+ await this.refuseIfErased(sql, entry.userId);
437
+ return null;
327
438
  }
328
439
  /**
329
440
  * Only an entry this call claimed records a start, so a row already there is a previous
@@ -331,13 +442,14 @@ var PostgresJourneyStore = class {
331
442
  * ts). It is replaced whole: the new attempt's name and payload, nothing submitted yet.
332
443
  */
333
444
  async recordStart(tx, entry, execution) {
334
- await tx.query(`INSERT INTO ${this.t.execution} (instance_id, workflow, user_id, name, target, input, created_at, generation)
335
- VALUES ($1, $2, $3, $4, $5, $6, ${TS("$7")}, 0)
445
+ if ((await tx.query(`INSERT INTO ${this.t.execution} (instance_id, workflow, user_id, name, target, input, created_at, generation)
446
+ SELECT $1, $2, $3, $4, $5, $6, ${TS("$7")}, 0 ${this.notErased("$3")}
336
447
  ON CONFLICT (instance_id) DO UPDATE SET
337
448
  name = EXCLUDED.name, target = EXCLUDED.target, input = EXCLUDED.input,
338
449
  created_at = EXCLUDED.created_at, generation = 0, submitted_at = NULL, arn = NULL,
339
450
  unresolved_at = NULL, callback_id = NULL, deadline_at = NULL, checked_at = NULL,
340
- stopped_at = NULL`, [
451
+ stopped_at = NULL
452
+ RETURNING instance_id`, [
341
453
  entry.instanceId,
342
454
  entry.workflow,
343
455
  entry.userId,
@@ -345,7 +457,7 @@ var PostgresJourneyStore = class {
345
457
  execution.target,
346
458
  execution.input,
347
459
  Date.now()
348
- ]);
460
+ ])).length === 0) throw new UserErasedError(entry.userId);
349
461
  }
350
462
  async removeEntry(instanceId) {
351
463
  await this.sql.query(`DELETE FROM ${this.t.entry} WHERE instance_id = $1`, [instanceId]);
@@ -357,12 +469,19 @@ var PostgresJourneyStore = class {
357
469
  return (await this.sql.query(`SELECT DISTINCT wake_handle AS handle FROM ${this.t.subscription} WHERE user_id = $1 AND event = $2`, [userId, event])).map((row) => row.handle);
358
470
  }
359
471
  async subscribe(handle, userId, events, ts) {
360
- for (const event of events) await this.sql.query(`INSERT INTO ${this.t.subscription} (user_id, event, wake_handle, ts) VALUES ($1, $2, $3, ${TS("$4")}) ON CONFLICT DO NOTHING`, [
361
- userId,
362
- event,
363
- handle,
364
- ts
365
- ]);
472
+ await this.sql.transaction(async (tx) => {
473
+ await this.lockUser(tx, userId);
474
+ let skipped = false;
475
+ for (const event of events) if ((await tx.query(`INSERT INTO ${this.t.subscription} (user_id, event, wake_handle, ts)
476
+ SELECT $1, $2, $3, ${TS("$4")} ${this.notErased("$1")}
477
+ ON CONFLICT DO NOTHING RETURNING event`, [
478
+ userId,
479
+ event,
480
+ handle,
481
+ ts
482
+ ])).length === 0) skipped = true;
483
+ if (skipped) await this.refuseIfErased(tx, userId);
484
+ });
366
485
  }
367
486
  async unsubscribe(handle) {
368
487
  await this.sql.query(`DELETE FROM ${this.t.subscription} WHERE wake_handle = $1`, [handle]);