@shware/workflow 7.0.0 → 9.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 (166) hide show
  1. package/README.md +125 -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 -3
  9. package/dist/aws/index.cjs.map +1 -1
  10. package/dist/aws/index.mjs +3 -3
  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 +5 -4
  21. package/dist/cloudflare/index.d.mts +5 -4
  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 +12 -1
  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 +12 -1
  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/engine/condition.cjs +3 -3
  49. package/dist/engine/condition.cjs.map +1 -1
  50. package/dist/engine/condition.d.cts +3 -3
  51. package/dist/engine/condition.d.mts +3 -3
  52. package/dist/engine/condition.mjs +3 -3
  53. package/dist/engine/condition.mjs.map +1 -1
  54. package/dist/engine/interpreter.cjs +9 -5
  55. package/dist/engine/interpreter.cjs.map +1 -1
  56. package/dist/engine/interpreter.d.cts.map +1 -1
  57. package/dist/engine/interpreter.d.mts.map +1 -1
  58. package/dist/engine/interpreter.mjs +9 -5
  59. package/dist/engine/interpreter.mjs.map +1 -1
  60. package/dist/engine/ports.d.cts +2 -0
  61. package/dist/engine/ports.d.cts.map +1 -1
  62. package/dist/engine/ports.d.mts +2 -0
  63. package/dist/engine/ports.d.mts.map +1 -1
  64. package/dist/host/batch.cjs +172 -0
  65. package/dist/host/batch.cjs.map +1 -0
  66. package/dist/host/batch.d.cts +88 -0
  67. package/dist/host/batch.d.cts.map +1 -0
  68. package/dist/host/batch.d.mts +88 -0
  69. package/dist/host/batch.d.mts.map +1 -0
  70. package/dist/host/batch.mjs +163 -0
  71. package/dist/host/batch.mjs.map +1 -0
  72. package/dist/host/execute.cjs +10 -6
  73. package/dist/host/execute.cjs.map +1 -1
  74. package/dist/host/execute.mjs +10 -6
  75. package/dist/host/execute.mjs.map +1 -1
  76. package/dist/host/http.cjs +30 -89
  77. package/dist/host/http.cjs.map +1 -1
  78. package/dist/host/http.d.cts +1 -1
  79. package/dist/host/http.d.cts.map +1 -1
  80. package/dist/host/http.d.mts +1 -1
  81. package/dist/host/http.d.mts.map +1 -1
  82. package/dist/host/http.mjs +24 -83
  83. package/dist/host/http.mjs.map +1 -1
  84. package/dist/host/ingest.cjs +84 -35
  85. package/dist/host/ingest.cjs.map +1 -1
  86. package/dist/host/ingest.d.cts +29 -16
  87. package/dist/host/ingest.d.cts.map +1 -1
  88. package/dist/host/ingest.d.mts +29 -16
  89. package/dist/host/ingest.d.mts.map +1 -1
  90. package/dist/host/ingest.mjs +83 -36
  91. package/dist/host/ingest.mjs.map +1 -1
  92. package/dist/host/journey-host.cjs +6 -0
  93. package/dist/host/journey-host.cjs.map +1 -1
  94. package/dist/host/journey-host.d.cts +5 -2
  95. package/dist/host/journey-host.d.cts.map +1 -1
  96. package/dist/host/journey-host.d.mts +5 -2
  97. package/dist/host/journey-host.d.mts.map +1 -1
  98. package/dist/host/journey-host.mjs +6 -0
  99. package/dist/host/journey-host.mjs.map +1 -1
  100. package/dist/host/platform.cjs.map +1 -1
  101. package/dist/host/platform.d.cts +2 -0
  102. package/dist/host/platform.d.cts.map +1 -1
  103. package/dist/host/platform.d.mts +2 -0
  104. package/dist/host/platform.d.mts.map +1 -1
  105. package/dist/host/platform.mjs.map +1 -1
  106. package/dist/ir.cjs +2 -1
  107. package/dist/ir.cjs.map +1 -1
  108. package/dist/ir.d.cts +3 -0
  109. package/dist/ir.d.cts.map +1 -1
  110. package/dist/ir.d.mts +3 -0
  111. package/dist/ir.d.mts.map +1 -1
  112. package/dist/ir.mjs +2 -1
  113. package/dist/ir.mjs.map +1 -1
  114. package/dist/store/condition-sql.cjs +144 -0
  115. package/dist/store/condition-sql.cjs.map +1 -0
  116. package/dist/store/condition-sql.d.cts +38 -0
  117. package/dist/store/condition-sql.d.cts.map +1 -0
  118. package/dist/store/condition-sql.d.mts +38 -0
  119. package/dist/store/condition-sql.d.mts.map +1 -0
  120. package/dist/store/condition-sql.mjs +143 -0
  121. package/dist/store/condition-sql.mjs.map +1 -0
  122. package/dist/store/facts.cjs +13 -9
  123. package/dist/store/facts.cjs.map +1 -1
  124. package/dist/store/facts.d.cts +6 -2
  125. package/dist/store/facts.d.cts.map +1 -1
  126. package/dist/store/facts.d.mts +6 -2
  127. package/dist/store/facts.d.mts.map +1 -1
  128. package/dist/store/facts.mjs +13 -9
  129. package/dist/store/facts.mjs.map +1 -1
  130. package/dist/store/identity.cjs +34 -0
  131. package/dist/store/identity.cjs.map +1 -0
  132. package/dist/store/identity.d.cts +13 -0
  133. package/dist/store/identity.d.cts.map +1 -0
  134. package/dist/store/identity.d.mts +13 -0
  135. package/dist/store/identity.d.mts.map +1 -0
  136. package/dist/store/identity.mjs +30 -0
  137. package/dist/store/identity.mjs.map +1 -0
  138. package/dist/store/index.cjs +5 -0
  139. package/dist/store/index.cjs.map +1 -1
  140. package/dist/store/index.d.cts +67 -21
  141. package/dist/store/index.d.cts.map +1 -1
  142. package/dist/store/index.d.mts +67 -21
  143. package/dist/store/index.d.mts.map +1 -1
  144. package/dist/store/index.mjs +2 -1
  145. package/dist/store/index.mjs.map +1 -1
  146. package/dist/store/postgres.cjs +248 -54
  147. package/dist/store/postgres.cjs.map +1 -1
  148. package/dist/store/postgres.d.cts +25 -7
  149. package/dist/store/postgres.d.cts.map +1 -1
  150. package/dist/store/postgres.d.mts +25 -7
  151. package/dist/store/postgres.d.mts.map +1 -1
  152. package/dist/store/postgres.mjs +249 -55
  153. package/dist/store/postgres.mjs.map +1 -1
  154. package/dist/store/tables.cjs +24 -6
  155. package/dist/store/tables.cjs.map +1 -1
  156. package/dist/store/tables.d.cts +1 -0
  157. package/dist/store/tables.d.cts.map +1 -1
  158. package/dist/store/tables.d.mts +1 -0
  159. package/dist/store/tables.d.mts.map +1 -1
  160. package/dist/store/tables.mjs +24 -6
  161. package/dist/store/tables.mjs.map +1 -1
  162. package/dist/webpush.cjs.map +1 -1
  163. package/dist/webpush.d.cts +1 -1
  164. package/dist/webpush.d.mts +1 -1
  165. package/dist/webpush.mjs.map +1 -1
  166. package/package.json +1 -1
