@shware/workflow 6.1.1 → 8.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 (192) hide show
  1. package/README.md +132 -35
  2. package/dist/aws/engine-step.cjs +4 -1
  3. package/dist/aws/engine-step.cjs.map +1 -1
  4. package/dist/aws/engine-step.d.cts.map +1 -1
  5. package/dist/aws/engine-step.d.mts.map +1 -1
  6. package/dist/aws/engine-step.mjs +4 -1
  7. package/dist/aws/engine-step.mjs.map +1 -1
  8. package/dist/aws/index.cjs +3 -2
  9. package/dist/aws/index.cjs.map +1 -1
  10. package/dist/aws/index.mjs +3 -2
  11. package/dist/aws/index.mjs.map +1 -1
  12. package/dist/client.cjs +66 -41
  13. package/dist/client.cjs.map +1 -1
  14. package/dist/client.d.cts +72 -22
  15. package/dist/client.d.cts.map +1 -1
  16. package/dist/client.d.mts +72 -22
  17. package/dist/client.d.mts.map +1 -1
  18. package/dist/client.mjs +66 -42
  19. package/dist/client.mjs.map +1 -1
  20. package/dist/cloudflare/index.d.cts +4 -3
  21. package/dist/cloudflare/index.d.mts +4 -3
  22. package/dist/cloudflare/runner.cjs +3 -1
  23. package/dist/cloudflare/runner.cjs.map +1 -1
  24. package/dist/cloudflare/runner.d.cts.map +1 -1
  25. package/dist/cloudflare/runner.d.mts.map +1 -1
  26. package/dist/cloudflare/runner.mjs +3 -1
  27. package/dist/cloudflare/runner.mjs.map +1 -1
  28. package/dist/cloudflare/worker.cjs +13 -2
  29. package/dist/cloudflare/worker.cjs.map +1 -1
  30. package/dist/cloudflare/worker.d.cts +9 -1
  31. package/dist/cloudflare/worker.d.cts.map +1 -1
  32. package/dist/cloudflare/worker.d.mts +9 -1
  33. package/dist/cloudflare/worker.d.mts.map +1 -1
  34. package/dist/cloudflare/worker.mjs +13 -2
  35. package/dist/cloudflare/worker.mjs.map +1 -1
  36. package/dist/contract.cjs.map +1 -1
  37. package/dist/contract.d.cts +4 -4
  38. package/dist/contract.d.mts +4 -4
  39. package/dist/contract.mjs.map +1 -1
  40. package/dist/dsl/trigger.cjs +2 -1
  41. package/dist/dsl/trigger.cjs.map +1 -1
  42. package/dist/dsl/trigger.d.cts +6 -0
  43. package/dist/dsl/trigger.d.cts.map +1 -1
  44. package/dist/dsl/trigger.d.mts +6 -0
  45. package/dist/dsl/trigger.d.mts.map +1 -1
  46. package/dist/dsl/trigger.mjs +2 -1
  47. package/dist/dsl/trigger.mjs.map +1 -1
  48. package/dist/email-sender.cjs +8 -1
  49. package/dist/email-sender.cjs.map +1 -1
  50. package/dist/email-sender.d.cts +2 -2
  51. package/dist/email-sender.d.cts.map +1 -1
  52. package/dist/email-sender.d.mts +2 -2
  53. package/dist/email-sender.d.mts.map +1 -1
  54. package/dist/email-sender.mjs +9 -2
  55. package/dist/email-sender.mjs.map +1 -1
  56. package/dist/email.cjs +5 -0
  57. package/dist/email.cjs.map +1 -1
  58. package/dist/email.d.cts +2 -0
  59. package/dist/email.d.cts.map +1 -1
  60. package/dist/email.d.mts +2 -0
  61. package/dist/email.d.mts.map +1 -1
  62. package/dist/email.mjs +5 -1
  63. package/dist/email.mjs.map +1 -1
  64. package/dist/engine/condition.cjs +7 -5
  65. package/dist/engine/condition.cjs.map +1 -1
  66. package/dist/engine/condition.d.cts +3 -3
  67. package/dist/engine/condition.d.cts.map +1 -1
  68. package/dist/engine/condition.d.mts +3 -3
  69. package/dist/engine/condition.d.mts.map +1 -1
  70. package/dist/engine/condition.mjs +7 -5
  71. package/dist/engine/condition.mjs.map +1 -1
  72. package/dist/engine/interpreter.cjs +11 -6
  73. package/dist/engine/interpreter.cjs.map +1 -1
  74. package/dist/engine/interpreter.d.cts.map +1 -1
  75. package/dist/engine/interpreter.d.mts.map +1 -1
  76. package/dist/engine/interpreter.mjs +11 -6
  77. package/dist/engine/interpreter.mjs.map +1 -1
  78. package/dist/engine/ports.d.cts +21 -3
  79. package/dist/engine/ports.d.cts.map +1 -1
  80. package/dist/engine/ports.d.mts +21 -3
  81. package/dist/engine/ports.d.mts.map +1 -1
  82. package/dist/host/batch.cjs +172 -0
  83. package/dist/host/batch.cjs.map +1 -0
  84. package/dist/host/batch.d.cts +88 -0
  85. package/dist/host/batch.d.cts.map +1 -0
  86. package/dist/host/batch.d.mts +88 -0
  87. package/dist/host/batch.d.mts.map +1 -0
  88. package/dist/host/batch.mjs +163 -0
  89. package/dist/host/batch.mjs.map +1 -0
  90. package/dist/host/execute.cjs +10 -6
  91. package/dist/host/execute.cjs.map +1 -1
  92. package/dist/host/execute.mjs +10 -6
  93. package/dist/host/execute.mjs.map +1 -1
  94. package/dist/host/http.cjs +31 -90
  95. package/dist/host/http.cjs.map +1 -1
  96. package/dist/host/http.d.cts +4 -2
  97. package/dist/host/http.d.cts.map +1 -1
  98. package/dist/host/http.d.mts +4 -2
  99. package/dist/host/http.d.mts.map +1 -1
  100. package/dist/host/http.mjs +25 -84
  101. package/dist/host/http.mjs.map +1 -1
  102. package/dist/host/ingest.cjs +164 -25
  103. package/dist/host/ingest.cjs.map +1 -1
  104. package/dist/host/ingest.d.cts +38 -8
  105. package/dist/host/ingest.d.cts.map +1 -1
  106. package/dist/host/ingest.d.mts +38 -8
  107. package/dist/host/ingest.d.mts.map +1 -1
  108. package/dist/host/ingest.mjs +161 -26
  109. package/dist/host/ingest.mjs.map +1 -1
  110. package/dist/host/journey-host.cjs +8 -1
  111. package/dist/host/journey-host.cjs.map +1 -1
  112. package/dist/host/journey-host.d.cts +10 -4
  113. package/dist/host/journey-host.d.cts.map +1 -1
  114. package/dist/host/journey-host.d.mts +10 -4
  115. package/dist/host/journey-host.d.mts.map +1 -1
  116. package/dist/host/journey-host.mjs +8 -1
  117. package/dist/host/journey-host.mjs.map +1 -1
  118. package/dist/host/platform.cjs.map +1 -1
  119. package/dist/host/platform.d.cts +2 -0
  120. package/dist/host/platform.d.cts.map +1 -1
  121. package/dist/host/platform.d.mts +2 -0
  122. package/dist/host/platform.d.mts.map +1 -1
  123. package/dist/host/platform.mjs.map +1 -1
  124. package/dist/index.cjs +1 -0
  125. package/dist/index.d.cts +2 -2
  126. package/dist/index.d.mts +2 -2
  127. package/dist/index.mjs +2 -2
  128. package/dist/ir.cjs +2 -1
  129. package/dist/ir.cjs.map +1 -1
  130. package/dist/ir.d.cts +3 -0
  131. package/dist/ir.d.cts.map +1 -1
  132. package/dist/ir.d.mts +3 -0
  133. package/dist/ir.d.mts.map +1 -1
  134. package/dist/ir.mjs +2 -1
  135. package/dist/ir.mjs.map +1 -1
  136. package/dist/reporting/observer.cjs +1 -0
  137. package/dist/reporting/observer.cjs.map +1 -1
  138. package/dist/reporting/observer.mjs +1 -0
  139. package/dist/reporting/observer.mjs.map +1 -1
  140. package/dist/store/facts.cjs +29 -9
  141. package/dist/store/facts.cjs.map +1 -1
  142. package/dist/store/facts.d.cts +7 -2
  143. package/dist/store/facts.d.cts.map +1 -1
  144. package/dist/store/facts.d.mts +7 -2
  145. package/dist/store/facts.d.mts.map +1 -1
  146. package/dist/store/facts.mjs +29 -9
  147. package/dist/store/facts.mjs.map +1 -1
  148. package/dist/store/identity.cjs +34 -0
  149. package/dist/store/identity.cjs.map +1 -0
  150. package/dist/store/identity.d.cts +13 -0
  151. package/dist/store/identity.d.cts.map +1 -0
  152. package/dist/store/identity.d.mts +13 -0
  153. package/dist/store/identity.d.mts.map +1 -0
  154. package/dist/store/identity.mjs +30 -0
  155. package/dist/store/identity.mjs.map +1 -0
  156. package/dist/store/index.cjs +5 -0
  157. package/dist/store/index.cjs.map +1 -1
  158. package/dist/store/index.d.cts +119 -25
  159. package/dist/store/index.d.cts.map +1 -1
  160. package/dist/store/index.d.mts +119 -25
  161. package/dist/store/index.d.mts.map +1 -1
  162. package/dist/store/index.mjs +2 -1
  163. package/dist/store/index.mjs.map +1 -1
  164. package/dist/store/postgres-reporting.cjs +4 -0
  165. package/dist/store/postgres-reporting.cjs.map +1 -1
  166. package/dist/store/postgres-reporting.d.cts +1 -0
  167. package/dist/store/postgres-reporting.d.cts.map +1 -1
  168. package/dist/store/postgres-reporting.d.mts +1 -0
  169. package/dist/store/postgres-reporting.d.mts.map +1 -1
  170. package/dist/store/postgres-reporting.mjs +4 -0
  171. package/dist/store/postgres-reporting.mjs.map +1 -1
  172. package/dist/store/postgres.cjs +271 -55
  173. package/dist/store/postgres.cjs.map +1 -1
  174. package/dist/store/postgres.d.cts +29 -7
  175. package/dist/store/postgres.d.cts.map +1 -1
  176. package/dist/store/postgres.d.mts +29 -7
  177. package/dist/store/postgres.d.mts.map +1 -1
  178. package/dist/store/postgres.mjs +272 -56
  179. package/dist/store/postgres.mjs.map +1 -1
  180. package/dist/store/tables.cjs +28 -9
  181. package/dist/store/tables.cjs.map +1 -1
  182. package/dist/store/tables.d.cts +1 -0
  183. package/dist/store/tables.d.cts.map +1 -1
  184. package/dist/store/tables.d.mts +1 -0
  185. package/dist/store/tables.d.mts.map +1 -1
  186. package/dist/store/tables.mjs +28 -9
  187. package/dist/store/tables.mjs.map +1 -1
  188. package/dist/webpush.cjs.map +1 -1
  189. package/dist/webpush.d.cts +1 -1
  190. package/dist/webpush.d.mts +1 -1
  191. package/dist/webpush.mjs.map +1 -1
  192. package/package.json +1 -1