@@ -1,10 +1,17 @@
1
1
  import { ConditionIR, WorkflowIR } from "../ir.mjs";
2
2
  import { UserErasedError } from "./erasure.mjs";
3
+ import { visitorIdOf, visitorSubject } from "./identity.mjs";
4
+ import { conditionToSql } from "./condition-sql.mjs";
3
5
  import { uuidv7 } from "./sql.mjs";
4
- import { lockUserTransaction } from "./user-lock.mjs";
6
+ import { beginReadCommitted, lockUser, lockUserTransaction } from "./user-lock.mjs";
5
7
  import { PostgresReporting } from "./postgres-reporting.mjs";
6
8
  import { DEFAULT_SCHEMA, TABLES, columnName, indexName, uniqueConstraintName } from "./tables.mjs";
7
9
  // src/store/postgres.ts
10
+ /** Who a device row belongs to: its user, or before sign-in its visitor. */
11
+ function deviceOwner(row) {
12
+ if (row.user_id !== null) return row.user_id;
13
+ return row.visitor_id === null ? void 0 : visitorSubject(row.visitor_id);
14
+ }
8
15
  /** information_schema's udt_name per column type. */
9
16
  const UDT = {
10
17
  uuid: "uuid",
@@ -184,51 +191,228 @@ var PostgresJourneyStore = class {
184
191
  const row = (await this.sql.query(`SELECT props FROM ${this.t.profile} WHERE user_id = $1`, [userId])).at(0);
185
192
  return row === void 0 ? void 0 : asJson(row.props);
186
193
  }
187
- async mergeProfile(userId, props) {
194
+ async mergeProfile(userId, props, at = Date.now()) {
188
195
  return this.sql.transaction(async (tx) => {
189
196
  await this.lockUser(tx, userId);
190
- const rows = await tx.query(`INSERT INTO ${this.t.profile} (user_id, props)
191
- SELECT $1, jsonb_strip_nulls($2::text::jsonb) ${this.notErased("$1")}
192
- ON CONFLICT (user_id) DO UPDATE SET props = jsonb_strip_nulls(${this.t.profile}.props || $3::text::jsonb)
197
+ const times = asJson((await tx.query(`SELECT props_at FROM ${this.t.profile} WHERE user_id = $1`, [userId]))[0]?.props_at ?? null) ?? {};
198
+ const patch = Object.fromEntries(Object.entries(props).filter(([key]) => (times[key] ?? Number.NEGATIVE_INFINITY) <= at));
199
+ const stamped = {
200
+ ...times,
201
+ ...Object.fromEntries(Object.keys(patch).map((k) => [k, at]))
202
+ };
203
+ const rows = await tx.query(`INSERT INTO ${this.t.profile} (user_id, props, props_at)
204
+ SELECT $1, jsonb_strip_nulls($2::text::jsonb), $4::text::jsonb ${this.notErased("$1")}
205
+ ON CONFLICT (user_id) DO UPDATE SET
206
+ props = jsonb_strip_nulls(${this.t.profile}.props || $3::text::jsonb),
207
+ props_at = $4::text::jsonb
193
208
  RETURNING props`, [
194
209
  userId,
195
- JSON.stringify(props),
196
- JSON.stringify(props)
210
+ JSON.stringify(patch),
211
+ JSON.stringify(patch),
212
+ JSON.stringify(stamped)
197
213
  ]);
198
214
  if (rows.length === 0) throw new UserErasedError(userId);
199
215
  return asJson(rows[0].props);
200
216
  });
201
217
  }
202
- async listUserIds(page) {
218
+ async listSegmentCandidates(page) {
219
+ if (page.segments.length === 0) return [];
203
220
  const params = [];
204
- let where = "";
205
- if (page?.after !== void 0) {
206
- params.push(page.after);
207
- where = ` WHERE user_id > $${params.length}`;
208
- }
209
- let limit = "";
210
- if (page?.limit !== void 0) {
211
- params.push(page.limit);
212
- limit = ` LIMIT $${params.length}`;
213
- }
214
- 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);
221
+ const bind = (value) => {
222
+ params.push(value);
223
+ return `$${params.length}`;
224
+ };
225
+ const definitions = new Map(page.segments.map((segment) => [segment.id, segment.condition]));
226
+ const branches = page.segments.map(({ id, condition }) => {
227
+ const segment = bind(id);
228
+ const matches = conditionToSql(condition, {
229
+ tables: this.t,
230
+ user: "u.user_id",
231
+ props: "p.props",
232
+ nowMs: page.now,
233
+ segments: definitions,
234
+ bind
235
+ });
236
+ const key = `segment = ${segment}::text AND user_id = u.user_id`;
237
+ return `SELECT ${segment}::text AS segment
238
+ WHERE (${matches}) IS DISTINCT FROM EXISTS (SELECT 1 FROM ${this.t.segmentMember} WHERE ${key})
239
+ OR EXISTS (SELECT 1 FROM ${this.t.segmentStart} WHERE ${key})`;
240
+ });
241
+ let keyset = "";
242
+ if (page.after !== void 0) keyset = ` AND user_id > ${bind(page.after)}::text`;
243
+ return (await this.sql.query(`WITH u AS MATERIALIZED (
244
+ SELECT user_id FROM (
245
+ SELECT user_id FROM ${this.t.profile}
246
+ UNION SELECT user_id FROM ${this.t.event}
247
+ UNION SELECT user_id FROM ${this.t.segmentMember}
248
+ ) known
249
+ -- Segments are about users: a visitor is not one until they sign in
250
+ WHERE user_id NOT LIKE '${visitorSubject("")}%'${keyset}
251
+ )
252
+ SELECT u.user_id, json_agg(c.segment) AS segments
253
+ FROM u
254
+ LEFT JOIN ${this.t.profile} p ON p.user_id = u.user_id
255
+ CROSS JOIN LATERAL (${branches.join("\n UNION ALL ")}) c
256
+ GROUP BY u.user_id
257
+ ORDER BY u.user_id
258
+ LIMIT ${bind(page.limit)}::int`, params)).map((row) => ({
259
+ userId: row.user_id,
260
+ segments: asJson(row.segments)
261
+ }));
215
262
  }
216
- listDevices(userId) {
217
- return this.sql.query(`SELECT token, platform FROM ${this.t.device} WHERE user_id = $1 ORDER BY ts DESC`, [userId]);
263
+ listDevices(subject) {
264
+ const visitorId = visitorIdOf(subject);
265
+ 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]);
218
266
  }
219
267
  async upsertDevice(device) {
220
- await this.sql.transaction(async (tx) => {
221
- await this.lockUser(tx, device.userId);
222
- if ((await tx.query(`INSERT INTO ${this.t.device} (token, user_id, platform, ts)
223
- SELECT $1, $2, $3, ${TS("$4")} ${this.notErased("$2")}
224
- ON CONFLICT (token) DO UPDATE SET user_id = EXCLUDED.user_id, platform = EXCLUDED.platform, ts = EXCLUDED.ts
268
+ const { deviceId = null, userId, visitorId = null, token, platform, at } = device;
269
+ const subject = userId ?? visitorSubject(visitorId ?? "");
270
+ return this.sql.transaction(async (tx) => {
271
+ await this.lockUser(tx, subject);
272
+ 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]);
273
+ for (const row of previous) {
274
+ if (row.token === token) continue;
275
+ const owner = deviceOwner(row);
276
+ 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]);
277
+ }
278
+ if ((await tx.query(`INSERT INTO ${this.t.device} (device_id, user_id, visitor_id, token, platform, created_at, updated_at)
279
+ SELECT $1, $2, $3, $4, $5, ${TS("$6")}, ${TS("$6")} ${this.notErased("$7")}
280
+ ON CONFLICT (token) DO UPDATE SET
281
+ device_id = COALESCE(EXCLUDED.device_id, ${this.t.device}.device_id),
282
+ user_id = EXCLUDED.user_id,
283
+ visitor_id = COALESCE(EXCLUDED.visitor_id, ${this.t.device}.visitor_id),
284
+ platform = EXCLUDED.platform,
285
+ updated_at = EXCLUDED.updated_at
225
286
  RETURNING token`, [
226
- device.token,
227
- device.userId,
228
- device.platform,
229
- device.ts
230
- ])).length === 0) throw new UserErasedError(device.userId);
287
+ deviceId,
288
+ userId,
289
+ visitorId,
290
+ token,
291
+ platform,
292
+ at,
293
+ subject
294
+ ])).length === 0) throw new UserErasedError(subject);
295
+ const owners = previous.flatMap((row) => {
296
+ const owner = deviceOwner(row);
297
+ return owner === void 0 || owner === subject ? [] : [owner];
298
+ });
299
+ return [...new Set(owners)];
300
+ });
301
+ }
302
+ async linkedUser(visitorId) {
303
+ return (await this.sql.query(`SELECT user_id FROM ${this.t.visitor} WHERE visitor_id = $1`, [visitorId]))[0]?.user_id ?? void 0;
304
+ }
305
+ async touchVisitor(visitorId, at) {
306
+ return (await this.sql.query(`INSERT INTO ${this.t.visitor} (visitor_id, user_id, created_at, updated_at)
307
+ VALUES ($1, NULL, ${TS("$2")}, ${TS("$2")})
308
+ ON CONFLICT (visitor_id) DO UPDATE SET updated_at = EXCLUDED.updated_at
309
+ RETURNING user_id`, [visitorId, at]))[0]?.user_id ?? visitorSubject(visitorId);
310
+ }
311
+ async pruneVisitors(before, limit) {
312
+ const idle = `user_id IS NULL AND updated_at < ${TS("$1")} AND NOT EXISTS (
313
+ SELECT 1 FROM ${this.t.entry} e
314
+ WHERE e.user_id = '${visitorSubject("")}' || visitor_id AND e.status = 'running')`;
315
+ const candidates = await this.sql.query(`SELECT visitor_id FROM ${this.t.visitor} WHERE ${idle} ORDER BY updated_at LIMIT $2`, [before, limit]);
316
+ let pruned = 0;
317
+ for (const { visitor_id: visitorId } of candidates) {
318
+ const subject = visitorSubject(visitorId);
319
+ await this.sql.transaction(async (tx) => {
320
+ await this.lockUser(tx, subject);
321
+ if ((await tx.query(`DELETE FROM ${this.t.visitor} WHERE visitor_id = $2 AND ${idle} RETURNING visitor_id`, [before, visitorId])).length === 0) return;
322
+ await this.deleteSubjectRows(tx, subject);
323
+ pruned += 1;
324
+ });
325
+ }
326
+ return pruned;
327
+ }
328
+ async entryOwner(instanceId) {
329
+ return (await this.sql.query(`SELECT user_id FROM ${this.t.entry} WHERE instance_id = $1`, [instanceId]))[0]?.user_id;
330
+ }
331
+ async linkVisitor(visitorId, userId, at) {
332
+ const subject = visitorSubject(visitorId);
333
+ if (await this.linkedUser(visitorId) !== void 0) return null;
334
+ return this.sql.transaction(async (tx) => {
335
+ await beginReadCommitted(tx);
336
+ for (const key of [subject, userId].sort()) await lockUser(tx, this.schema, key);
337
+ if ((await tx.query(`INSERT INTO ${this.t.visitor} (visitor_id, user_id, created_at, updated_at)
338
+ SELECT $1, $2, ${TS("$3")}, ${TS("$3")} ${this.notErased("$2")}
339
+ ON CONFLICT (visitor_id) DO UPDATE SET user_id = EXCLUDED.user_id, updated_at = EXCLUDED.updated_at
340
+ WHERE ${this.t.visitor}.user_id IS NULL
341
+ RETURNING visitor_id`, [
342
+ visitorId,
343
+ userId,
344
+ at
345
+ ])).length === 0) {
346
+ if ((await tx.query(`SELECT 1 FROM ${this.t.erasure} WHERE user_id = $1`, [userId])).length > 0) throw new UserErasedError(userId);
347
+ return null;
348
+ }
349
+ const droppedIds = (await tx.query(`DELETE FROM ${this.t.entry} v USING ${this.t.entry} u
350
+ WHERE v.user_id = $1 AND u.user_id = $2 AND u.workflow = v.workflow
351
+ RETURNING v.instance_id`, [subject, userId])).map((row) => row.instance_id);
352
+ await tx.query(`DELETE FROM ${this.t.subscription} WHERE wake_handle = ANY($1::text[])`, [droppedIds]);
353
+ await tx.query(`DELETE FROM ${this.t.execution} WHERE instance_id = ANY($1::text[])`, [droppedIds]);
354
+ await tx.query(`DELETE FROM ${this.t.event} v USING ${this.t.event} u
355
+ WHERE v.user_id = $1 AND u.user_id = $2 AND u.event_id = v.event_id`, [subject, userId]);
356
+ const events = await tx.query(`SELECT DISTINCT name FROM ${this.t.event} WHERE user_id = $1`, [subject]);
357
+ await tx.query(`INSERT INTO ${this.t.suppression} (user_id, channel, topic, ts)
358
+ SELECT $2, channel, topic, ts FROM ${this.t.suppression} WHERE user_id = $1
359
+ ON CONFLICT (user_id, channel, topic) DO NOTHING`, [subject, userId]);
360
+ await tx.query(`DELETE FROM ${this.t.suppression} WHERE user_id = $1`, [subject]);
361
+ const props = await this.mergeProfileInto(tx, subject, userId);
362
+ for (const table of [
363
+ this.t.event,
364
+ this.t.entry,
365
+ this.t.execution,
366
+ this.t.subscription,
367
+ this.t.instance,
368
+ this.t.message
369
+ ]) await tx.query(`UPDATE ${table} SET user_id = $2 WHERE user_id = $1`, [subject, userId]);
370
+ await tx.query(`UPDATE ${this.t.device} SET user_id = $2, updated_at = ${TS("$3")}
371
+ WHERE visitor_id = $1 AND user_id IS NULL`, [
372
+ visitorId,
373
+ userId,
374
+ at
375
+ ]);
376
+ return {
377
+ dropped: droppedIds,
378
+ events: events.map((row) => row.name),
379
+ props
380
+ };
381
+ });
382
+ }
383
+ /**
384
+ * The visitor's properties under the user's, a key the user has a time for going to the later
385
+ * write, one a row older than the times going to the user if they hold it. The visitor's row
386
+ * goes. Answers the keys that moved.
387
+ */
388
+ async mergeProfileInto(tx, subject, userId) {
389
+ const rows = await tx.query(`SELECT user_id, props, props_at FROM ${this.t.profile} WHERE user_id = ANY($1::text[])`, [[subject, userId]]);
390
+ const from = rows.find((row) => row.user_id === subject);
391
+ if (from === void 0) return [];
392
+ const to = rows.find((row) => row.user_id === userId);
393
+ const fromTimes = asJson(from.props_at ?? null) ?? {};
394
+ const toTimes = asJson(to?.props_at ?? null) ?? {};
395
+ const toProps = to === void 0 ? {} : asJson(to.props);
396
+ const moved = Object.entries(asJson(from.props)).filter(([key]) => {
397
+ const written = toTimes[key];
398
+ return written === void 0 ? !Object.hasOwn(toProps, key) : (fromTimes[key] ?? 0) > written;
231
399
  });
400
+ const props = {
401
+ ...toProps,
402
+ ...Object.fromEntries(moved)
403
+ };
404
+ const times = {
405
+ ...toTimes,
406
+ ...Object.fromEntries(moved.map(([key]) => [key, fromTimes[key] ?? 0]))
407
+ };
408
+ await tx.query(`INSERT INTO ${this.t.profile} (user_id, props, props_at) VALUES ($1, $2::text::jsonb, $3::text::jsonb)
409
+ ON CONFLICT (user_id) DO UPDATE SET props = EXCLUDED.props, props_at = EXCLUDED.props_at`, [
410
+ userId,
411
+ JSON.stringify(props),
412
+ JSON.stringify(times)
413
+ ]);
414
+ await tx.query(`DELETE FROM ${this.t.profile} WHERE user_id = $1`, [subject]);
415
+ return moved.map(([key]) => key);
232
416
  }