@@ -1,10 +1,16 @@
1
1
  import { ConditionIR, WorkflowIR } from "../ir.mjs";
2
2
  import { UserErasedError } from "./erasure.mjs";
3
+ import { visitorIdOf, visitorSubject } from "./identity.mjs";
3
4
  import { uuidv7 } from "./sql.mjs";
4
- import { lockUserTransaction } from "./user-lock.mjs";
5
+ import { beginReadCommitted, lockUser, lockUserTransaction } from "./user-lock.mjs";
5
6
  import { PostgresReporting } from "./postgres-reporting.mjs";
6
7
  import { DEFAULT_SCHEMA, TABLES, columnName, indexName, uniqueConstraintName } from "./tables.mjs";
7
8
  // src/store/postgres.ts
9
+ /** Who a device row belongs to: its user, or before sign-in its visitor. */
10
+ function deviceOwner(row) {
11
+ if (row.user_id !== null) return row.user_id;
12
+ return row.visitor_id === null ? void 0 : visitorSubject(row.visitor_id);
13
+ }
8
14
  /** information_schema's udt_name per column type. */
9
15
  const UDT = {
10
16
  uuid: "uuid",
@@ -154,29 +160,55 @@ var PostgresJourneyStore = class {
154
160
  params
155
161
  };
156
162
  }
157
- async countEvents(userId, event, window) {
163
+ async countEvents(userId, event, window, atLeast) {
158
164
  const { where, params } = this.eventWindow(userId, event, window);
159
- const rows = await this.sql.query(`SELECT count(*)::int AS c FROM ${this.t.event} WHERE ${where}`, params);
165
+ const capped = atLeast !== void 0 && Number.isInteger(atLeast) && atLeast > 0;
166
+ if (capped) params.push(atLeast);
167
+ const rows = await this.sql.query(capped ? `SELECT count(*)::int AS c FROM (SELECT 1 FROM ${this.t.event} WHERE ${where} LIMIT $${params.length}) capped` : `SELECT count(*)::int AS c FROM ${this.t.event} WHERE ${where}`, params);
160
168
  return Number(rows.at(0)?.c ?? 0);
161
169
  }
162
- async listEventPayloads(userId, event, window) {
170
+ async pageEventPayloads(userId, event, window, page) {
163
171
  const { where, params } = this.eventWindow(userId, event, window);
164
- return (await this.sql.query(`SELECT payload FROM ${this.t.event} WHERE ${where}`, params)).map((row) => asJson(row.payload));
172
+ let keyset = "";
173
+ if (page.after !== void 0) {
174
+ params.push(page.after.ts, page.after.id);
175
+ keyset = ` AND (ts, id) > ($${params.length - 1}::timestamptz, $${params.length}::uuid)`;
176
+ }
177
+ params.push(page.limit);
178
+ const rows = await this.sql.query(`SELECT payload, ts::text AS ts, id::text AS id FROM ${this.t.event}
179
+ WHERE ${where}${keyset} ORDER BY ts, id LIMIT $${params.length}`, params);
180
+ const last = rows.at(-1);
181
+ return {
182
+ payloads: rows.map((row) => asJson(row.payload)),
183
+ ...last !== void 0 && rows.length === page.limit ? { next: {
184
+ ts: last.ts,
185
+ id: last.id
186
+ } } : {}
187
+ };
165
188
  }
166
189
  async getProfile(userId) {
167
190
  const row = (await this.sql.query(`SELECT props FROM ${this.t.profile} WHERE user_id = $1`, [userId])).at(0);
168
191
  return row === void 0 ? void 0 : asJson(row.props);
169
192
  }
170
- async mergeProfile(userId, props) {
193
+ async mergeProfile(userId, props, at = Date.now()) {
171
194
  return this.sql.transaction(async (tx) => {
172
195
  await this.lockUser(tx, userId);
173
- const rows = await tx.query(`INSERT INTO ${this.t.profile} (user_id, props)
174
- SELECT $1, jsonb_strip_nulls($2::text::jsonb) ${this.notErased("$1")}
175
- ON CONFLICT (user_id) DO UPDATE SET props = jsonb_strip_nulls(${this.t.profile}.props || $3::text::jsonb)
196
+ const times = asJson((await tx.query(`SELECT props_at FROM ${this.t.profile} WHERE user_id = $1`, [userId]))[0]?.props_at ?? null) ?? {};
197
+ const patch = Object.fromEntries(Object.entries(props).filter(([key]) => (times[key] ?? Number.NEGATIVE_INFINITY) <= at));
198
+ const stamped = {
199
+ ...times,
200
+ ...Object.fromEntries(Object.keys(patch).map((k) => [k, at]))
201
+ };
202
+ const rows = await tx.query(`INSERT INTO ${this.t.profile} (user_id, props, props_at)
203
+ SELECT $1, jsonb_strip_nulls($2::text::jsonb), $4::text::jsonb ${this.notErased("$1")}
204
+ ON CONFLICT (user_id) DO UPDATE SET
205
+ props = jsonb_strip_nulls(${this.t.profile}.props || $3::text::jsonb),
206
+ props_at = $4::text::jsonb
176
207
  RETURNING props`, [
177
208
  userId,
178
- JSON.stringify(props),
179
- JSON.stringify(props)
209
+ JSON.stringify(patch),
210
+ JSON.stringify(patch),
211
+ JSON.stringify(stamped)
180
212
  ]);
181
213
  if (rows.length === 0) throw new UserErasedError(userId);
182
214
  return asJson(rows[0].props);
@@ -184,10 +216,10 @@ var PostgresJourneyStore = class {
184
216
  }
185
217
  async listUserIds(page) {
186
218
  const params = [];
187
- let where = "";
219
+ let where = ` WHERE user_id NOT LIKE '${visitorSubject("")}%'`;
188
220
  if (page?.after !== void 0) {
189
221
  params.push(page.after);
190
- where = ` WHERE user_id > $${params.length}`;
222
+ where += ` AND user_id > $${params.length}`;
191
223
  }
192
224
  let limit = "";
193
225
  if (page?.limit !== void 0) {
@@ -196,23 +228,160 @@ var PostgresJourneyStore = class {
196
228
  }
197
229
  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);
198
230
  }
199
- listDevices(userId) {
200
- return this.sql.query(`SELECT token, platform FROM ${this.t.device} WHERE user_id = $1 ORDER BY ts DESC`, [userId]);
231
+ listDevices(subject) {
232
+ const visitorId = visitorIdOf(subject);
233
+ return this.sql.query(visitorId === void 0 ? `SELECT token, platform FROM ${this.t.device} WHERE user_id = $1 ORDER BY updated_at DESC` : `SELECT token, platform FROM ${this.t.device} WHERE visitor_id = $1 AND user_id IS NULL ORDER BY updated_at DESC`, [visitorId ?? subject]);
201
234
  }
202
235
  async upsertDevice(device) {
203
- await this.sql.transaction(async (tx) => {
204
- await this.lockUser(tx, device.userId);
205
- if ((await tx.query(`INSERT INTO ${this.t.device} (token, user_id, platform, ts)
206
- SELECT $1, $2, $3, ${TS("$4")} ${this.notErased("$2")}
207
- ON CONFLICT (token) DO UPDATE SET user_id = EXCLUDED.user_id, platform = EXCLUDED.platform, ts = EXCLUDED.ts
236
+ const { deviceId = null, userId, visitorId = null, token, platform, at } = device;
237
+ const subject = userId ?? visitorSubject(visitorId ?? "");
238
+ return this.sql.transaction(async (tx) => {
239
+ await this.lockUser(tx, subject);
240
+ const previous = await tx.query(`SELECT token, user_id, visitor_id FROM ${this.t.device} WHERE token = $1 OR device_id = $2 FOR UPDATE`, [token, deviceId]);
241
+ for (const row of previous) {
242
+ if (row.token === token) continue;
243
+ const owner = deviceOwner(row);
244
+ await tx.query(owner === void 0 || owner === subject ? `DELETE FROM ${this.t.device} WHERE token = $1` : `UPDATE ${this.t.device} SET device_id = NULL WHERE token = $1`, [row.token]);
245
+ }
246
+ if ((await tx.query(`INSERT INTO ${this.t.device} (device_id, user_id, visitor_id, token, platform, created_at, updated_at)
247
+ SELECT $1, $2, $3, $4, $5, ${TS("$6")}, ${TS("$6")} ${this.notErased("$7")}
248
+ ON CONFLICT (token) DO UPDATE SET
249
+ device_id = COALESCE(EXCLUDED.device_id, ${this.t.device}.device_id),
250
+ user_id = EXCLUDED.user_id,
251
+ visitor_id = COALESCE(EXCLUDED.visitor_id, ${this.t.device}.visitor_id),
252
+ platform = EXCLUDED.platform,
253
+ updated_at = EXCLUDED.updated_at
208
254
  RETURNING token`, [
209
- device.token,
210
- device.userId,
211
- device.platform,
212
- device.ts
213
- ])).length === 0) throw new UserErasedError(device.userId);
255
+ deviceId,
256
+ userId,
257
+ visitorId,
258
+ token,
259
+ platform,
260
+ at,
261
+ subject
262
+ ])).length === 0) throw new UserErasedError(subject);
263
+ const owners = previous.flatMap((row) => {
264
+ const owner = deviceOwner(row);
265
+ return owner === void 0 || owner === subject ? [] : [owner];
266
+ });
267
+ return [...new Set(owners)];
268
+ });
269
+ }
270
+ async linkedUser(visitorId) {
271
+ return (await this.sql.query(`SELECT user_id FROM ${this.t.visitor} WHERE visitor_id = $1`, [visitorId]))[0]?.user_id ?? void 0;
272
+ }
273
+ async touchVisitor(visitorId, at) {
274
+ return (await this.sql.query(`INSERT INTO ${this.t.visitor} (visitor_id, user_id, created_at, updated_at)
275
+ VALUES ($1, NULL, ${TS("$2")}, ${TS("$2")})
276
+ ON CONFLICT (visitor_id) DO UPDATE SET updated_at = EXCLUDED.updated_at
277
+ RETURNING user_id`, [visitorId, at]))[0]?.user_id ?? visitorSubject(visitorId);
278
+ }
279
+ async pruneVisitors(before, limit) {
280
+ const idle = `user_id IS NULL AND updated_at < ${TS("$1")} AND NOT EXISTS (
281
+ SELECT 1 FROM ${this.t.entry} e
282
+ WHERE e.user_id = '${visitorSubject("")}' || visitor_id AND e.status = 'running')`;
283
+ const candidates = await this.sql.query(`SELECT visitor_id FROM ${this.t.visitor} WHERE ${idle} ORDER BY updated_at LIMIT $2`, [before, limit]);
284
+ let pruned = 0;
285
+ for (const { visitor_id: visitorId } of candidates) {
286
+ const subject = visitorSubject(visitorId);
287
+ await this.sql.transaction(async (tx) => {
288
+ await this.lockUser(tx, subject);
289
+ if ((await tx.query(`DELETE FROM ${this.t.visitor} WHERE visitor_id = $2 AND ${idle} RETURNING visitor_id`, [before, visitorId])).length === 0) return;
290
+ await this.deleteSubjectRows(tx, subject);
291
+ pruned += 1;
292
+ });
293
+ }
294
+ return pruned;
295
+ }
296
+ async entryOwner(instanceId) {
297
+ return (await this.sql.query(`SELECT user_id FROM ${this.t.entry} WHERE instance_id = $1`, [instanceId]))[0]?.user_id;
298
+ }
299
+ async linkVisitor(visitorId, userId, at) {
300
+ const subject = visitorSubject(visitorId);
301
+ if (await this.linkedUser(visitorId) !== void 0) return null;
302
+ return this.sql.transaction(async (tx) => {
303
+ await beginReadCommitted(tx);
304
+ for (const key of [subject, userId].sort()) await lockUser(tx, this.schema, key);
305
+ if ((await tx.query(`INSERT INTO ${this.t.visitor} (visitor_id, user_id, created_at, updated_at)
306
+ SELECT $1, $2, ${TS("$3")}, ${TS("$3")} ${this.notErased("$2")}
307
+ ON CONFLICT (visitor_id) DO UPDATE SET user_id = EXCLUDED.user_id, updated_at = EXCLUDED.updated_at
308
+ WHERE ${this.t.visitor}.user_id IS NULL
309
+ RETURNING visitor_id`, [
310
+ visitorId,
311
+ userId,
312
+ at
313
+ ])).length === 0) {
314
+ if ((await tx.query(`SELECT 1 FROM ${this.t.erasure} WHERE user_id = $1`, [userId])).length > 0) throw new UserErasedError(userId);
315
+ return null;
316
+ }
317
+ const droppedIds = (await tx.query(`DELETE FROM ${this.t.entry} v USING ${this.t.entry} u
318
+ WHERE v.user_id = $1 AND u.user_id = $2 AND u.workflow = v.workflow
319
+ RETURNING v.instance_id`, [subject, userId])).map((row) => row.instance_id);
320
+ await tx.query(`DELETE FROM ${this.t.subscription} WHERE wake_handle = ANY($1::text[])`, [droppedIds]);
321
+ await tx.query(`DELETE FROM ${this.t.execution} WHERE instance_id = ANY($1::text[])`, [droppedIds]);
322
+ await tx.query(`DELETE FROM ${this.t.event} v USING ${this.t.event} u
323
+ WHERE v.user_id = $1 AND u.user_id = $2 AND u.event_id = v.event_id`, [subject, userId]);
324
+ const events = await tx.query(`SELECT DISTINCT name FROM ${this.t.event} WHERE user_id = $1`, [subject]);
325
+ await tx.query(`INSERT INTO ${this.t.suppression} (user_id, channel, topic, ts)
326
+ SELECT $2, channel, topic, ts FROM ${this.t.suppression} WHERE user_id = $1
327
+ ON CONFLICT (user_id, channel, topic) DO NOTHING`, [subject, userId]);
328
+ await tx.query(`DELETE FROM ${this.t.suppression} WHERE user_id = $1`, [subject]);
329
+ const props = await this.mergeProfileInto(tx, subject, userId);
330
+ for (const table of [
331
+ this.t.event,
332
+ this.t.entry,
333
+ this.t.execution,
334
+ this.t.subscription,
335
+ this.t.instance,
336
+ this.t.message
337
+ ]) await tx.query(`UPDATE ${table} SET user_id = $2 WHERE user_id = $1`, [subject, userId]);
338
+ await tx.query(`UPDATE ${this.t.device} SET user_id = $2, updated_at = ${TS("$3")}
339
+ WHERE visitor_id = $1 AND user_id IS NULL`, [
340
+ visitorId,
341
+ userId,
342
+ at
343
+ ]);
344
+ return {
345
+ dropped: droppedIds,
346
+ events: events.map((row) => row.name),
347
+ props
348
+ };
214
349
  });
215
350
  }
351
+ /**
352
+ * The visitor's properties under the user's, a key the user has a time for going to the later
353
+ * write, one a row older than the times going to the user if they hold it. The visitor's row
354
+ * goes. Answers the keys that moved.
355
+ */
356
+ async mergeProfileInto(tx, subject, userId) {
357
+ const rows = await tx.query(`SELECT user_id, props, props_at FROM ${this.t.profile} WHERE user_id = ANY($1::text[])`, [[subject, userId]]);
358
+ const from = rows.find((row) => row.user_id === subject);
359
+ if (from === void 0) return [];
360
+ const to = rows.find((row) => row.user_id === userId);
361
+ const fromTimes = asJson(from.props_at ?? null) ?? {};
362
+ const toTimes = asJson(to?.props_at ?? null) ?? {};
363
+ const toProps = to === void 0 ? {} : asJson(to.props);
364
+ const moved = Object.entries(asJson(from.props)).filter(([key]) => {
365
+ const written = toTimes[key];
366
+ return written === void 0 ? !Object.hasOwn(toProps, key) : (fromTimes[key] ?? 0) > written;
367
+ });
368
+ const props = {
369
+ ...toProps,
370
+ ...Object.fromEntries(moved)
371
+ };
372
+ const times = {
373
+ ...toTimes,
374
+ ...Object.fromEntries(moved.map(([key]) => [key, fromTimes[key] ?? 0]))
375
+ };
376
+ await tx.query(`INSERT INTO ${this.t.profile} (user_id, props, props_at) VALUES ($1, $2::text::jsonb, $3::text::jsonb)
377
+ ON CONFLICT (user_id) DO UPDATE SET props = EXCLUDED.props, props_at = EXCLUDED.props_at`, [
378
+ userId,
379
+ JSON.stringify(props),
380
+ JSON.stringify(times)
381
+ ]);
382
+ await tx.query(`DELETE FROM ${this.t.profile} WHERE user_id = $1`, [subject]);
383
+ return moved.map(([key]) => key);
384
+ }
216
385
  async requestErasure(userId, at) {
217
386
  await this.sql.transaction(async (tx) => {
218
387
  await this.lockUser(tx, userId);
@@ -252,27 +421,33 @@ var PostgresJourneyStore = class {
252
421
  async forgetUser(userId, at) {
253
422
  await this.sql.transaction(async (tx) => {
254
423
  await this.lockUser(tx, userId);
255
- for (const child of [
256
- this.t.messageOpen,
257
- this.t.messageClick,
258
- this.t.messageLink
259
- ]) await tx.query(`DELETE FROM ${child} WHERE message_id IN (SELECT id FROM ${this.t.message} WHERE user_id = $1)`, [userId]);
260
- for (const table of [
261
- this.t.message,
262
- this.t.instance,
263
- this.t.entry,
264
- this.t.execution,
265
- this.t.subscription,
266
- this.t.segmentStart,
267
- this.t.segmentMember,
268
- this.t.suppression,
269
- this.t.device,
270
- this.t.event,
271
- this.t.profile
272
- ]) await tx.query(`DELETE FROM ${table} WHERE user_id = $1`, [userId]);
424
+ await this.deleteSubjectRows(tx, userId);
273
425
  await tx.query(`UPDATE ${this.t.erasure} SET erased_at = COALESCE(erased_at, ${TS("$2")}) WHERE user_id = $1`, [userId, at]);
274
426
  });
275
427
  }
428
+ /** Everything kept about a user or visitor, on a transaction holding their lock. */
429
+ async deleteSubjectRows(tx, subject) {
430
+ for (const child of [
431
+ this.t.messageOpen,
432
+ this.t.messageClick,
433
+ this.t.messageLink
434
+ ]) await tx.query(`DELETE FROM ${child} WHERE message_id IN (SELECT id FROM ${this.t.message} WHERE user_id = $1)`, [subject]);
435
+ for (const table of [
436
+ this.t.message,
437
+ this.t.instance,
438
+ this.t.entry,
439
+ this.t.execution,
440
+ this.t.subscription,
441
+ this.t.segmentStart,
442
+ this.t.segmentMember,
443
+ this.t.suppression,
444
+ this.t.device,
445
+ this.t.event,
446
+ this.t.profile
447
+ ]) await tx.query(`DELETE FROM ${table} WHERE user_id = $1`, [subject]);
448
+ const visitorId = visitorIdOf(subject);
449
+ if (visitorId !== void 0) await tx.query(`DELETE FROM ${this.t.device} WHERE visitor_id = $1 AND user_id IS NULL`, [visitorId]);
450
+ }
276
451
  async isSuppressed(userId, channel, topic) {
277
452
  return (await this.sql.query(`SELECT 1 AS x FROM ${this.t.suppression}
278
453
  WHERE user_id = $1 AND channel = $2 AND (topic IS NULL OR topic = $3::text)`, [
@@ -305,12 +480,14 @@ var PostgresJourneyStore = class {
305
480
  });
306
481
  }
307
482
  async removeDevice(token) {
308
- return (await this.sql.query(`DELETE FROM ${this.t.device} WHERE token = $1 RETURNING user_id`, [token]))[0]?.user_id ?? void 0;
483
+ const rows = await this.sql.query(`DELETE FROM ${this.t.device} WHERE token = $1 RETURNING user_id, visitor_id`, [token]);
484
+ return rows[0] === void 0 ? void 0 : deviceOwner(rows[0]);
309
485
  }
310
486
  async unbindDevice(token) {
311
- return (await this.sql.query(`WITH old AS (SELECT token, user_id FROM ${this.t.device} WHERE token = $1 FOR UPDATE)
312
- UPDATE ${this.t.device} d SET user_id = NULL FROM old WHERE d.token = old.token
313
- RETURNING old.user_id`, [token]))[0]?.user_id ?? void 0;
487
+ const rows = await this.sql.query(`WITH old AS (SELECT token, user_id, visitor_id FROM ${this.t.device} WHERE token = $1 FOR UPDATE)
488
+ UPDATE ${this.t.device} d SET user_id = NULL, visitor_id = NULL, updated_at = ${TS("$2")} FROM old WHERE d.token = old.token
489
+ RETURNING old.user_id, old.visitor_id`, [token, Date.now()]);
490
+ return rows[0] === void 0 ? void 0 : deviceOwner(rows[0]);
314
491
  }
315
492
  async listSegments() {
316
493
  return (await this.sql.query(`SELECT id, condition FROM ${this.t.segment} ORDER BY id`)).map((row) => ({
@@ -323,11 +500,12 @@ var PostgresJourneyStore = class {
323
500
  return row === void 0 ? void 0 : ConditionIR.parse(asJson(row.condition));
324
501
  }
325
502
  async findTriggers(event) {
326
- return (await this.sql.query(`SELECT workflow, hash, "where" AS where_clause, filter, version FROM ${this.t.trigger} WHERE event = $1`, [event])).map((row) => ({
503
+ return (await this.sql.query(`SELECT workflow, hash, "where" AS where_clause, filter, visitors, version FROM ${this.t.trigger} WHERE event = $1`, [event])).map((row) => ({
327
504
  workflow: row.workflow,
328
505
  hash: row.hash,
329
506
  where: row.where_clause === null ? null : ConditionIR.parse(asJson(row.where_clause)),
330
507
  filter: row.filter === null ? null : ConditionIR.parse(asJson(row.filter)),
508
+ visitors: row.visitors === true,
331
509
  version: row.version ?? void 0
332
510
  }));
333
511
  }
@@ -347,8 +525,18 @@ var PostgresJourneyStore = class {
347
525
  const row = (await this.sql.query(`SELECT ir FROM ${this.t.ir} WHERE content_hash = $1`, [contentHash])).at(0);
348
526
  return row === void 0 ? void 0 : WorkflowIR.parse(asJson(row.ir));
349
527
  }
350
- replaceRoutes(bundle) {
351
- return this.sql.transaction((tx) => this.swapRoutes(tx, bundle));
528
+ replaceRoutes(bundle, version) {
529
+ return this.sql.transaction(async (tx) => {
530
+ if (version !== void 0) await tx.query(`INSERT INTO ${this.t.deployment} (scope, version_id, version_created_at, applied_at)
531
+ VALUES ('routes', $1, ${TS("$2")}, ${TS("$3")})
532
+ ON CONFLICT (scope) DO UPDATE SET version_id = EXCLUDED.version_id,
533
+ version_created_at = EXCLUDED.version_created_at, applied_at = EXCLUDED.applied_at`, [
534
+ version.id,
535
+ version.createdAt,
536
+ Date.now()
537
+ ]);
538
+ await this.swapRoutes(tx, bundle, version?.id);
539
+ });
352
540
  }
353
541
  async liveBundleVersion() {
354
542
  const row = (await this.sql.transaction((tx) => tx.query(`SELECT version_id, (extract(epoch FROM version_created_at) * 1000)::double precision AS created_at
@@ -384,12 +572,13 @@ var PostgresJourneyStore = class {
384
572
  JSON.stringify(segment.condition),
385
573
  segment.contentHash
386
574
  ]);
387
- for (const workflow of bundle.workflows) if (workflow.trigger.type === "event") await tx.query(`INSERT INTO ${this.t.trigger} (workflow, hash, event, "where", filter, version) VALUES ($1, $2, $3, $4::text::jsonb, $5::text::jsonb, $6)`, [
575
+ for (const workflow of bundle.workflows) if (workflow.trigger.type === "event") await tx.query(`INSERT INTO ${this.t.trigger} (workflow, hash, event, "where", filter, visitors, version) VALUES ($1, $2, $3, $4::text::jsonb, $5::text::jsonb, $6, $7)`, [
388
576
  workflow.id,
389
577
  workflow.contentHash,
390
578
  workflow.trigger.event,
391
579
  workflow.trigger.where === void 0 ? null : JSON.stringify(workflow.trigger.where),
392
580
  workflow.trigger.filter === void 0 ? null : JSON.stringify(workflow.trigger.filter),
581
+ workflow.trigger.visitors === true,
393
582
  version ?? null
394
583
  ]);
395
584
  else if (workflow.trigger.type === "segment") await tx.query(`INSERT INTO ${this.t.segmentTrigger} (workflow, hash, segment, version) VALUES ($1, $2, $3, $4)`, [
@@ -448,17 +637,19 @@ var PostgresJourneyStore = class {
448
637
  });
449
638
  }
450
639
  async claimEntry(sql, entry) {
451
- if ((await sql.query(`INSERT INTO ${this.t.entry} (workflow, user_id, instance_id, hash, status, ts)
452
- SELECT $1, $2, $3, $4, 'running', ${TS("$5")} ${this.notErased("$2")}
640
+ const claimedAt = Date.now();
641
+ if ((await sql.query(`INSERT INTO ${this.t.entry} (workflow, user_id, instance_id, hash, status, ts, claimed_at)
642
+ SELECT $1, $2, $3, $4, 'running', ${TS("$5")}, ${TS("$6")} ${this.notErased("$2")}
453
643
  ON CONFLICT (workflow, user_id) DO NOTHING RETURNING instance_id`, [
454
644
  entry.workflow,
455
645
  entry.userId,
456
646
  entry.instanceId,
457
647
  entry.hash,
458
- entry.ts
648
+ entry.ts,
649
+ claimedAt
459
650
  ])).length > 0) return "entered";
460
651
  if ((await sql.query(`UPDATE ${this.t.entry} SET instance_id = $1, hash = $2, status = 'running', ts = ${TS("$3")},
461
- admitted_at = NULL
652
+ admitted_at = NULL, claimed_at = ${TS("$6")}
462
653
  WHERE workflow = $4 AND user_id = $5 AND status = 'failed'
463
654
  AND NOT EXISTS (SELECT 1 FROM ${this.t.erasure} WHERE user_id = $5)
464
655
  RETURNING instance_id`, [
@@ -466,7 +657,8 @@ var PostgresJourneyStore = class {
466
657
  entry.hash,
467
658
  entry.ts,
468
659
  entry.workflow,
469
- entry.userId
660
+ entry.userId,
661
+ claimedAt
470
662
  ])).length > 0) return "reclaimed";
471
663
  await this.refuseIfErased(sql, entry.userId);
472
664
  return null;
@@ -497,6 +689,30 @@ var PostgresJourneyStore = class {
497
689
  async removeEntry(instanceId) {
498
690
  await this.sql.query(`DELETE FROM ${this.t.entry} WHERE instance_id = $1`, [instanceId]);
499
691
  }
692
+ async getEntry(workflow, userId) {
693
+ const row = (await this.sql.transaction((tx) => tx.query(`SELECT instance_id, status, admitted_at IS NOT NULL AS admitted
694
+ FROM ${this.t.entry} WHERE workflow = $1 AND user_id = $2`, [workflow, userId]))).at(0);
695
+ return row === void 0 ? void 0 : {
696
+ instanceId: row.instance_id,
697
+ status: row.status,
698
+ admitted: row.admitted
699
+ };
700
+ }
701
+ async unadmittedEntries(claimedBefore, limit) {
702
+ return (await this.sql.query(`SELECT instance_id, workflow, user_id FROM ${this.t.entry}
703
+ WHERE admitted_at IS NULL AND status = 'running'
704
+ AND COALESCE(claimed_at, ts) < ${TS("$1")}
705
+ ORDER BY COALESCE(claimed_at, ts) LIMIT $2`, [claimedBefore, limit])).map((row) => ({
706
+ instanceId: row.instance_id,
707
+ workflow: row.workflow,
708
+ userId: row.user_id
709
+ }));
710
+ }
711
+ async failUnadmittedEntry(instanceId) {
712
+ return (await this.sql.query(`UPDATE ${this.t.entry} SET status = 'failed'
713
+ WHERE instance_id = $1 AND status = 'running' AND admitted_at IS NULL
714
+ RETURNING instance_id`, [instanceId])).length > 0;
715
+ }
500
716
  async setEntryStatus(instanceId, status) {
501
717
  await this.sql.query(`UPDATE ${this.t.entry} SET status = $1 WHERE instance_id = $2`, [status, instanceId]);
502
718
  }