233
417
  async requestErasure(userId, at) {
234
418
  await this.sql.transaction(async (tx) => {
@@ -269,27 +453,33 @@ var PostgresJourneyStore = class {
269
453
  async forgetUser(userId, at) {
270
454
  await this.sql.transaction(async (tx) => {
271
455
  await this.lockUser(tx, userId);
272
- for (const child of [
273
- this.t.messageOpen,
274
- this.t.messageClick,
275
- this.t.messageLink
276
- ]) await tx.query(`DELETE FROM ${child} WHERE message_id IN (SELECT id FROM ${this.t.message} WHERE user_id = $1)`, [userId]);
277
- for (const table of [
278
- this.t.message,
279
- this.t.instance,
280
- this.t.entry,
281
- this.t.execution,
282
- this.t.subscription,
283
- this.t.segmentStart,
284
- this.t.segmentMember,
285
- this.t.suppression,
286
- this.t.device,
287
- this.t.event,
288
- this.t.profile
289
- ]) await tx.query(`DELETE FROM ${table} WHERE user_id = $1`, [userId]);
456
+ await this.deleteSubjectRows(tx, userId);
290
457
  await tx.query(`UPDATE ${this.t.erasure} SET erased_at = COALESCE(erased_at, ${TS("$2")}) WHERE user_id = $1`, [userId, at]);
291
458
  });
292
459
  }
460
+ /** Everything kept about a user or visitor, on a transaction holding their lock. */
461
+ async deleteSubjectRows(tx, subject) {
462
+ for (const child of [
463
+ this.t.messageOpen,
464
+ this.t.messageClick,
465
+ this.t.messageLink
466
+ ]) await tx.query(`DELETE FROM ${child} WHERE message_id IN (SELECT id FROM ${this.t.message} WHERE user_id = $1)`, [subject]);
467
+ for (const table of [
468
+ this.t.message,
469
+ this.t.instance,
470
+ this.t.entry,
471
+ this.t.execution,
472
+ this.t.subscription,
473
+ this.t.segmentStart,
474
+ this.t.segmentMember,
475
+ this.t.suppression,
476
+ this.t.device,
477
+ this.t.event,
478
+ this.t.profile
479
+ ]) await tx.query(`DELETE FROM ${table} WHERE user_id = $1`, [subject]);
480
+ const visitorId = visitorIdOf(subject);
481
+ if (visitorId !== void 0) await tx.query(`DELETE FROM ${this.t.device} WHERE visitor_id = $1 AND user_id IS NULL`, [visitorId]);
482
+ }
293
483
  async isSuppressed(userId, channel, topic) {
294
484
  return (await this.sql.query(`SELECT 1 AS x FROM ${this.t.suppression}
295
485
  WHERE user_id = $1 AND channel = $2 AND (topic IS NULL OR topic = $3::text)`, [
@@ -322,12 +512,14 @@ var PostgresJourneyStore = class {
322
512
  });
323
513
  }
324
514
  async removeDevice(token) {
325
- return (await this.sql.query(`DELETE FROM ${this.t.device} WHERE token = $1 RETURNING user_id`, [token]))[0]?.user_id ?? void 0;
515
+ const rows = await this.sql.query(`DELETE FROM ${this.t.device} WHERE token = $1 RETURNING user_id, visitor_id`, [token]);
516
+ return rows[0] === void 0 ? void 0 : deviceOwner(rows[0]);
326
517
  }
327
518
  async unbindDevice(token) {
328
- return (await this.sql.query(`WITH old AS (SELECT token, user_id FROM ${this.t.device} WHERE token = $1 FOR UPDATE)
329
- UPDATE ${this.t.device} d SET user_id = NULL FROM old WHERE d.token = old.token
330
- RETURNING old.user_id`, [token]))[0]?.user_id ?? void 0;
519
+ const rows = await this.sql.query(`WITH old AS (SELECT token, user_id, visitor_id FROM ${this.t.device} WHERE token = $1 FOR UPDATE)
520
+ UPDATE ${this.t.device} d SET user_id = NULL, visitor_id = NULL, updated_at = ${TS("$2")} FROM old WHERE d.token = old.token
521
+ RETURNING old.user_id, old.visitor_id`, [token, Date.now()]);
522
+ return rows[0] === void 0 ? void 0 : deviceOwner(rows[0]);
331
523
  }
332
524
  async listSegments() {
333
525
  return (await this.sql.query(`SELECT id, condition FROM ${this.t.segment} ORDER BY id`)).map((row) => ({
@@ -340,11 +532,12 @@ var PostgresJourneyStore = class {
340
532
  return row === void 0 ? void 0 : ConditionIR.parse(asJson(row.condition));
341
533
  }
342
534
  async findTriggers(event) {
343
- return (await this.sql.query(`SELECT workflow, hash, "where" AS where_clause, filter, version FROM ${this.t.trigger} WHERE event = $1`, [event])).map((row) => ({
535
+ 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) => ({
344
536
  workflow: row.workflow,
345
537
  hash: row.hash,
346
538
  where: row.where_clause === null ? null : ConditionIR.parse(asJson(row.where_clause)),
347
539
  filter: row.filter === null ? null : ConditionIR.parse(asJson(row.filter)),
540
+ visitors: row.visitors === true,
348
541
  version: row.version ?? void 0
349
542
  }));
350
543
  }
@@ -411,12 +604,13 @@ var PostgresJourneyStore = class {
411
604
  JSON.stringify(segment.condition),
412
605
  segment.contentHash
413
606
  ]);
414
- 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)`, [
607
+ 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)`, [
415
608
  workflow.id,
416
609
  workflow.contentHash,
417
610
  workflow.trigger.event,
418
611
  workflow.trigger.where === void 0 ? null : JSON.stringify(workflow.trigger.where),
419
612
  workflow.trigger.filter === void 0 ? null : JSON.stringify(workflow.trigger.filter),
613
+ workflow.trigger.visitors === true,
420
614
  version ?? null
421
615
  ]);
422
616
  else if (workflow.trigger.type === "segment") await tx.query(`INSERT INTO ${this.t.segmentTrigger} (workflow, hash, segment, version) VALUES ($1, $2, $3, $4)`, [